def form_valid(self, form): if local_now() < itinerary_available_at(self.trip.trip_date): form.errors['__all__'] = ErrorList(["Form not yet available!"]) return self.form_invalid(form) self.trip.info = form.save() self.trip.save() return super().form_valid(form)
def after_lottery(self): """ True if it's after the lottery, but takes place before the next one. """ next_lottery = dateutils.next_lottery() past_lottery = dateutils.lottery_time(next_lottery - timedelta(days=7)) return (dateutils.local_now() > past_lottery and self.midnight_before < next_lottery)
def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['itinerary_available_at'] = itinerary_available_at( self.trip.trip_date) context['info_form_available'] = local_now( ) >= context['itinerary_available_at'] return context
def ws_lectures_complete(): """ Return if the mandatory first two lectures have occurred. Because IAP varies every year, use traits of the first week of Winter School in order to determine if lectures have completed. If there are completed Winter School trips this year, then it's no longer the first week, and lectures have completed. If it's at least Thursday night and there are future trips, we can deduce that lectures have ended. """ now = dateutils.local_now() today = now.date() dow = now.weekday() jan_1 = dateutils.jan_1() trips_this_ws = models.Trip.objects.filter(trip_date__gte=jan_1, activity='winter_school') after_thursday = dow > 3 or dow == 3 and now.hour >= 21 if trips_this_ws.filter(trip_date__lt=today): return True elif trips_this_ws.filter(trip_date__gte=today) and after_thursday: return True else: # It's Winter School, but it's not late enough in the first week return False
def _post_login_update_password_validity(self, times_password_seen): """ After form.login has been invoked, handle password being breached or not. This method exists to serve two types of users: - those who have a corresponding Participant model (most active users) - those who have only a user record (signed up, never finished registration) """ user = self.request.user assert user, "Method should be invoked *after* user login." # The user may or may not have a participant record linked! # If they do have a participant, set the password as insecure (or not) participant = models.Participant.from_user(user) if participant: participant.insecure_password = bool(times_password_seen) if times_password_seen is not None: # (None indicates an API failure) participant.password_last_checked = local_now() participant.save() if times_password_seen: subject = ( f"Participant {participant.pk}" if participant else f"User {user.pk}" ) logger.info("%s logged in with a breached password", subject) if not participant: # For non-new users lacking a participant (very rare), a message + redirect is fine. # If they ignore the reset, they'll be locked out once creating a Participant record. messages.error( self.request, 'This password has been compromised! Please choose a new password. ' 'If you use this password on any other sites, we recommend changing it immediately.', )
def configure_logger(self): """ Configure a stream to save the log to the trip. """ datestring = datetime.strftime(local_now(), "%Y-%m-%dT:%H:%M:%S") filename = Path(settings.WS_LOTTERY_LOG_DIR, f"ws_{datestring}.log") self.handler = logging.FileHandler(filename) self.handler.setLevel(logging.DEBUG) self.logger.addHandler(self.handler)
def lapsed_participants(): """Return all participants who've not used the system in a long time. We exclude anybody who's signed a waiver, paid dues, been on trips, or updated their profile recently. We consider a number of factors for "activity" so as to minimize the chance that we accidentally consider somebody to have lapsed. """ now = date_utils.local_now() one_year_ago = now - timedelta(days=365) # Participants are required to update their info periodically # If a sufficient period of time has lapsed since they last updated, # we can deduce that they're likely not going on trips anymore multiple_update_periods = timedelta(days=(2.5 * settings.MUST_UPDATE_AFTER_DAYS)) lapsed_update = Q(profile_last_updated__lt=(now - multiple_update_periods)) today = now.date() active_members = ( # Anybody with a current membership/waiver is active Q(membership__membership_expires__gte=today) | Q(membership__waiver_expires__gte=today) | # Anybody who led or participated in a trip during the last year Q(trips_led__trip_date__gte=one_year_ago) | Q(trip__trip_date__gte=one_year_ago) | # Anybody signed up for a trip in the future # (Should be disallowed without a current waiver, but might as well check) Q(signup__trip__trip_date__gte=today) ) return models.Participant.objects.filter(lapsed_update).exclude(active_members)
def remind_participants_to_renew(): """Identify all participants who requested membership reminders, email them. This method is designed to be idempotent (one email per participant). """ now = date_utils.local_now() # Reminders should be sent ~40 days before the participant's membership has expired. # We should only send one reminder every ~365 days or so. most_recent_allowed_reminder_time = now - timedelta(days=300) participants_needing_reminder = models.Participant.objects.filter( # Crucially, we *only* send these reminders to those who've opted in. send_membership_reminder=True, # Anybody with soon-expiring membership is eligible to renew. membership__membership_expires__lte=( now + models.Membership.RENEWAL_WINDOW).date(), # While it should technically be okay to tell people to renew *after* expiry, don't. # We'll run this task daily, targeting each participant for ~40 days before expiry. # Accordingly, various edge cases should have been handled some time in those 40 days. # The language in emails will suggest that renewal is for an active membership, too. membership__membership_expires__gte=now.date(), ).exclude( # Don't attempt to remind anybody who has already been reminded. membershipreminder__reminder_sent_at__gte= most_recent_allowed_reminder_time) # Farm out the delivery of individual emails to separate workers. for pk in participants_needing_reminder.values_list('pk', flat=True): logger.info("Identified participant %d as needing renewal reminder", pk) remind_lapsed_participant_to_renew.delay(pk)
def can_attend_trip(user, trip): """ Return whether the user's membership allows them to attend the trip. Their cached membership may be sufficient to show that the last membership/waiver stored allows them to go on the trip. Otherwise, we must consult the gear database to be sure whether or not they can go. """ participant = models.Participant.from_user(user, True) if not participant: return False if participant.can_attend(trip): return True # The first check used the cache, but failed. # To be sure they can't attend, we must consult the gear database membership = participant.membership original_ts = membership.last_cached if membership else local_now() try: update_membership_cache(participant) except OperationalError: if sentry.client: sentry.client.captureException() return True # Database is down! Just assume they can attend participant.membership.refresh_from_db() assert participant.membership.last_cached > original_ts return participant.can_attend(trip)
def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trip = context['trip'] context['itinerary_available_at'] = itinerary_available_at(trip.trip_date) context['info_form_editable'] = trip.info_editable context['waiting_to_open'] = local_now() < context['itinerary_available_at'] return context
def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['itinerary_available_at'] = itinerary_available_at(self.trip.trip_date) context['info_form_available'] = ( local_now() >= context['itinerary_available_at'] ) return context
def __init__(self, execution_datetime=None): # It's important that we be able to simulate the future time with `execution_datetime` # If test-running the lottery in advance, we want the same ranking to be used later self.lottery_runtime = execution_datetime or local_now() today = self.lottery_runtime.date() self.today = today # Silences a weird pylint error self.jan_1st = self.today.replace(month=1, day=1) self.lottery_key = f"ws-{today.isoformat()}"
def reasons_cannot_attend( user: Union[models.User, AnonymousUser], trip: models.Trip) -> Iterator[enums.TripIneligibilityReason]: """Yield reasons why the user is not allowed to attend the trip. Their cached membership may be sufficient to show that the last membership/waiver stored allows them to go on the trip. Otherwise, we must consult the gear database to be sure whether or not they can go. """ if not user.is_authenticated: yield enums.TripIneligibilityReason.NOT_LOGGED_IN return participant = models.Participant.from_user(user, True) if not participant: yield enums.TripIneligibilityReason.NO_PROFILE_INFO return reasons = list(participant.reasons_cannot_attend(trip)) if not any(reason.related_to_membership for reason in reasons): # There may be no reasons, or they may just not pertain to membership. # In either case, we don't need to refresh membership! yield from iter(reasons) return # The first check identified that the participant cannot attend due to membership problems. # It used the cache, so some reasons for failure may have been due to a stale cache. # To be sure they can't attend, we must consult the gear database. membership = participant.membership before_refreshing_ts = local_now() original_ts = membership.last_cached if membership else before_refreshing_ts try: latest_membership = get_latest_membership(participant) except requests.exceptions.RequestException: capture_exception() return # (When running tests we often mock away wall time so it's constant) if local_now() != before_refreshing_ts: assert latest_membership.last_cached > original_ts participant.refresh_from_db() assert participant.membership is not None, "Cache refresh failed!" yield from participant.reasons_cannot_attend(trip)
def _leader_emails_missing_itinerary(trips): now = date_utils.local_now() no_itinerary_trips = (trip for trip in trips if not trip.info) for trip in no_itinerary_trips: if now < date_utils.itinerary_available_at(trip.trip_date): continue # Not yet able to submit! for leader in trip.leaders.all(): yield leader.email_addr
def test_old_student_affiliation_dated(self): student = factories.ParticipantFactory.create( affiliation= 'S', # Ambiguous! Is it an MIT student? non-MIT? Undergrad/grad? last_updated=date_utils.local_now(), ) self.assertCountEqual(student.problems_with_profile, [enums.ProfileProblem.LEGACY_AFFILIATION])
def _eligible_trips(): """Identify all trips that are open for signups, or will be.""" now = date_utils.local_now() upcoming_trips = (models.Trip.objects.filter( trip_date__gte=now.date()).filter(signups_close_at__gt=now).order_by( 'trip_date', 'time_created')) return annotated_for_trip_list(upcoming_trips)
def form_valid(self, form): response = super().form_valid(form) if self.request.participant: self.request.participant.insecure_password = False # NOTE: Technically, we cannot know for sure if the API call to HIBP failed # (and thus, we might be updating this timestamp incorrectly) # However, we log API failures and could use that information to identify the last success self.request.participant.password_last_checked = local_now() self.request.participant.save() return response
def trip_edit_buttons(trip, participant, user, hide_approve=False): available_at = date_utils.itinerary_available_at(trip.trip_date) return { 'trip': trip, 'is_chair': perm_utils.chair_or_admin(user, trip.activity), 'is_creator': trip.creator == participant, 'is_trip_leader': perm_utils.leader_on_trip(participant, trip, False), 'hide_approve': hide_approve, # Hide approval even if user is a chair 'itinerary_available_at': available_at, 'available_today': available_at.date() == date_utils.local_date(), 'info_form_available': date_utils.local_now() >= available_at, }
def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trip = context['trip'] context['itinerary_available_at'] = itinerary_available_at( trip.trip_date) context['info_form_editable'] = trip.info_editable if local_now() < context['itinerary_available_at']: context['waiting_to_open'] = True context['form'].fields.pop('accurate') for field in context['form'].fields.values(): field.disabled = True return context
def refresh_all_membership_cache(): """ Refresh all membership caches in the system. After this is run, every participant in the system will have membership information that is no more than a week old. """ last_week = local_now() - timedelta(weeks=1) needs_update = Q(membership__isnull=True) | Q(membership__last_cached__lt=last_week) all_participants = models.Participant.objects.select_related('membership') for par in all_participants.filter(needs_update): update_membership_cache(par)
def trip_edit_buttons(trip, participant, user, hide_approve=False): available_at = date_utils.itinerary_available_at(trip.trip_date) return { 'trip': trip, 'is_chair': perm_utils.chair_or_admin(user, trip.activity), 'is_creator': trip.creator == participant, 'is_trip_leader': perm_utils.leader_on_trip(participant, trip, False), 'hide_approve': hide_approve, # Hide approval even if user is a chair 'itinerary_available_at': available_at, 'available_today': available_at.date() == date_utils.local_date(), 'info_form_available': date_utils.local_now() >= available_at }
def __init__(self, *args, **kwargs): allowed_programs = kwargs.pop("allowed_programs", None) super().__init__(*args, **kwargs) trip = self.instance if trip.pk is None: # New trips must have *all* dates/times in the future now = local_now() # (the `datetime-local` inputs don't take timezones at all) # We specify only to the minutes' place so that we don't display seconds naive_iso_now = now.replace(tzinfo=None).isoformat( timespec='minutes') self.fields['trip_date'].widget.attrs['min'] = now.date( ).isoformat() # There is *extra* dynamic logic that (open < close at <= trip date) # However, we can at minimum enforce that times occur in the future self.fields['signups_open_at'].widget.attrs['min'] = naive_iso_now self.fields['signups_close_at'].widget.attrs['min'] = naive_iso_now # Use the participant queryset to cover an edge case: # editing an old trip where one of the leaders is no longer a leader! self.fields[ 'leaders'].queryset = models.Participant.objects.get_queryset() self.fields['leaders'].help_text = None # Disable "Hold command..." # We'll dynamically hide the level widget on GET if it's not a winter trip # On POST, we only want this field required for winter trips program = self.data.get('program') program_enum = enums.Program(program) if program else None self.fields['level'].required = (program_enum and program_enum.winter_rules_apply()) initial_program: Optional[ enums.Program] = trip.pk and trip.program_enum if allowed_programs is not None: self.fields['program'].choices = list( self._allowed_program_choices(allowed_programs)) # If it's currently WS, the WS program is almost certainly what's desired for new trips. if (enums.Program.WINTER_SCHOOL in allowed_programs and is_currently_iap() and not trip.pk): initial_program = enums.Program.WINTER_SCHOOL self._init_wimp() # (No need for `ng-init`, we have a custom directive) self.fields['leaders'].widget.attrs['data-ng-model'] = 'leaders' _bind_input(self, 'program', initial=initial_program and initial_program.value) _bind_input(self, 'algorithm', initial=trip and trip.algorithm)
def refresh_all_membership_cache(): """ Refresh all membership caches in the system. After this is run, every participant in the system will have membership information that is no more than a week old. """ last_week = local_now() - timedelta(weeks=1) needs_update = (Q(membership__isnull=True) | Q(membership__last_cached__lt=last_week)) all_participants = models.Participant.objects.select_related('membership') for par in all_participants.filter(needs_update): update_membership_cache(par)
def form_valid(self, form): response = super().form_valid(form) if self.request.participant: models.PasswordQuality.objects.update_or_create( participant=self.request.participant, defaults={ 'is_insecure': False, # NOTE: Technically, we cannot know for sure if the API call to HIBP failed # (and thus, we might be updating this timestamp incorrectly) # However, we log API failures and could use that information to identify the last success. 'last_checked': local_now(), }, ) return response
def can_reapply(self, latest_application): """ Winter School allows one application per year. Other activities just impose a reasonable waiting time. """ if not latest_application: return True # Not "re-applying," just applying for first time if latest_application.activity == models.LeaderRating.WINTER_SCHOOL: return latest_application.year < self.application_year # Allow upgrades after 2 weeks, repeat applications after ~6 months waiting_period_days = 14 if latest_application.rating_given else 180 time_passed = local_now() - latest_application.time_created return time_passed > timedelta(days=waiting_period_days)
def make_fcfs(self, signups_open_at=None): """ Set the algorithm to FCFS, adjust signup times appropriately. """ self.algorithm = 'fcfs' now = dateutils.local_now() if signups_open_at: self.signups_open_at = signups_open_at elif dateutils.wed_morning() <= now < dateutils.closest_wed_at_noon(): # If posted between lottery time and noon, make it open at noon self.signups_open_at = dateutils.closest_wed_at_noon() else: self.signups_open_at = now # If a Winter School trip is last-minute, occurring mid-week, # we end up with a close time in the past (preventing trip creation) if self.fcfs_close_time > now: self.signups_close_at = self.fcfs_close_time
class Car(models.Model): # As long as this module is reloaded once a year, this is fine # (First license plates were issued in Mass in 1903) year_min, year_max = 1903, dateutils.local_now().year + 2 # Loosely validate - may wish to use international plates in the future license_plate = models.CharField(max_length=31, validators=[alphanum]) state = USStateField() make = models.CharField(max_length=63) model = models.CharField(max_length=63) year = models.PositiveIntegerField(validators=[MaxValueValidator(year_max), MinValueValidator(year_min)]) color = models.CharField(max_length=63) def __unicode__(self): car_info = "{} {} {} {}".format(self.color, self.year, self.make, self.model) registration_info = "-".join([self.license_plate, self.state]) return "{} ({})".format(car_info, registration_info)
def complain_if_missing_itineraries(request): """ Create messages if the leader needs to complete trip itineraries. """ if not ws.utils.perms.is_leader(request.user): return now = dateutils.local_now() # Most trips require itineraries, but some (TRS, etc.) do not # All WS trips require itineraries, though future_trips_without_info = request.participant.trips_led.filter( trip_date__gte=now.date(), info__isnull=True, activity='winter_school').values_list('pk', 'trip_date', 'name') for trip_pk, trip_date, name in future_trips_without_info: if now > dateutils.itinerary_available_at(trip_date): trip_url = reverse('trip_itinerary', args=(trip_pk, )) msg = (f'Please <a href="{trip_url}">submit an itinerary for ' f'{escape(name)}</a> before departing!') messages.warning(request, msg, extra_tags='safe')
def _save_forms(self, user, post_forms): """ Given completed, validated forms, handle saving all. If no CarForm is supplied, a participant's existing car will be removed. Returns the saved Participant object. :param post_forms: Dictionary of <template_name>: <form> """ participant = post_forms['participant_form'].save(commit=False) if participant.pk: # Existing participant! Lock for UPDATE now. # (we don't also lock other objects via JOIN since those can be NULL) models.Participant.objects.filter( pk=participant.pk).select_for_update() e_contact = post_forms['emergency_contact_form'].save() e_info = post_forms['emergency_info_form'].save(commit=False) e_info.emergency_contact = e_contact e_info = post_forms['emergency_info_form'].save() participant.user_id = user.id participant.emergency_info = e_info del_car = False try: car = post_forms['car_form'].save() except KeyError: # No CarForm posted # If Participant existed and has a stored Car, mark it for deletion if participant.car: car = participant.car participant.car = None del_car = True else: participant.car = car if participant == self.request.participant: participant.profile_last_updated = date_utils.local_now() participant.save() if del_car: car.delete() return participant
def complain_if_missing_itineraries(request): """ Create messages if the leader needs to complete trip itineraries. """ if not ws.utils.perms.is_leader(request.user): return now = dateutils.local_now() # Most trips require itineraries, but some (TRS, etc.) do not # All WS trips require itineraries, though future_trips_without_info = request.participant.trips_led.filter( trip_date__gte=now.date(), info__isnull=True, activity='winter_school' ).values_list('pk', 'trip_date', 'name') for trip_pk, trip_date, name in future_trips_without_info: if now > dateutils.itinerary_available_at(trip_date): trip_url = reverse('trip_itinerary', args=(trip_pk,)) msg = ( f'Please <a href="{trip_url}">submit an itinerary for ' f'{escape(name)}</a> before departing!' ) messages.warning(request, msg, extra_tags='safe')
def remind_lapsed_participant_to_renew(participant_id: int): """A task which should only be called by `remind_participants_to_renew'. Like its parent task, is designed to be idempotent (so we only notify participants once per year). """ participant = models.Participant.objects.get(pk=participant_id) # It's technically possible that the participant opted out in between execution if not participant.send_membership_reminder: logger.info("Not reminding participant %s, who since opted out", participant.pk) return now = date_utils.local_now() with transaction.atomic(): (reminder, created) = ( models.MembershipReminder.objects # Make sure we get an exclusive lock to prevent sending a redundant email. .select_for_update() # It's possible this is the first reminder! If so, create it now. # (the unique constraint on participant will ensure other queries can't insert .get_or_create( participant=participant, defaults={'reminder_sent_at': now}, )) if not created: logger.info("Last reminded %s: %s", participant, reminder.reminder_sent_at) # Reminders should be sent ~40 days before the participant's membership has expired. # We should only send one reminder every ~365 days or so. # Pick 300 days as a sanity check that we send one message yearly (+/- some days) if reminder.reminder_sent_at > (now - timedelta(days=300)): raise ValueError( f"Mistakenly trying to notify {participant} to renew") # (Note that this method makes some final assertions before delivering) # If the email succeeds, we'll commit the reminder record (else rollback) renew.send_email_reminding_to_renew(participant)
def _complain_if_missing_itineraries(self): """ Create messages if the leader needs to complete trip itineraries. """ now = date_utils.local_now() # Most trips require itineraries, but some (TRS, etc.) do not # All WS trips require itineraries, though future_trips_without_info = ( self.request.participant.trips_led.filter( trip_date__gte=now.date(), info__isnull=True, program=enums.Program.WINTER_SCHOOL.value, ).order_by('trip_date') # Warn about closest trips first! .values_list('pk', 'trip_date', 'name')) for trip_pk, trip_date, name in future_trips_without_info: if now > date_utils.itinerary_available_at(trip_date): trip_url = reverse('trip_itinerary', args=(trip_pk, )) msg = (f'Please <a href="{trip_url}">submit an itinerary for ' f'{escape(name)}</a> before departing!') self.add_unique_message(messages.WARNING, msg, extra_tags='safe')
def outstanding_items(emails): if not emails: return [] cursor = connections['geardb'].cursor() cursor.execute( """ select g.id, gt.type_name, gt.rental_amount, r.checkedout from rentals r join gear g on g.id = r.gear_id join gear_types gt on gt.id = g.type where returned is null and person_id in (select id from people where email in %s) """, [tuple(emails)]) items = [{ 'id': gear_id, 'name': name, 'cost': cost, 'checkedout': checkedout } for gear_id, name, cost, checkedout in cursor.fetchall()] for item in items: item['overdue'] = local_now() - item['checkedout'] > timedelta( weeks=10) return items
def test_non_ws_trips_ignored(self): """ Participants with signups for non-WS trips are handled. Namely, - a participant's signup for a non-WS trip does not affect the lottery - The cleanup phase of the lottery does not modify any non-WS trips """ outside_iap_trip = factories.TripFactory.create( name='non-WS trip', algorithm='lottery', program=enums.Program.WINTER_NON_IAP.value, trip_date=date(2020, 1, 18), ) office_day = factories.TripFactory.create( name='Office Day', algorithm='fcfs', program=enums.Program.NONE.value, trip_date=date(2020, 1, 19), ) # Sign up the hiker for all three trips! for trip in [self.hike, outside_iap_trip, office_day]: factories.SignUpFactory.create(participant=self.hiker, trip=trip) runner = run.WinterSchoolLotteryRunner() runner() # The participant was placed on their desired WS trip! ws_signup = models.SignUp.objects.get(trip=self.hike, participant=self.hiker) self.assertTrue(ws_signup.on_trip) # Neither of the other two trips had their algorithm or start time adjusted outside_iap_trip.refresh_from_db() self.assertEqual(outside_iap_trip.algorithm, 'lottery') office_day.refresh_from_db() self.assertEqual(office_day.signups_open_at, date_utils.local_now())
def __init__(self, execution_datetime=None): self.execution_datetime = execution_datetime or local_now() self.ranker = WinterSchoolParticipantRanker(self.execution_datetime) super().__init__() self.configure_logger()