import datetime import logging from collections import OrderedDict from datetime import timedelta import pytz from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from psycopg2.extras import DateTimeTZRange logger = logging.getLogger("bornhack.%s" % __name__) def get_daychunks(day): """ Given a DateTimeTZRange day returns a list of "daychunks" which are DateTimeTZRanges of length settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS starting from day.lower. If day.lower is midnight and day.upper is 10 AM and settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=2 then a list of 5 daychunks would be returned. """ chunks = [] daychunk = DateTimeTZRange( day.lower, day.lower + timedelta(hours=settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS), ) i = 0 while daychunk.upper < day.upper: # append this chunk chunks.append(daychunk) # increase our counter i += 1 daychunk = DateTimeTZRange( day.lower + timedelta(hours=settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS * i), day.lower + timedelta(hours=settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS * (i + 1)), ) # cap the final chunk to be equal to the end of the day if daychunk.upper > day.upper: daychunk.upper = day.upper # append the final chunk and return chunks.append(daychunk) return chunks def get_speaker_availability_form_matrix(sessions): """ Create a speaker availability matrix of columns, rows and checkboxes for the HTML form. Returns a "matrix" - a dict of dicts, where the outer dict keys are DateTimeTZRanges representing a full camp "day" (as returned by camp.get_days("camp")), and the value is an OrderedDict of chunks based on settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS with the daychunk DateTimeTZRange as key and a value which is None if we don't want a checkbox, or a dict with "fieldname" (string) and "event_types" (list) and "available" (bool) if we do. For example, with a 2 day camp and settings.SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=12 and 24h EventSessions for both days he matrix dict would have 2 members (one per day), and each would be an OrderedDict with 2 members (one per 12 hour daychunk). """ # start with an empty dict matrix = OrderedDict() if not sessions: return matrix # loop over days in the camp for day in get_tzrange_days(tzranges=[s.when for s in sessions]): # loop over the daychunks in this day for daychunk in get_daychunks(day): event_types = set() for session in sessions: # add the event_type if this session overlaps with daychunk if daychunk & session.when: event_types.add(session.event_type) # make sure we already have an OrderedDict for this day in the matrix if day not in matrix: matrix[day] = OrderedDict() # skip this chunk if we found no sessions if event_types: # build the dict for this daychunk matrix[day][daychunk] = dict() matrix[day][daychunk][ "fieldname" ] = f"availability_{daychunk.lower.strftime('%Y_%m_%d_%H_%M')}_to_{daychunk.upper.strftime('%Y_%m_%d_%H_%M')}" matrix[day][daychunk]["event_types"] = [] # pass a list of dicts instead of the queryset to avoid one million lookups for et in event_types: matrix[day][daychunk]["event_types"].append( { "name": et.name, "icon": et.icon, "color": et.color, } ) matrix[day][daychunk]["initial"] = None else: # no sessions for this chunk, no checkbox needed matrix[day][daychunk] = None # Due to the way we build the matrix it is not trivial to avoid adding days # where none of the chunks need a checkbox. Loop over and remove any days with # 0 checkboxes before returning new_matrix = matrix.copy() for date in matrix.keys(): for chunk in matrix[date].keys(): if matrix[date][chunk]: # we have at least one checkbox on this date, keep it break else: # we looped over all chunks on this day and we need 0 checkboxes del new_matrix[date] return new_matrix def save_speaker_availability(form, obj): """ Called from SpeakerProposalCreateView, SpeakerProposalUpdateView, and CombinedProposalSubmitView to create SpeakerProposalAvailability objects based on the submitted form. Also called from SpeakerUpdateView in backoffice to update SpeakerAvailability. Starts out by deleting all existing availability before saving form. """ if hasattr(obj, "proposal"): # obj is a Speaker AvailabilityModel = apps.get_model("program", "SpeakerAvailability") kwargs = {"speaker": obj} else: # obj is a SpeakerProposal AvailabilityModel = apps.get_model("program", "SpeakerProposalAvailability") kwargs = {"speaker_proposal": obj} # start with a clean slate AvailabilityModel.objects.filter(**kwargs).delete() # all the entered data is in the users local TIME_ZONE, interpret it as such tz = pytz.timezone(settings.TIME_ZONE) # count availability form fields fieldcounter = 0 for field in form.cleaned_data.keys(): if field[:13] == "availability_": fieldcounter += 1 # loop over form fields, and make sure we get them in sorted order formerchunk = None fields = list(form.cleaned_data.keys()) fields.sort() for field in fields: if field[:13] != "availability_": continue # this is a speaker_availability field, first split the # fieldname to get the tzrange for this daychunk elements = field.split("_") # format is "availability_2020_08_28_18_00_to_2020_08_28_21_00" daychunk = DateTimeTZRange( tz.localize( datetime.datetime( int(elements[1]), int(elements[2]), int(elements[3]), int(elements[4]), int(elements[5]), ) ), tz.localize( datetime.datetime( int(elements[7]), int(elements[8]), int(elements[9]), int(elements[10]), int(elements[11]), ) ), ) available = form.cleaned_data[field] if fieldcounter == 1: # we only have one field in the form, no field merging to be done AvailabilityModel.objects.create( when=daychunk, available=available, **kwargs ) continue # we have more than one form field, but we want to save continuous ranges # as one SpeakerAvailability object, so we might need to merge this field with # the next one, so we can't save it yet if not formerchunk: # this is the first loop or we changed availability, # remember the current chunk for the next loop formerchunk = daychunk formeravailable = available continue # this is not the first chunk if formeravailable == available and formerchunk.upper == daychunk.lower: # we have the same value for "available" and adjacent times, # merge with the former chunk formerchunk = formerchunk + daychunk else: # "available" changed or daychunk is not adjacent to formerchunk AvailabilityModel.objects.create( when=formerchunk, available=formeravailable, **kwargs, ) # and remember the current chunk for next iteration formerchunk = daychunk formeravailable = available # save the last chunk? if formerchunk: AvailabilityModel.objects.create( when=formerchunk, available=available, **kwargs ) def add_existing_availability_to_matrix(matrix, speaker_proposal): """ Loops over the matrix and adds an "intial" member to the daychunk dicts with the availability info for the speaker_proposal. This is used to populate initial form field values and to set background colours in the html table. speaker_proposal can be either a SpeakerProposal object or a Speaker object. """ # loop over dates in the matrix for date in matrix.keys(): # loop over daychunks and check if we need a checkbox for daychunk in matrix[date].keys(): if not matrix[date][daychunk]: # we have no event_session here, carry on continue # do we have any availability info for this speakerproposal? try: availability = speaker_proposal.availabilities.get( when__contains=daychunk ) matrix[date][daychunk]["initial"] = availability.available except ObjectDoesNotExist: matrix[date][daychunk]["initial"] = None def get_slots(period, duration, bounds="()"): """ Cuts a DateTimeTZRange into slices of duration minutes length and returns a list of them """ slots = [] if period.upper - period.lower < timedelta(minutes=duration): # this period is shorter than the duration, no slots return slots # create the first slot slot = DateTimeTZRange( period.lower, period.lower + timedelta(minutes=duration), bounds=bounds ) # loop until we pass the end while slot.upper < period.upper: slots.append(slot) # the next slot starts when this one ends slot = DateTimeTZRange( slot.upper, slot.upper + timedelta(minutes=duration), bounds=bounds ) # append the final slot to the list unless it continues past the end if not slot.upper > period.upper: slots.append(slot) return slots def get_tzrange_days(tzranges): """Loop over tzranges and build a list of datetimetzranges representing all unique dates (in the local timezone).""" days = set() # loop over input ranges for tzrange in tzranges: # convert this range to local timezone localrange = DateTimeTZRange( timezone.localtime(tzrange.lower), timezone.localtime(tzrange.upper) ) # find the first date in this range day = DateTimeTZRange( localrange.lower.replace(hour=0), localrange.lower.replace(hour=0) + timedelta(days=1), ) # add this day to the set days.add(day) # does this range spans multiple dates? if localrange.lower.date() != localrange.upper.date(): # this range spans multiple dates, loop over them while day.lower.date() <= localrange.upper.date(): day = DateTimeTZRange(day.upper, day.upper + timedelta(days=1)) days.add(day) days = list(days) days.sort() return days