diff --git a/README.md b/README.md index 38479fcd..c33ff463 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,6 @@ there to easily launch an instance. ## Account management ![Email address management][screenshots-email_address_management] -## Member discounts -![MITOC members can receive discounts][screenshots-discounts] - ## Leader application ![Submitted application][screenshots-leader_application_submitted] @@ -78,7 +75,6 @@ trip formats once subject to same problems as Winter School. [screenshots-profile]: https://dcain.me/static/images/mitoc-trips/profile.png [screenshots-email_address_management]: https://dcain.me/static/images/mitoc-trips/email_address_management.png - [screenshots-discounts]: https://dcain.me/static/images/mitoc-trips/discounts.png [screenshots-leader_application_submitted]: https://dcain.me/static/images/mitoc-trips/leader_application_submitted.png [screenshots-leader_application_queue]: https://dcain.me/static/images/mitoc-trips/leader_application_queue.png [screenshots-leader_application]: https://dcain.me/static/images/mitoc-trips/leader_application.png diff --git a/poetry.lock b/poetry.lock index 742bac34..523f8689 100644 --- a/poetry.lock +++ b/poetry.lock @@ -117,17 +117,6 @@ files = [ {file = "billiard-4.2.0.tar.gz", hash = "sha256:9a3c3184cb275aa17a732f93f65b20c525d3d9f253722d26a82194803ade5a2c"}, ] -[[package]] -name = "cachetools" -version = "5.3.3" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, - {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, -] - [[package]] name = "celery" version = "5.4.0" @@ -838,62 +827,6 @@ files = [ [package.dependencies] python-dateutil = ">=2.7" -[[package]] -name = "google-auth" -version = "2.31.0" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google-auth-2.31.0.tar.gz", hash = "sha256:87805c36970047247c8afe614d4e3af8eceafc1ebba0c679fe75ddd1d575e871"}, - {file = "google_auth-2.31.0-py2.py3-none-any.whl", hash = "sha256:042c4702efa9f7d3c48d3a69341c209381b125faa6dbf3ebe56bc7e40ae05c23"}, -] - -[package.dependencies] -cachetools = ">=2.0.0,<6.0" -pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" - -[package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] -enterprise-cert = ["cryptography (==36.0.2)", "pyopenssl (==22.0.0)"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] -reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] - -[[package]] -name = "google-auth-oauthlib" -version = "1.2.0" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.6" -files = [ - {file = "google-auth-oauthlib-1.2.0.tar.gz", hash = "sha256:292d2d3783349f2b0734a0a0207b1e1e322ac193c2c09d8f7c613fb7cc501ea8"}, - {file = "google_auth_oauthlib-1.2.0-py2.py3-none-any.whl", hash = "sha256:297c1ce4cb13a99b5834c74a1fe03252e1e499716718b190f56bcb9c4abc4faf"}, -] - -[package.dependencies] -google-auth = ">=2.15.0" -requests-oauthlib = ">=0.7.0" - -[package.extras] -tool = ["click (>=6.0.0)"] - -[[package]] -name = "gspread" -version = "6.1.2" -description = "Google Spreadsheets Python API" -optional = false -python-versions = ">=3.8" -files = [ - {file = "gspread-6.1.2-py3-none-any.whl", hash = "sha256:345996fbb74051ee574e3d330a375ac625774f289459f73cb1f8b6fb3cf4cac5"}, - {file = "gspread-6.1.2.tar.gz", hash = "sha256:b147688b8c7a18c9835d5f998997ec17c97c0470babcab17f65ac2b3a32402b7"}, -] - -[package.dependencies] -google-auth = ">=1.12.0" -google-auth-oauthlib = ">=0.4.1" - [[package]] name = "gunicorn" version = "22.0.0" @@ -1558,31 +1491,6 @@ httpx = "*" docs = ["furo", "sphinx", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-notfound-page", "sphinxcontrib-django", "sphinxext-opengraph"] tests = ["coverage", "tomli"] -[[package]] -name = "pyasn1" -version = "0.6.0" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyasn1-0.6.0-py2.py3-none-any.whl", hash = "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473"}, - {file = "pyasn1-0.6.0.tar.gz", hash = "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c"}, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.0" -description = "A collection of ASN.1-based protocols modules" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyasn1_modules-0.4.0-py3-none-any.whl", hash = "sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b"}, - {file = "pyasn1_modules-0.4.0.tar.gz", hash = "sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6"}, -] - -[package.dependencies] -pyasn1 = ">=0.4.6,<0.7.0" - [[package]] name = "pycparser" version = "2.22" @@ -1842,20 +1750,6 @@ urllib3 = ">=1.25.10,<3.0" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] -[[package]] -name = "rsa" -version = "4.9" -description = "Pure-Python RSA implementation" -optional = false -python-versions = ">=3.6,<4" -files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - [[package]] name = "ruff" version = "0.5.1" @@ -2161,4 +2055,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10.0" -content-hash = "7f6798435fbec82b834c582b9420cf70299b6daba3241e24757d35e1eea904f0" +content-hash = "2f26560f84113e77c032aabcf71349e518e45afc67fb10bf0b74382001df8700" diff --git a/pyproject.toml b/pyproject.toml index 3b39823c..173da295 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,7 +161,6 @@ django-phonenumber-field = { version = ">= 2.0", extras = ["phonenumberslite"] } django-pipeline = "*" # TODO: To eventually be replaced by webpack-loader django-smtp-ssl = "*" django-webpack-loader = "^1.1.0" # Should maintain parity with frontend/package.json -gspread = "*" gunicorn = "^22.0.0" # Used to run production worker markdown2 = "*" mitoc-const = "^1.0.0" # (1.0.0 includes type hints) diff --git a/ws/api_views.py b/ws/api_views.py index 7e439b54..10218aa7 100644 --- a/ws/api_views.py +++ b/ws/api_views.py @@ -24,7 +24,7 @@ import ws.utils.membership as membership_utils import ws.utils.perms as perm_utils import ws.utils.signups as signup_utils -from ws import enums, models, tasks +from ws import enums, models from ws.decorators import group_required from ws.middleware import RequestWithParticipant from ws.mixins import JsonTripLeadersOnlyView, TripLeadersOnlyView @@ -568,9 +568,6 @@ def post(self, request, *args, **kwargs): if not participant: # Not in our system, nothing to do return JsonResponse({}) - # Can be true in case of early renewal! - was_already_active = participant.membership_active - keys = ("membership_expires", "waiver_expires") update_fields = { key: date.fromisoformat(self.payload[key]) @@ -579,14 +576,6 @@ def post(self, request, *args, **kwargs): } _membership, created = participant.update_membership(**update_fields) - # If the participant has reactivated a membership, update any discount sheets - # (this will ensure that they do not have to wait for the next daily refresh) - if not was_already_active: - for discount in participant.discounts.all(): - tasks.update_discount_sheet_for_participant.delay( - discount.pk, participant.pk - ) - return JsonResponse({}, status=201 if created else 200) @@ -654,7 +643,6 @@ class MemberInfo(TypedDict): is_leader: NotRequired[bool] num_trips_attended: NotRequired[int] num_trips_led: NotRequired[int] - num_discounts: NotRequired[int] class RawMembershipStatsView(View): @@ -678,7 +666,6 @@ def _flat_members_info( "is_leader": info.trips_information.is_leader, "num_trips_attended": info.trips_information.num_trips_attended, "num_trips_led": info.trips_information.num_trips_led, - "num_discounts": info.trips_information.num_discounts, } ) # If there's a verified MIT email address from the trips site, use it! diff --git a/ws/cleanup.py b/ws/cleanup.py index 3b01715a..6fa7ff53 100644 --- a/ws/cleanup.py +++ b/ws/cleanup.py @@ -45,22 +45,6 @@ def lapsed_participants() -> QuerySet[models.Participant]: return models.Participant.objects.filter(lapsed_update).exclude(active_members) -def purge_non_student_discounts() -> None: - """Purge non-students from student-only discounts. - - Student eligibility is enforced at the API and form level. If somebody was - a student at the time of enrolling but is no longer a student, we should - unenroll them. - """ - stu_discounts = models.Discount.objects.filter(student_required=True) - not_student = ~Q(affiliation__in=models.Participant.STUDENT_AFFILIATIONS) - - # Remove student discounts from all non-students who have them - participants = models.Participant.objects.all() - for par in participants.filter(not_student, discounts__in=stu_discounts): - par.discounts.set(par.discounts.filter(student_required=False)) - - @transaction.atomic def purge_old_medical_data() -> None: """For privacy reasons, purge old medical information. diff --git a/ws/email/renew.py b/ws/email/renew.py index a4730a98..0058f9e1 100644 --- a/ws/email/renew.py +++ b/ws/email/renew.py @@ -44,7 +44,6 @@ def send_email_reminding_to_renew( context = { "participant": participant, - "discounts": participant.discounts.all().order_by("name"), "expiry_if_renewing": membership.membership_expires + timedelta(days=365), "unsubscribe_token": unsubscribe.generate_unsubscribe_token(participant), } diff --git a/ws/forms.py b/ws/forms.py index ab259cad..ae52167f 100644 --- a/ws/forms.py +++ b/ws/forms.py @@ -57,37 +57,6 @@ class RequiredModelForm(forms.ModelForm): error_css_class = "warning" -class DiscountForm(forms.ModelForm): - send_membership_reminder = forms.BooleanField( - label="Email me when it's time to renew my membership", - help_text="Ensure continued access to discounts (not required, but strongly recommended!)", - required=False, - ) - - def clean_discounts(self): - """Ensure the participant meets the requirements for each discount.""" - participant = self.instance - discounts = self.cleaned_data["discounts"] - - if not participant.is_student: - for discount in discounts: - if discount.student_required: - raise ValidationError(f"{discount.name} is a student-only discount") - if not discount.ga_key: - # The UI should prevent "enrolling" in these read-only discounts, but check anyway. - raise ValidationError( - f"{discount.name} does not support sharing your information automatically. " - "See discount terms for instructions." - ) - - return discounts - - class Meta: - model = models.Participant - fields = ["discounts", "send_membership_reminder"] - widgets = {"discounts": forms.CheckboxSelectMultiple} - - class ParticipantForm(forms.ModelForm): class Meta: model = models.Participant diff --git a/ws/merge.py b/ws/merge.py index b28762cf..54b81b77 100644 --- a/ws/merge.py +++ b/ws/merge.py @@ -33,13 +33,11 @@ "ws_leaderrecommendation": ("creator_id", "participant_id"), "ws_lectureattendance": ("participant_id", "creator_id"), "ws_winterschoolsettings": ("last_updated_by_id",), - "ws_discount_administrators": ("participant_id",), "ws_distinctaccounts": ("left_id", "right_id"), # Each of these tables should only have one row for the given person. # (For example, it's possible that two participants representing the same human are on the same trip. # In practice, though, this should never actually be happening. Uniqueness constraints will protect us. "ws_tripinfo_drivers": ("participant_id",), - "ws_participant_discounts": ("participant_id",), "ws_trip_leaders": ("participant_id",), "ws_leadersignup": ("participant_id",), "ws_signup": ("participant_id",), diff --git a/ws/models.py b/ws/models.py index 567f79f7..44459b05 100644 --- a/ws/models.py +++ b/ws/models.py @@ -115,56 +115,6 @@ def get_queryset(self): return leaders.prefetch_related("leaderrating_set") -class Discount(models.Model): - """Discount at another company available to MITOC members.""" - - administrators = models.ManyToManyField( - "ws.Participant", - blank=True, - help_text="Persons selected to administer this discount", - related_name="discounts_administered", - ) - - active = models.BooleanField( - default=True, help_text="Discount is currently open & active" - ) - name = models.CharField(max_length=255) - summary = models.CharField(max_length=255) - terms = models.TextField(max_length=4095) - url = models.URLField(blank=True) - ga_key = models.CharField( - max_length=63, - # If blank, then we don't actually report this information to a spreadsheet - blank=True, - help_text="key for Google spreadsheet with membership information (shared as read-only with the company)", - ) - - time_created = models.DateTimeField(auto_now_add=True) - last_updated = models.DateTimeField(auto_now=True) - - student_required = models.BooleanField( - default=False, help_text="Discount provider requires recipients to be students" - ) - - report_school = models.BooleanField( - default=False, help_text="Report MIT affiliation if participant is a student" - ) - report_student = models.BooleanField( - default=False, - help_text="Report MIT affiliation and student status to discount provider", - ) - report_leader = models.BooleanField( - default=False, help_text="Report MITOC leader status to discount provider" - ) - report_access = models.BooleanField( - default=False, - help_text="Report if participant should have leader, student, or admin level access", - ) - - def __str__(self): # pylint: disable=invalid-str-returned - return self.name - - class MembershipStats(SingletonModel): """Cached response from https://mitoc-gear.mit.edu/api-auth/v1/stats @@ -418,8 +368,6 @@ class Participant(models.Model): } ) - discounts = models.ManyToManyField(Discount, blank=True) - class Meta: ordering = ["name", "email"] diff --git a/ws/privacy.py b/ws/privacy.py index 1e05d797..3f2b87b1 100644 --- a/ws/privacy.py +++ b/ws/privacy.py @@ -24,7 +24,6 @@ def all_data(self): fields = [ "user", "membership", - "discounts", "car", "medical", "lottery_info", @@ -60,12 +59,6 @@ def car(self): """Participant's car information.""" return self.par.car and model_to_dict(self.par.car, exclude="id") - @property - def discounts(self): - """Discounts where the participant elected to share their info.""" - for d in self.par.discounts.all(): - yield model_to_dict(d, fields=["name", "active", "summary", "url"]) - @property def authored_feedback(self): """Feedback supplied by the participant.""" diff --git a/ws/settings.py b/ws/settings.py index f39fa2e1..14568c36 100644 --- a/ws/settings.py +++ b/ws/settings.py @@ -138,18 +138,10 @@ } CELERY_BEAT_SCHEDULE = { - "purge-non-student-discounts": { - "task": "ws.tasks.purge_non_student_discounts", - "schedule": crontab(minute=0, hour=2, day_of_week=1), - }, "purge-old-medical-data": { "task": "ws.tasks.purge_old_medical_data", "schedule": crontab(minute=0, hour=2, day_of_week=2), }, - "refresh-all-discount-spreadsheets": { - "task": "ws.tasks.update_all_discount_sheets", - "schedule": crontab(minute=0, hour=3), - }, "send-trip-summaries-email": { "task": "ws.tasks.send_trip_summaries_email", # Tuesdays around noon (ignore DST) @@ -319,10 +311,6 @@ "recipientEvents": [{"recipientEventStatusCode": "Completed"}], } -# Google Sheet (discount roster) settings -OAUTH_JSON_CREDENTIALS = os.getenv("OAUTH_JSON_CREDENTIALS") -DISABLE_GSHEETS = bool(os.getenv("DISABLE_GSHEETS")) - # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = "en-us" diff --git a/ws/tasks.py b/ws/tasks.py index 6aee05d9..e7f6ec4f 100644 --- a/ws/tasks.py +++ b/ws/tasks.py @@ -5,18 +5,18 @@ from time import monotonic import requests -from celery import group, shared_task +from celery import shared_task from django.core.cache import cache from django.db import connections, transaction from django.db.utils import IntegrityError -from ws import cleanup, models, settings +from ws import cleanup, models from ws.email import renew from ws.email.sole import send_email_to_funds from ws.email.trips import send_trips_summary from ws.lottery.run import SingleTripLotteryRunner, WinterSchoolLotteryRunner from ws.utils import dates as date_utils -from ws.utils import geardb, member_sheets +from ws.utils import geardb logger = logging.getLogger(__name__) @@ -51,80 +51,6 @@ def exclusive_lock(task_identifier: str) -> Iterator[bool]: cache.delete(task_identifier) -@shared_task -def update_discount_sheet_for_participant( - discount_id: int, - participant_id: int, -) -> None: - """Lock the sheet and add/update a single participant. - - Updating of the sheet should not be done at the same time that we're - updating the sheet for another participant (or for all participants, as we - do nightly). Simultaneous edits are prevented with a Redis lock. - """ - discount = models.Discount.objects.get(pk=discount_id) - if not discount.ga_key: - # Form logic should prevent ever letting participants "enroll" in this type of discount - logger.error("Discount %s does not have a Google Sheet!", discount.name) - return - - participant = models.Participant.objects.get(pk=participant_id) - - if settings.DISABLE_GSHEETS: - logger.warning( - "Google Sheets functionality is disabled, not updating '%s' for %s", - discount.name, - participant.name, - ) - return - - with exclusive_lock(f"update_discount-{discount_id}"): - member_sheets.update_participant(discount, participant) - - -@shared_task -def update_discount_sheet( - discount_id: int, - *, - check_all_lapsed_members: bool = False, -) -> None: - """Overwrite the sheet to include all members desiring the discount. - - This is the only means of removing users if they no longer - wish to share their information, so it should be run periodically. - - This task should not run at the same time that we're updating the sheet for - another participant (or for all participants, as we do nightly). - """ - discount = models.Discount.objects.get(pk=discount_id) - if not discount.ga_key: - # Form logic should prevent ever letting participants "enroll" in this type of discount - logger.error("Discount %s does not have a Google Sheet!", discount.name) - return - - logger.info("Updating the discount sheet for %s", discount.name) - - if settings.DISABLE_GSHEETS: - logger.warning( - "Google Sheets functionality is disabled, not updating sheet for '%s'", - discount.name, - ) - return - - trust_cache = not check_all_lapsed_members - with exclusive_lock(f"update_discount-{discount_id}"): - member_sheets.update_discount_sheet(discount, trust_cache=trust_cache) - - -@shared_task -def update_all_discount_sheets() -> None: - logger.info("Updating the member roster for all discount sheets") - discount_pks = models.Discount.objects.exclude(ga_key="").values_list( - "pk", flat=True - ) - group([update_discount_sheet.s(pk) for pk in discount_pks])() - - @shared_task( autoretry_for=(requests.exceptions.RequestException,), # Account for brief outages by retrying after 1 minute, then 2, then 4, then 8 @@ -287,13 +213,6 @@ def run_ws_lottery() -> None: runner() -@shared_task -def purge_non_student_discounts() -> None: - """Purge non-students from student-only discounts.""" - logger.info("Purging non-students from student-only discounts") - cleanup.purge_non_student_discounts() - - @shared_task def purge_old_medical_data() -> None: """Purge old, dated medical information.""" diff --git a/ws/templates/email/membership/renew.html b/ws/templates/email/membership/renew.html index cce40231..9bdc2159 100644 --- a/ws/templates/email/membership/renew.html +++ b/ws/templates/email/membership/renew.html @@ -11,17 +11,6 @@ Your MITOC membership will expire on {{ participant.membership.membership_expires|date:"F j, Y" }}.

- {% if discounts %} -

- Renewing is required to maintain access to your discounts with - {% for discount in discounts %} - {% include 'snippets/oxford_comma.html' %} - {{ discount.name }}{% if forloop.last %}.{% endif %} - {% endfor %} - -

- {% endif %} -

Renew today to add another 365 days to your membership. (Renewing any time between now and {{ participant.membership.membership_expires|date:"F jS" }} @@ -32,7 +21,6 @@ Your MITOC membership enables you to:

diff --git a/ws/templates/email/membership/renew.txt b/ws/templates/email/membership/renew.txt index a59499b0..f2c97aaa 100644 --- a/ws/templates/email/membership/renew.txt +++ b/ws/templates/email/membership/renew.txt @@ -1,15 +1,6 @@ {% load general_tags %} Your MITOC membership will expire on {{ participant.membership.membership_expires|date:"F j, Y" }}. -{% if discounts %} -{% gapless %} -{% autoescape off %} -Renewing is required to maintain access to your discounts with: -{% for discount in discounts %} -- {{ discount.name }} -{% endfor %} -{% endautoescape %} -{% endgapless %} -{% endif %} + Renew today to add another 365 days to your membership: https://mitoc-trips.mit.edu/profile/membership/ @@ -18,7 +9,6 @@ will ensure that your membership is valid until {{ expiry_if_renewing|date:"F j, Your MITOC membership enables you to: - rent gear from the MITOC office -- enroll in discounts for club members - go on official trips - stay in MITOC's cabins diff --git a/ws/templates/for_templatetags/active_discounts.html b/ws/templates/for_templatetags/active_discounts.html deleted file mode 100644 index dd546432..00000000 --- a/ws/templates/for_templatetags/active_discounts.html +++ /dev/null @@ -1,17 +0,0 @@ -{% if participant.discounts.count %} -

Active Discounts

-

- You are sharing your name, email address, and membership status with the following companies: -

- - - -

- You can change this at any time by modifying your - discount preferences. -

-{% endif %} diff --git a/ws/templates/preferences/discounts.html b/ws/templates/preferences/discounts.html index 7593106f..6f8dc5dc 100644 --- a/ws/templates/preferences/discounts.html +++ b/ws/templates/preferences/discounts.html @@ -6,82 +6,69 @@ {% block content %} {{ block.super }} -
-
-

MITOC Discounts

+

MITOC Discounts (Discontinued)

+

+ Previously, MITOC allowed active members to opt into sharing information with local businesses. +

+

+ The discount program has been discontinued. Local businesses may extend + discounts (at their own discretion) to MITOCers. Inquire directly at those + businesses; MITOC does not manage these programs in any way. +

-

Various companies offer discounts and perks to MITOC members. At your request, we can share your membership information to make you eligible for these discounts.

+
- MITOC will never share your information without your express consent. -
-
- - - An active membership is required for these discounts. +{% if viewing_participant.is_student %} +
+
+
+
+ +
+ +
+ $589 Base, $839 Base Plus, $879 Full +
+
-
+
+

+ To receive a discount for the 2024-25 season, fill out a brief form. +

+
+

+ MITOC members who are also students can get discounted passes: + $879 for the Ikon Pass, + $839 for the Ikon Base Plus Pass, + and $589 for the Ikon Base Pass. +

+

+ The major distinction between the Ikon (Full) and Base passes are that the + Base passes have some blackout dates, and provide 5 days vs. 7 days at + the non-core resorts. -


-{% if not form.discounts %} -
Sorry, there aren't any discounts currently available!
-{% else %} -
-
- - By selecting discounts below, you're granting - us permission to share your name, email address, and membership status. -
-
- {% csrf_token %} - {% for discount, checkbox in form.discounts|instances_and_widgets %} -
-
-
-
- -
-
- Site -
-
- {{ discount.summary }} -
-
-
-
-

{{ discount.terms | safe }}

-
-
- {% endfor %} - {% for err in form.non_field_errors %} -
{{ err }}
- {% endfor %} -
-
- {{ form.send_membership_reminder|as_crispy_field }} -
-
+ In the Northeast, locations include: +

+
    +
  • Stratton, VT (unlimited)
  • +
  • Sugarbush, VT (unlimited)
  • +
  • Tremblant, QC (unlimited)
  • +
  • Killington-Pico, VT
  • +
  • Sugarloaf, ME
  • +
  • Sunday River, ME
  • +
  • Loon, NH
  • +
+

+ Discounts can be requested now and used later; the program + closes November 30, 2024, so make sure to purchase before then. +

+

+ Please direct any questions to mitoc.ikon@gmail.com. +

- {# At least for now, we still allow people to submit the form. #} - {# This allows people to easily *unenroll*. #} - {# It also lets people enroll, then pay dues later. #} - {# To ease confusion, we might later consider just blocking form submission. #} - {% if not viewing_participant.membership.membership_active %} -
- An active membership is required for discounts. - Please pay membership dues in order to be eligible. -
- {% endif %} - -
- +
{% endif %} - {% endblock content %} diff --git a/ws/templates/privacy/settings.html b/ws/templates/privacy/settings.html index 6497e5da..78f69c60 100644 --- a/ws/templates/privacy/settings.html +++ b/ws/templates/privacy/settings.html @@ -1,10 +1,8 @@ {% extends "base.html" %} {% load crispy_forms_tags %} -{% load discount_tags %} {% block head_title %}Privacy Settings{% endblock head_title %} {% block content %} -{% active_discounts viewing_participant %}

Configurable Settings

{% if viewing_participant.gravatar_opt_out %} diff --git a/ws/templates/stats/membership.html b/ws/templates/stats/membership.html index 6ba74ef2..638ecb89 100644 --- a/ws/templates/stats/membership.html +++ b/ws/templates/stats/membership.html @@ -203,11 +203,6 @@ var ratedLeaders = _.filter(allMembers, 'is_leader'); renderChart("#rated_leaders", ratedLeaders); - var justDiscounts = _.filter(allMembers, function(info) { - return (info.num_discounts && !info.num_rentals && !info.num_trips_attended); - }); - renderChart("#discounts", justDiscounts); - }); @@ -228,7 +223,7 @@

Membership Statistics

This page combines two sources of data:
  1. Gear database: source of truth for membership, gear rental history, and present affiliation.
  2. -
  3. MITOC Trips: adds trip participation, leader status, discount enrollment, preferred email address.
  4. +
  5. MITOC Trips: adds trip participation, leader status, preferred email address.

@@ -250,7 +245,7 @@

MITOC members

  • Have paid current annual dues
  • Are MIT students, or have ever participated on a trip
  • - (This excludes people who only use the club for rentals or MITOC discounts) + (This excludes people who only use the club for rentals)

    @@ -280,11 +275,4 @@

    Just renters

    People who pay dues and rent gear, but have never been on a trip.

    -

    Just discounts

    -

    - People who use MITOC discounts, but - have never rented gear or been on a trip. -

    -
    - {% endblock content %} diff --git a/ws/templatetags/discount_tags.py b/ws/templatetags/discount_tags.py deleted file mode 100644 index 88235cbb..00000000 --- a/ws/templatetags/discount_tags.py +++ /dev/null @@ -1,8 +0,0 @@ -from django import template - -register = template.Library() - - -@register.inclusion_tag("for_templatetags/active_discounts.html") -def active_discounts(participant): - return {"participant": participant} diff --git a/ws/tests/email/test_renew.py b/ws/tests/email/test_renew.py index c38caf67..0a5d1b1b 100644 --- a/ws/tests/email/test_renew.py +++ b/ws/tests/email/test_renew.py @@ -2,17 +2,18 @@ from textwrap import dedent from unittest import mock -from bs4 import BeautifulSoup from django.core import mail from django.test import TestCase from freezegun import freeze_time from ws.email import renew -from ws.tests.factories import DiscountFactory, ParticipantFactory +from ws.tests.factories import ParticipantFactory @freeze_time("2020-01-12 09:00:00 EST") class RenewTest(TestCase): + maxDiff = None + def test_will_not_email_without_membership(self): par = ParticipantFactory.create(membership=None) @@ -61,7 +62,7 @@ def test_will_not_email_before_renewal_date(self): self.assertIn("don't yet recommend renewal", str(cm.exception)) - def test_normal_renewal_no_discounts(self): + def test_normal_renewal(self): par = ParticipantFactory.create( # Exact token depends on the participant's PK pk=881203, @@ -86,7 +87,6 @@ def test_normal_renewal_no_discounts(self): Your MITOC membership enables you to: - rent gear from the MITOC office - - enroll in discounts for club members - go on official trips - stay in MITOC's cabins @@ -105,74 +105,3 @@ def test_normal_renewal_no_discounts(self): """ ) self.assertEqual(msg.body, expected_text) - - def test_participant_with_discounts(self): - """We mention a participant's discounts when offering renewal.""" - par = ParticipantFactory.create( - # Exact token depends on the participant's PK - pk=991838, - membership__membership_expires=date(2020, 2, 5), - ) - par.discounts.add(DiscountFactory.create(name="Zazu's Advisory Services")) - par.discounts.add(DiscountFactory.create(name="Acme Corp")) - - with mock.patch.object(mail.EmailMultiAlternatives, "send") as send: - with self.settings(UNSUBSCRIBE_SECRET_KEY="sooper-secret"): # noqa: S106 - msg = renew.send_email_reminding_to_renew(par) - send.assert_called_once() - - expected_text = dedent( - """ - Your MITOC membership will expire on February 5, 2020. - - Renewing is required to maintain access to your discounts with: - - Acme Corp - - Zazu's Advisory Services - - Renew today to add another 365 days to your membership: - https://mitoc-trips.mit.edu/profile/membership/ - - Renewing any time between now and February 5th - will ensure that your membership is valid until February 4, 2021. - - Your MITOC membership enables you to: - - rent gear from the MITOC office - - enroll in discounts for club members - - go on official trips - - stay in MITOC's cabins - - ------------------------------------------------------ - - You can unsubscribe from membership renewal reminders: - https://mitoc-trips.mit.edu/preferences/email/eyJwayI6OTkxODM4LCJlbWFpbHMiOlswXX0:1iqdma:toxfJebkHgNNKTDNf42EiXTHT32ifB8EsqflXOED7R8/ - - Note that we send at most one reminder per year: - we will not email you again unless you renew. - - You can also manage your email preferences directly: - https://mitoc-trips.mit.edu/preferences/email/ - - Questions? Contact us: https://mitoc-trips.mit.edu/contact/ - """ - ) - self.assertEqual(msg.body, expected_text) - - html, mime_type = msg.alternatives[0] - self.assertEqual(mime_type, "text/html") - soup = BeautifulSoup(html, "html.parser") - self.assertEqual( - [tag.attrs["href"] for tag in soup.find_all("a")], - [ - # We link to the discounts immediately, since that's mentioned up-front - "https://mitoc.mit.edu/preferences/discounts/", - "https://mitoc-trips.mit.edu/profile/membership/", - "https://mitoc.mit.edu/rentals", - # All MITOCers are told about discounts in their renewal email - "https://mitoc.mit.edu/preferences/discounts/", - "https://mitoc-trips.mit.edu/trips/", - "https://mitoc.mit.edu/rentals/cabins", - "https://mitoc-trips.mit.edu/preferences/email/eyJwayI6OTkxODM4LCJlbWFpbHMiOlswXX0:1iqdma:toxfJebkHgNNKTDNf42EiXTHT32ifB8EsqflXOED7R8/", - "https://mitoc-trips.mit.edu/preferences/email/", - "https://mitoc-trips.mit.edu/contact/", - ], - ) diff --git a/ws/tests/factories.py b/ws/tests/factories.py index 7e94ef7e..aaa03a03 100644 --- a/ws/tests/factories.py +++ b/ws/tests/factories.py @@ -18,14 +18,6 @@ class Meta: skip_postgeneration_save = True -class DiscountFactory(BaseFactory): - class Meta: - model = models.Discount - - name = "Local Climbing Gym" - active = True - - class EmergencyContactFactory(BaseFactory): class Meta: model = models.EmergencyContact diff --git a/ws/tests/templatetags/test_discount_tags.py b/ws/tests/templatetags/test_discount_tags.py deleted file mode 100644 index d947f2e1..00000000 --- a/ws/tests/templatetags/test_discount_tags.py +++ /dev/null @@ -1,50 +0,0 @@ -from bs4 import BeautifulSoup -from django.template import Context, Template -from django.test import TestCase - -from ws.tests import factories - - -class DiscountTagsTest(TestCase): - def test_no_discounts(self): - html_template = Template( - "{% load discount_tags %}{% active_discounts participant%}" - ) - context = Context({"participant": factories.ParticipantFactory.create()}) - self.assertFalse(html_template.render(context).strip()) - - def test_discounts(self): - participant = factories.ParticipantFactory.create() - gym = factories.DiscountFactory.create(name="Local Gym", url="example.com/gym") - retailer = factories.DiscountFactory.create( - name="Large Retailer", url="example.com/retail" - ) - factories.DiscountFactory.create(name="Other Outing Club") - - participant.discounts.add(gym) - participant.discounts.add(retailer) - - html_template = Template( - "{% load discount_tags %}{% active_discounts participant%}" - ) - context = Context({"participant": participant}) - raw_html = html_template.render(context) - soup = BeautifulSoup(raw_html, "html.parser") - - self.assertEqual( - soup.find("p").get_text(" ", strip=True), - "You are sharing your name, email address, and membership status with the following companies:", - ) - self.assertEqual( - [str(li) for li in soup.find("ul").find_all("li")], - [ - '
  • Local Gym
  • ', - '
  • Large Retailer
  • ', - ], - ) - - self.assertTrue( - soup.find( - "a", string="discount preferences", href="/preferences/discounts/" - ) - ) diff --git a/ws/tests/test_auth.py b/ws/tests/test_auth.py index 71a13250..67edbe1d 100644 --- a/ws/tests/test_auth.py +++ b/ws/tests/test_auth.py @@ -17,7 +17,6 @@ "participant_lookup", "trip_signup", "leader_trip_signup", - "discounts", "lottery_preferences", "lottery_pairing", ] @@ -105,23 +104,6 @@ def test_registered_participant_pages(self): response = self.client.get(desired_page) self.assertProfileRedirectedTo(response, desired_page) - @unittest.mock.patch("ws.decorators.profile_needs_update") - def test_participant_pages(self, profile_needs_update): - """Participants are allowed to view certain pages.""" - par_only_page = reverse("discounts") - self.login() - - # When authenticated, but not a participant: redirected to edit profile - no_par_response = self.client.get(par_only_page) - self.assertProfileRedirectedTo(no_par_response, par_only_page) - - PermHelpers.mark_participant(self.user) - profile_needs_update.return_value = False - - # When authenticated and a participant: success - par_response = self.client.get(par_only_page) - self.assertEqual(par_response.status_code, 200) - @unittest.mock.patch("ws.decorators.profile_needs_update") def test_leader_pages(self, profile_needs_update): """Participants are given forbidden messages on leader-only pages. diff --git a/ws/tests/test_cleanup.py b/ws/tests/test_cleanup.py index 36d5fde8..9fe7e0e3 100644 --- a/ws/tests/test_cleanup.py +++ b/ws/tests/test_cleanup.py @@ -1,5 +1,4 @@ from datetime import date, timedelta -from unittest import mock from django.test import TestCase from freezegun import freeze_time @@ -95,102 +94,3 @@ def test_lapsed(self): make_last_updated_on(participant, date(2012, 12, 1)) self.assertEqual(cleanup.lapsed_participants().get(), participant) - - -class PurgeNonStudentDiscountsTests(TestCase): - def setUp(self): - self.discount_for_everybody = factories.DiscountFactory.create( - student_required=False - ) - self.student_only_discount = factories.DiscountFactory.create( - student_required=True - ) - - def test_purged(self): - # Create a collection of participants with every student affiliation - current_students = [ - factories.ParticipantFactory.create(affiliation="MU"), - factories.ParticipantFactory.create(affiliation="MG"), - factories.ParticipantFactory.create(affiliation="NU"), - factories.ParticipantFactory.create(affiliation="NG"), - ] - alum = factories.ParticipantFactory.create(affiliation="MU") - former_undergrad = factories.ParticipantFactory.create(affiliation="MU") - former_grad_student = factories.ParticipantFactory.create(affiliation="NG") - - # Assign both discounts to everybody, since they're all currently students - everybody = [former_undergrad, former_grad_student, alum, *current_students] - for participant in everybody: - participant.discounts.set( - [self.discount_for_everybody, self.student_only_discount] - ) - - # The former students move into non-student statuses - alum.affiliation = "ML" - alum.save() - former_undergrad.affiliation = "MA" - former_undergrad.save() - former_grad_student.affiliation = "NA" - former_grad_student.save() - - cleanup.purge_non_student_discounts() - - # All participants remain in the discount which has no student requirement - self.assertCountEqual( - self.discount_for_everybody.participant_set.all(), everybody - ) - - # Just the students keep the student discount! - self.assertCountEqual( - self.student_only_discount.participant_set.all(), current_students - ) - - -class PurgeMedicalInfoTests(TestCase): - def test_current_participants_unaffected(self): - # Will automatically get a profile_last_updated value - participant = factories.ParticipantFactory.create() - original = participant.emergency_info - with mock.patch.object(cleanup.logger, "info") as log_info: - cleanup.purge_old_medical_data() - log_info.assert_not_called() # Nothing to log, no changes made - participant.emergency_info.refresh_from_db() - self.assertEqual(original, participant.emergency_info) - - def test_purge_medical_data(self): - # (Will have medical information created) - participant = factories.ParticipantFactory.create( - membership=None, name="Old Member", email="old@example.com", pk=823 - ) - # Hasn't updated in at least 13 months - make_last_updated_on(participant, date(2012, 12, 1)) - - # Note that we started with information - e_info = participant.emergency_info - e_contact = e_info.emergency_contact - self.assertTrue(e_info.allergies) - self.assertTrue(e_info.medications) - self.assertTrue(e_info.medical_history) - - with mock.patch.object(cleanup.logger, "info") as log_info: - cleanup.purge_old_medical_data() - log_info.assert_called_once_with( - "Purging medical data for %s (%s - %s, last updated %s)", - "Old Member", - 823, - "old@example.com", - date(2012, 12, 1), - ) - - # Re-query so that we get all fresh data (refresh_from_db only does one model) - participant = models.Participant.objects.select_related( - "emergency_info__emergency_contact" - ).get(pk=participant.pk) - - # Now, note that sensitive fields have been cleaned out - self.assertEqual(participant.emergency_info.allergies, "") - self.assertEqual(participant.emergency_info.medications, "") - self.assertEqual(participant.emergency_info.medical_history, "") - - # The emergency contact remains, though. - self.assertEqual(participant.emergency_info.emergency_contact, e_contact) diff --git a/ws/tests/test_privacy.py b/ws/tests/test_privacy.py index ea893c2b..6bdd38a7 100644 --- a/ws/tests/test_privacy.py +++ b/ws/tests/test_privacy.py @@ -48,7 +48,6 @@ def test_minimal_participant(self): "waiver_expires": date(2020, 10, 29), }, ), - ("discounts", []), ("car", None), ( "medical", @@ -78,7 +77,6 @@ def test_minimal_participant(self): def test_success(self): """Create a bunch of data about the participant, ensure that dumping it works.""" participant = factories.ParticipantFactory.create() - participant.discounts.add(factories.DiscountFactory.create()) participant.car = factories.CarFactory.create() participant.save() factories.LeaderRatingFactory.create(participant=participant) diff --git a/ws/tests/test_tasks.py b/ws/tests/test_tasks.py index 98bec057..58afbf15 100644 --- a/ws/tests/test_tasks.py +++ b/ws/tests/test_tasks.py @@ -4,7 +4,6 @@ from zoneinfo import ZoneInfo from django.core import mail -from django.core.cache import cache from django.test import TestCase from freezegun import freeze_time from mitoc_const import affiliations @@ -12,17 +11,9 @@ from ws import models, tasks from ws.email import renew from ws.tests import factories -from ws.utils import member_sheets class TaskTests(TestCase): - @staticmethod - def test_update_discount_sheet(): - discount = factories.DiscountFactory.create(pk=9123, ga_key="test-key") - with patch.object(member_sheets, "update_discount_sheet") as update_sheet: - tasks.update_discount_sheet(9123) - update_sheet.assert_called_with(discount, trust_cache=True) - @staticmethod @patch("ws.utils.geardb.update_affiliation") def test_update_participant_affiliation(update_affiliation): @@ -72,78 +63,6 @@ def test_trips_without_itineraries_included(self): [*trips_with_itinerary, no_itinerary_trip], ) - @staticmethod - @patch("ws.utils.member_sheets.update_discount_sheet") - @patch("ws.utils.member_sheets.update_participant") - def test_discount_tasks_share_same_key( - _update_participant, - _update_discount_sheet, - ): - """All tasks modifying the same discount sheet must share a task ID. - - This prevents multiple tasks modifying the Google Sheet at the same time. - """ - discount = factories.DiscountFactory.create(ga_key="some-key") - participant = factories.ParticipantFactory.create() - expected_lock_id = f"update_discount-{discount.pk}" - - with patch("ws.tasks.cache", wraps=cache) as mock_cache_one: - tasks.update_discount_sheet_for_participant(discount.pk, participant.pk) - mock_cache_one.add.assert_called_with(expected_lock_id, "true", 600) - - with patch("ws.tasks.cache", wraps=cache) as mock_cache_two: - tasks.update_discount_sheet(discount.pk) - mock_cache_two.add.assert_called_with(expected_lock_id, "true", 600) - - -class DiscountsWithoutGaKeyTest(TestCase): - """Test our handling of discounts which opt out of the Google Sheets flow.""" - - def setUp(self): - self.par = factories.ParticipantFactory.create() - # Some discounts opt out of the Google Sheets flow - self.discount = factories.DiscountFactory.create(ga_key="") - - def test_update_sheet_for_participant(self): - """If we mistakenly wrote a discount without a Google Sheets key, Celery handles it.""" - # Participants shouldn't be able to opt in to these discounts, - # but make sure Celery doesn't choke if they do. - self.par.discounts.add(self.discount) - - with patch.object(member_sheets, "update_participant") as update_par: - with patch.object(tasks.logger, "error") as log_error: - tasks.update_discount_sheet_for_participant( - self.discount.pk, self.par.pk - ) - - log_error.assert_called() - update_par.assert_not_called() - - def test_update_sheet(self): - """Updating just a single sheet is handled if that sheet has no Google Sheets key.""" - with patch.object(member_sheets, "update_participant") as update_par: - with patch.object(tasks.logger, "error") as log_error: - tasks.update_discount_sheet(self.discount.pk) - - log_error.assert_called() - update_par.assert_not_called() - - @staticmethod - def test_update_all(): - """When updating the sheets for all discounts, we exclude ones without a sheet.""" - # Because this discount has no Google Sheets key, we don't do anything - with patch.object(tasks.update_discount_sheet, "s") as update_sheet: - tasks.update_all_discount_sheets() - update_sheet.assert_not_called() - - # If we add another discount, we can bulk update but will exclude the current one - other_discount = factories.DiscountFactory.create(ga_key="some-koy") - - with patch.object(tasks.update_discount_sheet, "s") as update_sheet: - with patch.object(tasks, "group"): - tasks.update_all_discount_sheets() - update_sheet.assert_called_once_with(other_discount.pk) - @freeze_time("2019-01-25 12:00:00 EST") class RemindAllParticipantsToRenewTest(TestCase): diff --git a/ws/tests/views/test_api_views.py b/ws/tests/views/test_api_views.py index 0362529a..fc062ccb 100644 --- a/ws/tests/views/test_api_views.py +++ b/ws/tests/views/test_api_views.py @@ -688,7 +688,6 @@ def test_fetches_async_by_default(self): "is_leader": True, "num_trips_attended": 1, "num_trips_led": 0, - "num_discounts": 0, "mit_email": "tim@mit.edu", }, ], @@ -782,7 +781,6 @@ def test_matches_on_verified_emails_only(self) -> None: "affiliation": "Non-MIT undergrad", # Found a matching account! "is_leader": False, - "num_discounts": 0, "num_rentals": 0, "num_trips_attended": 0, "num_trips_led": 0, @@ -838,7 +836,6 @@ def test_ignores_possibly_old_mit_edu(self): "is_leader": True, "num_trips_attended": 0, "num_trips_led": 0, - "num_discounts": 0, # We do *not* report the mit.edu email -- it may be old "mit_email": None, }, @@ -913,11 +910,6 @@ def test_trips_data_included(self): factories.LeaderRatingFactory.create(participant=self.participant, active=False) factories.TripFactory.create().leaders.add(self.participant) - # Enjoys one discount, administers another! - discount = factories.DiscountFactory.create() - self.participant.discounts.add(discount) - factories.DiscountFactory.create().administrators.add(self.participant) - self._expect_members( { "email": "bob+bu@example.com", # Preferred email! @@ -927,7 +919,6 @@ def test_trips_data_included(self): "is_leader": False, # Not presently a leader! "num_trips_attended": 1, "num_trips_led": 2, - "num_discounts": 0, }, { "email": self.participant.email, @@ -937,7 +928,6 @@ def test_trips_data_included(self): "is_leader": True, "num_trips_attended": 2, "num_trips_led": 1, - "num_discounts": 1, }, # We did not find a matching trips account { @@ -957,7 +947,7 @@ def test_trips_data_included(self): ) # 1. Count trips per participant (separate to avoid double-counting) - # 2. Count discounts, trips led, per participant + # 2. Count trips led per participant # 3. Get all emails (lowercased, for mapping back to participant records) # 4. Get MIT email addresses with self.assertNumQueries(4): diff --git a/ws/tests/views/test_preferences.py b/ws/tests/views/test_preferences.py index 5553c756..cabe6ad3 100644 --- a/ws/tests/views/test_preferences.py +++ b/ws/tests/views/test_preferences.py @@ -6,9 +6,8 @@ from django.contrib import messages from django.test import TestCase from freezegun import freeze_time -from mitoc_const import affiliations -from ws import enums, models, tasks, unsubscribe +from ws import enums, models, unsubscribe from ws.tests import factories, strip_whitespace @@ -460,187 +459,6 @@ def test_signups_as_paired(self): ) -class DiscountsTest(TestCase): - def test_authenticated_users_only(self): - """Users must be signed in to enroll in discounts.""" - response = self.client.get("/preferences/discounts/") - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/accounts/login/?next=/preferences/discounts/") - - def test_users_with_info_only(self): - """Participant records are required.""" - user = factories.UserFactory.create() - self.client.force_login(user) - response = self.client.get("/preferences/discounts/") - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/profile/edit/?next=/preferences/discounts/") - - def test_enrollment_without_a_membership(self): - """We allow (yet heavily warn against) users who lack a membership.""" - par = factories.ParticipantFactory.create(membership=None) - gym = factories.DiscountFactory.create(ga_key="test-key-to-update-sheet") - - self.client.force_login(par.user) - get_response = self.client.get("/preferences/discounts/") - soup = BeautifulSoup(get_response.content, "html.parser") - self.assertEqual( - soup.find("strong").text, "An active membership is required for discounts." - ) - - with mock.patch.object(tasks, "update_discount_sheet_for_participant") as task: - with mock.patch.object(messages, "error") as error: - response = self.client.post( - "/preferences/discounts/", {"discounts": str(gym.pk)} - ) - # Immediately after signup, we sync this user to the sheet - task.delay.assert_called_once_with(gym.pk, par.pk) - - # They won't actually be eligible, so we direct them to pay dues. - error.assert_called_once_with( - mock.ANY, # request object - "You must be a current MITOC member to receive discounts. " - "We recorded your discount choices, but please pay dues to be eligible", - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/profile/membership/") - - # We did at least accept their choices, though. - self.assertEqual([d.pk for d in par.discounts.all()], [gym.pk]) - - def test_successful_enrollment(self): - """Participants can enroll in a selection of discounts.""" - par = factories.ParticipantFactory.create() - gym = factories.DiscountFactory.create(ga_key="test-key-to-update-sheet") - # Another discount exists, but they don't enroll in it - factories.DiscountFactory.create() - - # When showing the page, we exclude the students-only discount - # If the user tries to bypass the students-only rule, they still cannot enroll - self.client.force_login(par.user) - with mock.patch.object(tasks, "update_discount_sheet_for_participant") as task: - self.client.post( - "/preferences/discounts/", - {"discounts": str(gym.pk), "send_membership_reminder": True}, - ) - - # Immediately after signup, we sync this user to the sheet - task.delay.assert_called_once_with(gym.pk, par.pk) - - # The participant opted to be reminded when their membership expires - par.refresh_from_db() - self.assertTrue(par.send_membership_reminder) - - self.assertEqual([d.pk for d in par.discounts.all()], [gym.pk]) - - def test_inactive_discounts_excluded(self): - """We don't show inactive discounts to participants.""" - par = factories.ParticipantFactory.create() - active = factories.DiscountFactory.create(active=True, name="Active Discount") - factories.DiscountFactory.create(active=False, name="Inactive Discount") - - self.client.force_login(par.user) - - response = self.client.get("/preferences/discounts/") - self.assertEqual(response.status_code, 200) - self.assertEqual( - list(response.context["form"].fields["discounts"].choices), - [(active.pk, "Active Discount")], - ) - - def test_student_only_discounts_excluded(self): - """If the participant is not a student, they cannot see student-only discounts.""" - par = factories.ParticipantFactory.create( - affiliation=affiliations.NON_AFFILIATE.CODE - ) - student_only = factories.DiscountFactory.create( - student_required=True, name="Students Only" - ) - open_discount = factories.DiscountFactory.create( - student_required=False, name="All members allowed" - ) - self.client.force_login(par.user) - - # When showing the page, we exclude the students-only discount - response = self.client.get("/preferences/discounts/") - self.assertEqual(response.status_code, 200) - self.assertEqual( - list(response.context["form"].fields["discounts"].choices), - [(open_discount.pk, "All members allowed")], - ) - - # If the user tries to bypass the students-only rule, they still cannot enroll - response = self.client.post( - "/preferences/discounts/", - {"discounts": [student_only.pk, open_discount.pk]}, - ) - self.assertIn("discounts", response.context["form"].errors) - self.assertFalse(par.discounts.exists()) - - response = self.client.post( - "/preferences/discounts/", {"discounts": [student_only.pk]} - ) - self.assertIn("discounts", response.context["form"].errors) - self.assertFalse(par.discounts.exists()) - - def test_students_can_use_student_only_discounts(self): - """Students are obviously eligible for student-only discounts.""" - par = factories.ParticipantFactory.create( - affiliation=affiliations.MIT_UNDERGRAD.CODE - ) - student_only = factories.DiscountFactory.create( - student_required=True, name="Students Only" - ) - open_discount = factories.DiscountFactory.create( - student_required=False, name="All members allowed" - ) - self.client.force_login(par.user) - - # When showing the page, we show them both discounts, alphabetically - response = self.client.get("/preferences/discounts/") - self.assertEqual(response.status_code, 200) - self.assertCountEqual( - list(response.context["form"].fields["discounts"].choices), - [ - (open_discount.pk, "All members allowed"), - (student_only.pk, "Students Only"), - ], - ) - - # They can enroll in both - with mock.patch.object(tasks, "update_discount_sheet_for_participant") as task: - response = self.client.post( - "/preferences/discounts/", - {"discounts": [student_only.pk, open_discount.pk]}, - ) - task.delay.assert_has_calls( - [mock.call(student_only.pk, par.pk), mock.call(open_discount.pk, par.pk)] - ) - self.assertCountEqual( - [d.pk for d in par.discounts.all()], [student_only.pk, open_discount.pk] - ) - - def test_removal_from_discount(self): - """Unenrollment is supported too.""" - par = factories.ParticipantFactory.create(send_membership_reminder=True) - discount = factories.DiscountFactory.create() - par.discounts.add(discount) - - self.client.force_login(par.user) - with mock.patch.object(tasks, "update_discount_sheet_for_participant") as task: - self.client.post( - "/preferences/discounts/", - {"discounts": [], "send_membership_reminder": False}, - ) - # We don't bother updating the sheet, instead relying on the daily removal script - task.delay.assert_not_called() - - # The participant opted out of reminder emails - par.refresh_from_db() - self.assertFalse(par.send_membership_reminder) - - self.assertFalse(par.discounts.exists()) - - class EmailPreferencesTest(TestCase): def _expect_success( self, diff --git a/ws/urls.py b/ws/urls.py index 4205c465..8100c744 100644 --- a/ws/urls.py +++ b/ws/urls.py @@ -204,7 +204,11 @@ views.LeaderSignUpView.as_view(), name="leader_trip_signup", ), - path("preferences/discounts/", views.DiscountsView.as_view(), name="discounts"), + path( + "preferences/discounts/", + views.DiscountsView.as_view(), + name="discounts", + ), path( "preferences/email/", views.EmailPreferencesView.as_view(), diff --git a/ws/utils/geardb.py b/ws/utils/geardb.py index 2816a9d3..2c91daf9 100644 --- a/ws/utils/geardb.py +++ b/ws/utils/geardb.py @@ -57,7 +57,6 @@ class TripsInformation(NamedTuple): is_leader: bool num_trips_attended: int num_trips_led: int - num_discounts: int # Email address as given on Participant object # (We assume this is their preferred current email) email: str diff --git a/ws/utils/member_sheets.py b/ws/utils/member_sheets.py deleted file mode 100644 index 333d964d..00000000 --- a/ws/utils/member_sheets.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Allows maintenance of Google Spreadsheets with membership information. - -Users can opt-in to have their information shared with third parties. - -These methods will post information about users' membership status to Google -spreadsheets. Each spreadsheet will be shared with the company offering the -discount, so that they can verify membership status. -""" - -import bisect -import logging -import random -from collections.abc import Iterable, Iterator -from datetime import timedelta -from itertools import zip_longest -from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple - -import requests -from django.utils import timezone -from gspread.auth import service_account -from gspread.cell import Cell -from mitoc_const import affiliations - -from ws import enums, models, settings -from ws.utils import membership as membership_utils -from ws.utils.perms import is_chair - -if TYPE_CHECKING: - from gspread.worksheet import Worksheet - -logger = logging.getLogger(__name__) - -MIT_STUDENT_AFFILIATIONS = frozenset( - [ - affiliations.MIT_GRAD_STUDENT.CODE, - affiliations.MIT_UNDERGRAD.CODE, - ] -) - -# We generally trust our own membership cache -# But just to catch possible failures, we can ask the gear db for updates on old members -WINDOW_FOR_RE_CHECKING_INACTIVE_MEMBERS = timedelta(days=7) - - -class GearDatabaseError(Exception): - pass - - -class SpreadsheetLabels(NamedTuple): - name: str - email: str - membership: str - access: str - leader: str - student: str - school: str - - -class SheetWriter: - """Utility methods for formatting a row in discount worksheets.""" - - # Use constants to refer to columns - labels = SpreadsheetLabels( - name="Name", - email="Email", - membership="Membership Status", - access="Access Level", - leader="Leader Status", - student="Student Status", - school="School", - ) - - discount: models.Discount - header: tuple[str, ...] - - def __init__(self, discount: models.Discount, trust_cache: bool): - """Identify the columns that will be used in the spreadsheet.""" - self.discount = discount - self.header = self._header_for_discount(discount) - self.trust_cache = trust_cache - - def _header_for_discount(self, discount: models.Discount) -> tuple[str, ...]: - # These columns appear in all discount sheets - header = [self.labels.name, self.labels.email, self.labels.membership] - - # Depending on properties of the discount, include additional cols - extra_optional_columns = [ - (self.labels.access, discount.report_access), - (self.labels.leader, discount.report_leader), - (self.labels.student, discount.report_student), - (self.labels.school, discount.report_school), - ] - for label, should_include in extra_optional_columns: - if should_include: - header.append(label) - - return tuple(header) - - @staticmethod - def activity_descriptors(participant: models.Participant) -> Iterator[str]: - """Yield a description for each activity the participant is a leader. - - Mentions if the person is a leader or a chair, but does not give their - specific ranking. - """ - active_ratings = participant.leaderrating_set.filter(active=True) - for activity in active_ratings.values_list("activity", flat=True): - activity_enum = enums.Activity(activity) - position = ( - "chair" - if is_chair(participant.user, activity_enum, False) - else "leader" - ) - yield f"{activity_enum.label} {position}" - - def leader_text(self, participant: models.Participant) -> str: - return ", ".join(self.activity_descriptors(participant)) - - @staticmethod - def school(participant: models.Participant) -> str: - """Return what school participant goes to, if applicable.""" - if not participant.is_student: - return "N/A" - if participant.affiliation in MIT_STUDENT_AFFILIATIONS: - return "MIT" - return "Other" # We don't collect non-MIT affiliation - - def access_text(self, participant: models.Participant) -> str: - """Simple string indicating level of access person should have.""" - if self.discount.administrators.filter(pk=participant.pk).exists(): - return "Admin" - if participant.is_leader: - return "Leader" - if participant.is_student: - return "Student" - return "Standard" - - @staticmethod - def _cache_recent_enough(membership: models.Membership) -> bool: - """Return if the membership cache on an inactive member is somewhat recent. - - "Somewhat" recently basically means that we're pretty confident the person - remains a non-member. - """ - # If we use a hard interval, then we'll end up re-checking all members at once. - # Use a jigger so we re-check a subset of members each week. - day_seconds = int(timedelta(days=1).total_seconds()) - jigger = timedelta(seconds=random.randint(-2 * day_seconds, 2 * day_seconds)) - cutoff = timezone.now() - WINDOW_FOR_RE_CHECKING_INACTIVE_MEMBERS + jigger - - return membership.last_cached > cutoff - - def _membership_to_report( - self, participant: models.Participant - ) -> models.Membership: - """Return a Membership object that is accurate enough for our purposes.""" - membership = participant.membership - - # Most participants will be active members - if membership and membership.membership_active: - return membership - - if membership and self.trust_cache and self._cache_recent_enough(membership): - return membership - - logger.info("Participant %s lacks a membership, checking again", participant) - try: - return membership_utils.get_latest_membership(participant) - except requests.exceptions.RequestException as e: - raise GearDatabaseError from e - - def membership_status(self, participant: models.Participant) -> str: - """Return membership status, irrespective of waiver status. - - (Companies don't care about participant waiver status, so ignore it). - """ - try: - membership = self._membership_to_report(participant) - except GearDatabaseError: - logger.exception("Error fetching membership information!") - # This is hopefully a temporary error... - # Discount sheets are re-generated every day at the very least. - # Avoid breaking the whole sheet for all users; continue on - return "Unknown" - - # We report Active/Expired, since companies don't care about waiver status - if membership.membership_active: - return "Active" - if membership.membership_expires: - return f"Expired {membership.membership_expires.isoformat()}" - return "Missing" - - def get_row(self, participant: models.Participant) -> tuple[str, ...]: - """Get the row values that match the header for this discount sheet.""" - row_mapper = { - self.labels.name: participant.name, - self.labels.email: participant.email, - self.labels.membership: self.membership_status(participant), - self.labels.student: participant.get_affiliation_display(), - self.labels.school: self.school(participant), - } - - # Only fetch these if needed, as we hit the database - if self.labels.leader in self.header: - row_mapper[self.labels.leader] = self.leader_text(participant) - if self.labels.access in self.header: - row_mapper[self.labels.access] = self.access_text(participant) - - return tuple(row_mapper[label] for label in self.header) - - -def _assign(cells: Iterable[Cell], values: Iterable[Any]) -> None: - """Set the values of cells, but **do not** issue an API call to update.""" - for cell, value in zip(cells, values, strict=True): - cell.value = value - - -def update_participant( - discount: models.Discount, - participant: models.Participant, -) -> None: - """Add or update the participant. - - Much more efficient than updating the entire sheet. - """ - assert settings.OAUTH_JSON_CREDENTIALS is not None, "gpread not configured?" - client = service_account(Path(settings.OAUTH_JSON_CREDENTIALS)) - wks = client.open_by_key(discount.ga_key).sheet1 - writer = SheetWriter(discount, trust_cache=False) - - new_row = writer.get_row(participant) - - # Attempt to find existing row, update it if found - last_col = len(writer.header) - for cell in wks.findall(participant.email): - if cell.col == 2: # (Participants _could_ name themselves an email...) - row_cells = wks.range( # pylint: disable=too-many-function-args - cell.row, 1, cell.row, last_col - ) - _assign(row_cells, new_row) - wks.update_cells(row_cells) - return - - # No existing row was found for the participant, so insert (maintaining sort) - sorted_names: list[str] = [] - for name in wks.col_values(1)[1:]: - assert isinstance(name, str) # (not numeric, not null) - sorted_names.append(name) - row_index = bisect.bisect(sorted_names, participant.name) + 1 - wks.insert_row(new_row, row_index + 1) - - -def update_discount_sheet(discount: models.Discount, trust_cache: bool) -> None: - """Update the entire worksheet, updating all members' status. - - This will remove members who no longer wish to share their information, - so it's important to run this periodically. - - For individual updates, this approach should be avoided (instead, opting to - update individual cells in the spreadsheet). - """ - assert settings.OAUTH_JSON_CREDENTIALS is not None, "gpread not configured?" - client = service_account(Path(settings.OAUTH_JSON_CREDENTIALS)) - wks: Worksheet = client.open_by_key(discount.ga_key).sheet1 - participants = list( - discount.participant_set.select_related("membership", "user").order_by("name") - ) - - writer = SheetWriter(discount, trust_cache) - - # Resize sheet to exact size, select all cells - num_rows, num_cols = len(participants) + 1, len(writer.header) - wks.resize(num_rows, num_cols) - # pylint: disable=too-many-function-args - all_cells = wks.range(1, 1, num_rows, num_cols) - - rows = zip_longest(*([iter(all_cells)] * len(writer.header))) - - _assign(next(rows), writer.header) - - # Update each row with current membership information - for participant, row in zip(participants, rows, strict=True): - _assign(row, writer.get_row(participant)) - - # Batch update to minimize API calls - wks.update_cells(all_cells) diff --git a/ws/utils/member_stats.py b/ws/utils/member_stats.py index ed9ba491..0d5a16b4 100644 --- a/ws/utils/member_stats.py +++ b/ws/utils/member_stats.py @@ -29,7 +29,6 @@ class TripsInformation(NamedTuple): is_leader: bool num_trips_attended: int num_trips_led: int - num_discounts: int # Email address as given on Participant object # (We assume this is their preferred current email) email: str @@ -200,9 +199,8 @@ def _get_trip_stats_by_user() -> dict[int, TripsInformation]: additional_stats = ( models.Participant.objects.all() .annotate( - # Future optimization: *most* participants don't lead trips or use discounts. + # Future optimization: *most* participants don't lead trips # Querying those separately should avoid the need to do pointless JOINs - num_discounts=Count("discounts", distinct=True), num_trips_led=Count("trips_led", distinct=True), is_leader=Exists( models.LeaderRating.objects.filter( @@ -216,7 +214,6 @@ def _get_trip_stats_by_user() -> dict[int, TripsInformation]: "email", "is_leader", "num_trips_led", - "num_discounts", ) ) @@ -227,7 +224,6 @@ def _get_trip_stats_by_user() -> dict[int, TripsInformation]: is_leader=par["is_leader"], num_trips_attended=trips_per_participant.get(par["pk"], 0), num_trips_led=par["num_trips_led"], - num_discounts=par["num_discounts"], ) for par in additional_stats } @@ -240,7 +236,6 @@ def fetch_membership_information(cache_strategy: CacheStrategy) -> MemberStatsRe - have attended any trips - have led any trips - have rented gear - - make use MITOC discounts """ stats = fetch_geardb_stats_for_all_members(cache_strategy) return stats.with_trips_information() diff --git a/ws/views/preferences.py b/ws/views/preferences.py index 92471528..420dd085 100644 --- a/ws/views/preferences.py +++ b/ws/views/preferences.py @@ -1,8 +1,7 @@ """Participant preference views. -Users can enroll in discounts, rank their preferred trips, or elect to be -paired with another participant. All of these options are deemed "preferences" -of the participant. +Users can rank their preferred trips, or elect to be paired with another participant. +All of these options are deemed "preferences" of the participant. """ import contextlib @@ -11,14 +10,13 @@ from typing import Any from django.contrib import messages -from django.db.models import Q from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import redirect from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator -from django.views.generic import CreateView, FormView, TemplateView +from django.views.generic import CreateView, TemplateView -from ws import enums, forms, models, tasks, unsubscribe +from ws import enums, forms, models, unsubscribe from ws.decorators import participant_required, user_info_required from ws.mixins import LotteryPairingMixin from ws.utils.dates import is_currently_iap, local_date @@ -85,50 +83,8 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) -class DiscountsView(FormView): - form_class = forms.DiscountForm +class DiscountsView(TemplateView): template_name = "preferences/discounts.html" - success_url = reverse_lazy("discounts") - - def get_queryset(self): - available = Q(active=True) - par = self.request.participant - if not (par and par.is_student): - available &= Q(student_required=False) - return models.Discount.objects.filter(available) - - def get_form(self, form_class=None): - form = super().get_form(form_class) - form.fields["discounts"].queryset = self.get_queryset().order_by("name") - return form - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["instance"] = self.request.participant - return kwargs - - def form_valid(self, form): - participant = form.save() - for discount in participant.discounts.all(): - tasks.update_discount_sheet_for_participant.delay( - discount.pk, participant.pk - ) - - if participant.membership and participant.membership.membership_active: - messages.success(self.request, "Discount choices updated!") - else: - messages.error( - self.request, - "You must be a current MITOC member to receive discounts. " - "We recorded your discount choices, but please pay dues to be eligible", - ) - return redirect(reverse("pay_dues")) - - return super().form_valid(form) - - @method_decorator(user_info_required) - def dispatch(self, request, *args, **kwargs): - return super().dispatch(request, *args, **kwargs) class LotteryPreferencesView(TemplateView, LotteryPairingMixin):