|
| 1 | +import logging |
| 2 | +from datetime import timedelta |
| 3 | +from typing import NamedTuple |
| 4 | + |
| 5 | +from django.core.mail import EmailMultiAlternatives |
| 6 | +from django.template.loader import get_template |
| 7 | +from typing_extensions import assert_never |
| 8 | + |
| 9 | +from ws import enums, models |
| 10 | +from ws.utils import dates as date_utils |
| 11 | + |
| 12 | +logger = logging.getLogger(__name__) |
| 13 | + |
| 14 | + |
| 15 | +class ReminderIsSent(NamedTuple): |
| 16 | + trip_id: int |
| 17 | + activity: enums.Activity |
| 18 | + has_trip_info: bool |
| 19 | + |
| 20 | + |
| 21 | +def _upcoming_trips_lacking_approval(trips: list[models.Trip]) -> list[models.Trip]: |
| 22 | + today = date_utils.local_date() |
| 23 | + already_approved_trip_ids = { |
| 24 | + approval.trip_id |
| 25 | + for approval in models.ChairApproval.objects.filter(trip_id__in=trips) |
| 26 | + } |
| 27 | + return [ |
| 28 | + trip |
| 29 | + for trip in trips |
| 30 | + # It's pointless to remind about trips in the past |
| 31 | + if trip.trip_date >= today |
| 32 | + # Obviously, approved trips ought be excluded |
| 33 | + and trip.id not in already_approved_trip_ids |
| 34 | + # Circuses & trips that have no activity should never need reminding |
| 35 | + # (we assume they got in by nature of a race condition) |
| 36 | + and trip.required_activity_enum() is not None |
| 37 | + ] |
| 38 | + |
| 39 | + |
| 40 | +def _notified_chairs_too_recently(trips: list[models.Trip]) -> bool: |
| 41 | + now = date_utils.local_now() |
| 42 | + |
| 43 | + # Wait, why are multiple activities possible? (it's a weird edge case) |
| 44 | + # This method answers "should we notify one activity chair about these trips?" |
| 45 | + # We call the method with a collection of trip IDs meant for one chair; |
| 46 | + # they **all shared the same activity at the time they were inspected.** |
| 47 | + # However, if one trip changed activity in the small window between |
| 48 | + # fanning out trips to chairs based on activity & calling this method... |
| 49 | + # we still want to notify at least one chair about the trip, even |
| 50 | + # if the activity chair that we notify is the wrong one! |
| 51 | + # (we'll notify the new activity chair on the next pass) |
| 52 | + trip_activities: list[str] = [] |
| 53 | + for trip in trips: |
| 54 | + activity_enum = trip.required_activity_enum() |
| 55 | + if activity_enum is not None: |
| 56 | + trip_activities.append(activity_enum.value) |
| 57 | + |
| 58 | + try: |
| 59 | + last_reminder_sent = models.ChairApprovalReminder.objects.filter( |
| 60 | + activity__in=trip_activities |
| 61 | + ).latest("pk") |
| 62 | + except models.ChairApprovalReminder.DoesNotExist: |
| 63 | + pass |
| 64 | + else: |
| 65 | + if last_reminder_sent.time_created > (now - timedelta(minutes=55)): |
| 66 | + logger.error( |
| 67 | + "Trying to send another reminder at %s, less than an hour since last reminder email at %s", |
| 68 | + now, |
| 69 | + last_reminder_sent.time_created, |
| 70 | + ) |
| 71 | + return True |
| 72 | + return False |
| 73 | + |
| 74 | + |
| 75 | +def _trips_without_similar_reminders(trips: list[models.Trip]) -> list[models.Trip]: |
| 76 | + """Return any trips which have not already been sent to chairs in their current state.""" |
| 77 | + trip_ids_having_itinerary = set( |
| 78 | + models.TripInfo.objects.filter(trip__in=trips).values_list("trip", flat=True) |
| 79 | + ) |
| 80 | + |
| 81 | + def _get_reminder_key(trip: models.Trip) -> ReminderIsSent: |
| 82 | + activity_enum = trip.required_activity_enum() |
| 83 | + assert activity_enum is not None, f"Trip #{trip.id} somehow has no activity?" |
| 84 | + return ReminderIsSent( |
| 85 | + trip.pk, |
| 86 | + activity_enum, |
| 87 | + trip.pk in trip_ids_having_itinerary, |
| 88 | + ) |
| 89 | + |
| 90 | + # We can regard a trip as having been notified if both: |
| 91 | + # 1. An email was sent containing that trip in the message. |
| 92 | + # 2. The activity & itinerary (at the time the email was sent) match current values. |
| 93 | + already_reminded_keys = { |
| 94 | + ReminderIsSent( |
| 95 | + reminder.trip_id, |
| 96 | + enums.Activity(reminder.activity), |
| 97 | + reminder.trip_id in trip_ids_having_itinerary, |
| 98 | + ) |
| 99 | + for reminder in models.ChairApprovalReminder.objects.filter(trip_id__in=trips) |
| 100 | + } |
| 101 | + |
| 102 | + # Obviously, we can notify for *any* trip that's never had a reminder. |
| 103 | + could_notify_trip_ids = {trip.id for trip in trips} - { |
| 104 | + key.trip_id for key in already_reminded_keys |
| 105 | + } |
| 106 | + # Trips that were notified with a different activity *or* itinerary status |
| 107 | + # are eligible for re-notification! |
| 108 | + could_notify_trip_ids.update( |
| 109 | + key.trip_id |
| 110 | + for key in already_reminded_keys.difference( |
| 111 | + _get_reminder_key(trip) for trip in trips |
| 112 | + ) |
| 113 | + ) |
| 114 | + return [ |
| 115 | + trip |
| 116 | + for trip in trips |
| 117 | + if trip.id in could_notify_trip_ids |
| 118 | + # We do *not* want to prompt for "hey, approve these trips!" before itineraries are available. |
| 119 | + # 1. It encourages the wrong behavior (approving trips too early to silence emails). |
| 120 | + # 2. Trips are ideally meant to be approved once an itinerary is posted. |
| 121 | + and trip.info_editable |
| 122 | + ] |
| 123 | + |
| 124 | + |
| 125 | +def at_least_one_trip_merits_reminder_email(trips: list[models.Trip]) -> list[str]: |
| 126 | + """Avoid sending reminder emails until actually necessary. |
| 127 | +
|
| 128 | + This will return a non-empty list *only* if: |
| 129 | + 1. We should notify the chair in the first place about one or more trips. |
| 130 | + 2. There would be something new in the email body were we to notify chairs |
| 131 | + about the trips needing approval. |
| 132 | +
|
| 133 | + If we're already sending an email, we might as well notify them about |
| 134 | + *all* trips currently pending approval. |
| 135 | + """ |
| 136 | + now = date_utils.local_now() |
| 137 | + if _notified_chairs_too_recently(trips): |
| 138 | + return [] |
| 139 | + trips_lacking_approval = _upcoming_trips_lacking_approval(trips) |
| 140 | + |
| 141 | + # If *any* trips leave tomorrow & don't have an approval, remind! |
| 142 | + tomorrow = now.date() + timedelta(days=1) |
| 143 | + trips_leaving_soon = [ |
| 144 | + f"Trip #{trip.id} starts very soon (on {trip.trip_date}) but has no approval!" |
| 145 | + for trip in trips_lacking_approval |
| 146 | + if trip.trip_date <= tomorrow |
| 147 | + ] |
| 148 | + if trips_leaving_soon: |
| 149 | + return trips_leaving_soon |
| 150 | + |
| 151 | + return [ |
| 152 | + ( |
| 153 | + f"Trip #{trip.id} could complete an itinerary, " |
| 154 | + f"has{'' if trip.info else ' not'} done so, " |
| 155 | + "and chairs have not been emailed about the trip yet." |
| 156 | + ) |
| 157 | + for trip in _trips_without_similar_reminders(trips_lacking_approval) |
| 158 | + ] |
| 159 | + |
| 160 | + |
| 161 | +def emails_for_activity_chair(activity: enums.Activity) -> list[str]: |
| 162 | + if activity == enums.Activity.BIKING: |
| 163 | + |
| 164 | + if activity == enums.Activity.BOATING: |
| 165 | + |
| 166 | + if activity == enums.Activity.CABIN: |
| 167 | + # 1) Cabin trips don't require approval |
| 168 | + # 2) We don't provide any way to indicate *which* cabin is being used. |
| 169 | + |
| 170 | + if activity == enums.Activity.CLIMBING: |
| 171 | + |
| 172 | + if activity == enums.Activity.HIKING: |
| 173 | + # Include 3-season chair for now, 3SSC is new |
| 174 | + |
| 175 | + if activity == enums.Activity.WINTER_SCHOOL: |
| 176 | + # Exclude WS chairs, they don't approve trips. |
| 177 | + |
| 178 | + assert_never(activity) |
| 179 | + |
| 180 | + |
| 181 | +def notify_activity_chair( |
| 182 | + activity_enum: enums.Activity, |
| 183 | + trips: list[models.Trip], |
| 184 | +) -> None: |
| 185 | + context = {"activity_enum": activity_enum, "trips": trips} |
| 186 | + |
| 187 | + text_content = get_template("email/sole/trips_needing_approval.txt").render(context) |
| 188 | + html_content = get_template("email/sole/trips_needing_approval.html").render( |
| 189 | + context |
| 190 | + ) |
| 191 | + msg = EmailMultiAlternatives( |
| 192 | + f"{len(trips)} {activity_enum.label} trip{'' if len(trips) == 1 else 's'} need approval", |
| 193 | + text_content, |
| 194 | + to=emails_for_activity_chair(activity_enum), |
| 195 | + # TEMPORARY while we make sure this feature works as expected. |
| 196 | + |
| 197 | + ) |
| 198 | + msg.attach_alternative(html_content, "text/html") |
| 199 | + msg.send() |
| 200 | + |
| 201 | + # Creating a record of the reminders is the mechanism by which we don't spam chairs. |
| 202 | + models.ChairApprovalReminder.objects.bulk_create( |
| 203 | + [ |
| 204 | + models.ChairApprovalReminder( |
| 205 | + trip=trip, |
| 206 | + # Importantly, we log the receiving chair activity, *NOT* trip activity. |
| 207 | + # (this has ramifications for an edge case on trips changing activity) |
| 208 | + activity=activity_enum.value, |
| 209 | + had_trip_info=trip.info is not None, |
| 210 | + ) |
| 211 | + for trip in trips |
| 212 | + ] |
| 213 | + ) |
0 commit comments