diff --git a/api/availability/serializers.py b/api/availability/serializers.py index aae7561..c57dada 100644 --- a/api/availability/serializers.py +++ b/api/availability/serializers.py @@ -17,9 +17,7 @@ class DisplayNameCheckSerializer(EventCodeSerializer, DisplayNameSerializer): class AvailabilitySerializer(serializers.Serializer): availability = serializers.ListField( - child=serializers.ListField( - child=serializers.BooleanField(), required=True, min_length=4 - ), + child=serializers.DateTimeField(required=True), required=True, min_length=1, ) diff --git a/api/availability/utils.py b/api/availability/utils.py index c7332a3..1e08ae6 100644 --- a/api/availability/utils.py +++ b/api/availability/utils.py @@ -10,32 +10,18 @@ ) -class EventGridDimensionError(Exception): - pass - - -def get_event_grid(event): +def get_timeslots(event): timeslots = [] - num_days = 0 if event.date_type == UserEvent.EventType.SPECIFIC: timeslots = EventDateTimeslot.objects.filter(user_event=event).order_by( "timeslot" ) - num_days = ( - timeslots.last().timeslot.date() - timeslots.first().timeslot.date() - ).days + 1 else: timeslots = EventWeekdayTimeslot.objects.filter(user_event=event).order_by( "weekday", "timeslot" ) - num_days = timeslots.last().weekday - timeslots.first().weekday + 1 - num_slots = timeslots.count() / num_days - if timeslots.count() % num_days != 0: - raise EventGridDimensionError( - "Event timeslots are not evenly distributed across days." - ) - return timeslots, int(num_days), int(num_slots) + return timeslots def check_name_available(event, user, display_name): diff --git a/api/availability/views.py b/api/availability/views.py index 4de35dd..497bff8 100644 --- a/api/availability/views.py +++ b/api/availability/views.py @@ -11,13 +11,9 @@ EventAvailabilitySerializer, EventCodeSerializer, ) -from api.availability.utils import ( - EventGridDimensionError, - check_name_available, - get_event_grid, - get_weekday_date, -) +from api.availability.utils import check_name_available, get_timeslots, get_weekday_date from api.models import ( + AvailabilityStatus, EventDateAvailability, EventParticipant, EventWeekdayAvailability, @@ -42,7 +38,7 @@ class AvailabilityAddThrottle(AnonRateThrottle): scope = "availability_add" -class AvailabilityInputInvalidError(Exception): +class InvalidTimeslotError(Exception): pass @@ -85,17 +81,7 @@ def add_availability(request): status=400, ) - timeslots, num_days, num_times = get_event_grid(user_event) - - if len(availability) != num_days: - raise AvailabilityInputInvalidError( - f"Invalid availability days. Expected {num_days}, got {len(availability)}." - ) - for day in availability: - if len(day) != num_times: - raise AvailabilityInputInvalidError( - f"Invalid availability timeslots. Expected {num_times}, got {len(day)}." - ) + timeslots = get_timeslots(user_event) participant, new = EventParticipant.objects.get_or_create( user_event=user_event, @@ -107,6 +93,7 @@ def add_availability(request): participant.display_name = display_name participant.save() + # Remove existing availability if user_event.date_type == UserEvent.EventType.SPECIFIC: EventDateAvailability.objects.filter( event_participant=participant @@ -116,50 +103,54 @@ def add_availability(request): event_participant=participant ).delete() - # Flatten the availability array to match the timeslots array format - flattened_availability = [ - timeslot for day in availability for timeslot in day - ] - new_availabilities = [] + # Add new availability if user_event.date_type == UserEvent.EventType.SPECIFIC: - for i, timeslot in enumerate(timeslots): + timeslot_dict = {t.timeslot: t for t in timeslots} + new_availabilities = [] + for timeslot in availability: + if timeslot not in timeslot_dict: + raise InvalidTimeslotError() new_availabilities.append( EventDateAvailability( event_participant=participant, - event_date_timeslot=timeslot, - is_available=flattened_availability[i], + event_date_timeslot=timeslot_dict[timeslot], + status=AvailabilityStatus.AVAILABLE, ) ) EventDateAvailability.objects.bulk_create(new_availabilities) elif user_event.date_type == UserEvent.EventType.GENERIC: - for i, timeslot in enumerate(timeslots): + timeslot_dict = { + get_weekday_date(t.weekday, t.timeslot): t for t in timeslots + } + new_availabilities = [] + for timeslot in availability: + if timeslot not in timeslot_dict: + raise InvalidTimeslotError() new_availabilities.append( EventWeekdayAvailability( event_participant=participant, - event_weekday_timeslot=timeslot, - is_available=flattened_availability[i], + event_weekday_timeslot=timeslot_dict[timeslot], + status=AvailabilityStatus.AVAILABLE, ) ) EventWeekdayAvailability.objects.bulk_create(new_availabilities) - except AvailabilityInputInvalidError as e: - logger.warning(str(e) + " Event code: " + event_code) + except UserEvent.DoesNotExist: + return Response( + {"error": {"event_code": ["Event not found."]}}, + status=404, + ) + except InvalidTimeslotError: return Response( { "error": { - "availability": [str(e)], + "availability": [ + "One or more timeslots are invalid, check if the event has been updated." + ] } }, status=400, ) - except EventGridDimensionError as e: - logger.critical(e) - return GENERIC_ERR_RESPONSE - except UserEvent.DoesNotExist: - return Response( - {"error": {"event_code": ["Event not found."]}}, - status=404, - ) except DatabaseError as e: logger.db_error(e) return GENERIC_ERR_RESPONSE @@ -250,18 +241,14 @@ def get_self_availability(request): if event.date_type == UserEvent.EventType.SPECIFIC: availabilities = ( - EventDateAvailability.objects.filter( - event_participant=participant, is_available=True - ) + EventDateAvailability.objects.filter(event_participant=participant) .select_related("event_date_timeslot") .order_by("event_date_timeslot__timeslot") ) data = [a.event_date_timeslot.timeslot for a in availabilities] else: availabilities = ( - EventWeekdayAvailability.objects.filter( - event_participant=participant, is_available=True - ) + EventWeekdayAvailability.objects.filter(event_participant=participant) .select_related("event_weekday_timeslot") .order_by( "event_weekday_timeslot__weekday", @@ -327,7 +314,7 @@ def get_all_availability(request): # Prep the dictionary with empty arrays for the return value availability_dict = {} - timeslots, _, _ = get_event_grid(event) + timeslots = get_timeslots(event) if event.date_type == UserEvent.EventType.SPECIFIC: for slot in timeslots: availability_dict[slot.timeslot.isoformat()] = [] @@ -369,8 +356,7 @@ def get_all_availability(request): f"Timeslot {timeslot} not found in availability dict for event {event_code}" ) continue - if t.is_available: - availability_dict[timeslot].append(t.event_participant.display_name) + availability_dict[timeslot].append(t.event_participant.display_name) return Response( { @@ -403,8 +389,7 @@ def get_all_availability(request): f"Timeslot {timeslot} not found in availability dict for event {event_code}" ) continue - if t.is_available: - availability_dict[timeslot].append(t.event_participant.display_name) + availability_dict[timeslot].append(t.event_participant.display_name) return Response( { diff --git a/api/dashboard/views.py b/api/dashboard/views.py index 57d17ff..67ae44e 100644 --- a/api/dashboard/views.py +++ b/api/dashboard/views.py @@ -1,12 +1,11 @@ import logging +from zoneinfo import ZoneInfo from django.db import DatabaseError from django.db.models import Prefetch, Q from rest_framework import serializers from rest_framework.response import Response -from api.event.serializers import EventDetailSerializer -from api.event.utils import format_event_info from api.models import ( EventDateTimeslot, EventParticipant, @@ -14,12 +13,26 @@ UserEvent, ) from api.settings import GENERIC_ERR_RESPONSE -from api.utils import api_endpoint, check_auth, validate_output +from api.utils import ( + TimeZoneField, + api_endpoint, + check_auth, + format_event_info, + validate_output, +) logger = logging.getLogger("api") -class DashboardEventSerializer(EventDetailSerializer): +class DashboardEventSerializer(serializers.Serializer): + title = serializers.CharField(required=True, max_length=255) + event_type = serializers.ChoiceField(required=True, choices=["Date", "Week"]) + duration = serializers.IntegerField(required=False) + start_date = serializers.DateField(required=True) + end_date = serializers.DateField(required=True) + start_time = serializers.TimeField(required=True) + end_time = serializers.TimeField(required=True) + time_zone = TimeZoneField(required=True) event_code = serializers.CharField(required=True, max_length=255) @@ -99,12 +112,9 @@ def get_dashboard(request): my_events = [] for event in created_events: my_events.append(format_event_info(event)) - my_events[-1]["event_code"] = event.url_code.url_code their_events = [] for event in participations: their_events.append(format_event_info(event.user_event)) - if event.user_event.url_code is not None: - their_events[-1]["event_code"] = event.user_event.url_code.url_code except DatabaseError as e: logger.db_error(e) diff --git a/api/docs/utils.py b/api/docs/utils.py index de11c01..fc69ca6 100644 --- a/api/docs/utils.py +++ b/api/docs/utils.py @@ -34,6 +34,8 @@ def get_readable_field_name(field_name): return "boolean" case "DateField": return "date" + case "TimeField": + return "time" case "DateTimeField": return "datetime" case "EmailField": diff --git a/api/event/serializers.py b/api/event/serializers.py index df22727..ab6a9b0 100644 --- a/api/event/serializers.py +++ b/api/event/serializers.py @@ -15,8 +15,9 @@ class EventCodeSerializer(serializers.Serializer): class EventInfoSerializer(serializers.Serializer): title = serializers.CharField(required=True, max_length=50) duration = serializers.IntegerField(required=False) - start_hour = serializers.IntegerField(required=True, min_value=0, max_value=24) - end_hour = serializers.IntegerField(required=True, min_value=0, max_value=24) + timeslots = serializers.ListField( + child=serializers.DateTimeField(), required=True, allow_empty=False + ) time_zone = TimeZoneField(required=True) def validate(self, attrs): @@ -32,35 +33,25 @@ def validate(self, attrs): return super().validate(attrs) -class DateEventInfoSerializer(EventInfoSerializer): - start_date = serializers.DateField(required=True) - end_date = serializers.DateField(required=True) - - -class WeekEventInfoSerializer(EventInfoSerializer): - start_weekday = serializers.IntegerField(required=True, min_value=0, max_value=6) - end_weekday = serializers.IntegerField(required=True, min_value=0, max_value=6) - - -class DateEventCreateSerializer(DateEventInfoSerializer, CustomCodeSerializer): +class DateEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): pass -class WeekEventCreateSerializer(WeekEventInfoSerializer, CustomCodeSerializer): +class WeekEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): pass -class DateEventEditSerializer(DateEventInfoSerializer, EventCodeSerializer): +class DateEventEditSerializer(EventInfoSerializer, EventCodeSerializer): pass -class WeekEventEditSerializer(WeekEventInfoSerializer, EventCodeSerializer): +class WeekEventEditSerializer(EventInfoSerializer, EventCodeSerializer): pass class EventDetailSerializer(EventInfoSerializer): event_type = serializers.ChoiceField(required=True, choices=["Date", "Week"]) - start_date = serializers.DateField(required=False) - end_date = serializers.DateField(required=False) - start_weekday = serializers.IntegerField(required=False, min_value=0, max_value=6) - end_weekday = serializers.IntegerField(required=False, min_value=0, max_value=6) + start_date = serializers.DateField(required=True) + end_date = serializers.DateField(required=True) + start_time = serializers.TimeField(required=True) + end_time = serializers.TimeField(required=True) diff --git a/api/event/utils.py b/api/event/utils.py index 65f69fe..dffad35 100644 --- a/api/event/utils.py +++ b/api/event/utils.py @@ -2,6 +2,7 @@ import re import string from datetime import datetime, time, timedelta +from zoneinfo import ZoneInfo from django.db.models import Prefetch @@ -64,68 +65,68 @@ def generate_random_string(): raise Exception("Failed to generate a unique URL code.") -def daterange(start_date, end_date): - current = start_date - while current <= end_date: - yield current - current += timedelta(days=1) - - -def timerange(start_hour, end_hour): - start_time = time(start_hour) - end_time = time(end_hour) if end_hour != 24 else time(23, 59) - # Adding the date is a workaround since you can't use timedelta with just times - date = datetime.today() - current = datetime.combine(date, start_time) - end_dt = datetime.combine(date, end_time) - while current < end_dt: - yield current.time() - current += timedelta(minutes=15) +def check_timeslot_times(timeslots): + for timeslot in timeslots: + if ( + timeslot.minute % 15 != 0 + or timeslot.second != 0 + or timeslot.microsecond != 0 + ): + return False + return True -def validate_date_input( - start_date, end_date, start_hour, end_hour, earliest_date, editing=False +def validate_date_timeslots( + timeslots: list[datetime], + earliest_date_local: datetime.date, + user_time_zone: str, + editing: bool = False, ): """ - Validates date and time ranges for an event. + Validates timeslots for a date event. The editing parameter determines the error message given if start_date is too early. """ + if not timeslots: + return {"timeslots": ["At least one timeslot is required."]} + + start_date = min(ts.date() for ts in timeslots) + end_date = max(ts.date() for ts in timeslots) + + start_date_local = min(timeslots).astimezone(ZoneInfo(user_time_zone)).date() + errors = {} - if start_date < earliest_date: + + def add_error(message): + if "timeslots" not in errors: + errors["timeslots"] = [] + errors["timeslots"].append(message) + + # The earliest date allowed is "today" in the user's local time zone, which is why + # this uses a time zone conversion instead of UTC + if start_date_local < earliest_date_local: if editing: - errors["start_date"] = [ - "Start date cannot be set earlier than today, or moved earlier if already before today." - ] + add_error( + "Event cannot start earlier than today, or be moved earlier if already before today." + ) else: - errors["start_date"] = ["Start date must be today or in the future."] - if start_date > end_date: - errors["end_date"] = ["End date must be on or after start date."] - if start_hour >= end_hour: - errors["end_hour"] = ["End hour must be after start hour."] + add_error("Event must start today or in the future.") if (end_date - start_date).days > MAX_EVENT_DAYS: - errors["end_date"] = [ - f"End date must be within {MAX_EVENT_DAYS} days of start date." - ] - - return errors + add_error(f"Max event length is {MAX_EVENT_DAYS} days.") + if not check_timeslot_times(timeslots): + add_error("Timeslots must be on 15-minute intervals.") -def validate_weekday_input(start_weekday, end_weekday, start_hour, end_hour): - errors = {} - if start_weekday > end_weekday: - errors["end_weekday"] = ["End weekday must be on or after start weekday."] - if start_hour >= end_hour: - errors["end_hour"] = ["End hour must be after start hour."] return errors -def get_event_type(date_type): - match date_type: - case UserEvent.EventType.SPECIFIC: - return "Date" - case UserEvent.EventType.GENERIC: - return "Week" +def validate_weekday_timeslots(timeslots): + if not timeslots: + return {"timeslots": ["At least one timeslot is required."]} + + if not check_timeslot_times(timeslots): + return {"timeslots": ["Timeslots must be on 15-minute intervals."]} + return {} def event_lookup(event_code: str): @@ -145,61 +146,8 @@ def event_lookup(event_code: str): ).get(url_code=event_code) -def format_event_info( - event: UserEvent, -): +def js_weekday(weekday: int) -> int: """ - Formats event info into a dictionary to satisfy the output serializers on certain - endpoints. - - For query efficiency, the event's timeslots should be prefetched. + Converts a Python weekday (0 = Monday) to a JavaScript weekday (0 = Sunday). """ - start_date = None - end_date = None - start_weekday = None - end_weekday = None - event_type = "" - start_hour = -1 - end_hour = -1 - - event_type = get_event_type(event.date_type) - first_timeslot = None - last_timeslot = None - match event_type: - case "Date": - all_timeslots = list(event.date_timeslots.all()) - first_timeslot = all_timeslots[0] - last_timeslot = all_timeslots[-1] - start_date = first_timeslot.timeslot.date() - end_date = last_timeslot.timeslot.date() - case "Week": - all_timeslots = list(event.weekday_timeslots.all()) - first_timeslot = all_timeslots[0] - last_timeslot = all_timeslots[-1] - start_weekday = first_timeslot.weekday - end_weekday = last_timeslot.weekday - - start_hour = first_timeslot.timeslot.hour - # The last timeslot will always be XX:45, so just add 1 to the hour - end_hour = last_timeslot.timeslot.hour + 1 - - data = { - "title": event.title, - "event_type": event_type, - "start_hour": start_hour, - "end_hour": end_hour, - "time_zone": event.time_zone, - } - # Add the extra fields only if not null, otherwise the serializer complains - if event.duration: - data["duration"] = event.duration - if start_date: - data["start_date"] = start_date - if end_date: - data["end_date"] = end_date - if start_weekday is not None: - data["start_weekday"] = start_weekday - if end_weekday is not None: - data["end_weekday"] = end_weekday - - return data + return (weekday + 1) % 7 diff --git a/api/event/views.py b/api/event/views.py index 8dfb16a..cb87120 100644 --- a/api/event/views.py +++ b/api/event/views.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from rest_framework.throttling import AnonRateThrottle +from api.availability.utils import get_weekday_date from api.event.serializers import ( CustomCodeSerializer, DateEventCreateSerializer, @@ -18,27 +19,19 @@ ) from api.event.utils import ( check_custom_code, - daterange, event_lookup, - format_event_info, generate_code, - timerange, - validate_date_input, - validate_weekday_input, -) -from api.models import ( - EventDateAvailability, - EventDateTimeslot, - EventWeekdayAvailability, - EventWeekdayTimeslot, - UrlCode, - UserEvent, + js_weekday, + validate_date_timeslots, + validate_weekday_timeslots, ) -from api.settings import GENERIC_ERR_RESPONSE, MAX_EVENT_DAYS +from api.models import EventDateTimeslot, EventWeekdayTimeslot, UrlCode, UserEvent +from api.settings import GENERIC_ERR_RESPONSE from api.utils import ( MessageOutputSerializer, api_endpoint, check_auth, + format_event_info, rate_limit, require_auth, validate_json_input, @@ -52,6 +45,11 @@ {"error": {"general": ["Event not found."]}}, status=404 ) +INVALID_TIMESLOT_TIME_ERROR = Response( + {"error": {"timeslots": ["Timeslots must be on 15-minute intervals."]}}, + status=400, +) + class EventCreateThrottle(AnonRateThrottle): scope = "event_creation" @@ -76,15 +74,12 @@ def create_date_event(request): user = request.user title = request.validated_data.get("title") duration = request.validated_data.get("duration") - start_date = request.validated_data.get("start_date") - end_date = request.validated_data.get("end_date") - start_hour = request.validated_data.get("start_hour") - end_hour = request.validated_data.get("end_hour") + timeslots = request.validated_data.get("timeslots") time_zone = request.validated_data.get("time_zone") custom_code = request.validated_data.get("custom_code") - user_date = datetime.now(ZoneInfo(time_zone)).date() - errors = validate_date_input(start_date, end_date, start_hour, end_hour, user_date) + user_date_local = datetime.now(ZoneInfo(time_zone)).date() + errors = validate_date_timeslots(timeslots, user_date_local, time_zone) if errors.keys(): return Response({"error": errors}, status=400) @@ -115,17 +110,13 @@ def create_date_event(request): UrlCode.objects.update_or_create( url_code=url_code, defaults={"user_event": new_event} ) - # Create timeslots for the date and time range - timeslots = [] - for date in daterange(start_date, end_date): - for time in timerange(start_hour, end_hour): - timeslots.append( - EventDateTimeslot( - user_event=new_event, - timeslot=datetime.combine(date, time), - ) - ) - EventDateTimeslot.objects.bulk_create(timeslots) + # Create timeslot objects + EventDateTimeslot.objects.bulk_create( + [ + EventDateTimeslot(user_event=new_event, timeslot=ts) + for ts in set(timeslots) + ] + ) except DatabaseError as e: logger.db_error(e) return GENERIC_ERR_RESPONSE @@ -156,15 +147,12 @@ def create_week_event(request): user = request.user title = request.validated_data.get("title") duration = request.validated_data.get("duration") - start_weekday = request.validated_data.get("start_weekday") - end_weekday = request.validated_data.get("end_weekday") - start_hour = request.validated_data.get("start_hour") - end_hour = request.validated_data.get("end_hour") + timeslots = request.validated_data.get("timeslots") time_zone = request.validated_data.get("time_zone") custom_code = request.validated_data.get("custom_code") # Some extra input validation - errors = validate_weekday_input(start_weekday, end_weekday, start_hour, end_hour) + errors = validate_weekday_timeslots(timeslots) if errors.keys(): return Response({"error": errors}, status=400) @@ -195,18 +183,20 @@ def create_week_event(request): UrlCode.objects.update_or_create( url_code=url_code, defaults={"user_event": new_event} ) - # Create timeslots for the date and time range - timeslots = [] - for weekday in range(start_weekday, end_weekday + 1): - for time in timerange(start_hour, end_hour): - timeslots.append( - EventWeekdayTimeslot( - user_event=new_event, - weekday=weekday, - timeslot=time, - ) + # Create timeslot objects + deduplicated_timeslots = set( + (js_weekday(ts.weekday()), ts.time()) for ts in timeslots + ) + EventWeekdayTimeslot.objects.bulk_create( + [ + EventWeekdayTimeslot( + user_event=new_event, + weekday=weekday, + timeslot=time, ) - EventWeekdayTimeslot.objects.bulk_create(timeslots) + for (weekday, time) in deduplicated_timeslots + ] + ) except DatabaseError as e: logger.db_error(e) return GENERIC_ERR_RESPONSE @@ -250,16 +240,13 @@ def edit_date_event(request): event_code = request.validated_data.get("event_code") title = request.validated_data.get("title") duration = request.validated_data.get("duration") - start_date = request.validated_data.get("start_date") - end_date = request.validated_data.get("end_date") - start_hour = request.validated_data.get("start_hour") - end_hour = request.validated_data.get("end_hour") + timeslots = request.validated_data.get("timeslots") time_zone = request.validated_data.get("time_zone") if not user: return EVENT_NOT_FOUND_ERROR - user_date = datetime.now(ZoneInfo(time_zone)).date() + user_date_local = datetime.now(ZoneInfo(time_zone)).date() try: # Do everything inside a transaction to ensure atomicity with transaction.atomic(): @@ -270,20 +257,31 @@ def edit_date_event(request): date_type=UserEvent.EventType.SPECIFIC, ) # Get the earliest timeslot - existing_start_date = ( + earliest_timeslot = ( EventDateTimeslot.objects.filter(user_event=event) .order_by("timeslot") .first() - .timeslot.date() ) + existing_start_date: datetime = None + if earliest_timeslot: + existing_start_date = earliest_timeslot.timeslot + else: + logger.critical( + f"Event {event.id} has no timeslots when editing date event." + ) + return GENERIC_ERR_RESPONSE + # Convert it to local date for comparison + existing_start_date = existing_start_date.astimezone( + ZoneInfo(event.time_zone) + ).date() # If the start date is after today, it cannot be moved to a date earlier than today. # If the start date is before today, it cannot be moved earlier at all. - earliest_date = user_date - if existing_start_date < user_date: - earliest_date = existing_start_date - errors = validate_date_input( - start_date, end_date, start_hour, end_hour, earliest_date, True + earliest_date_local = user_date_local + if existing_start_date < user_date_local: + earliest_date_local = existing_start_date + errors = validate_date_timeslots( + timeslots, earliest_date_local, time_zone, True ) if errors.keys(): return Response({"error": errors}, status=400) @@ -300,11 +298,7 @@ def edit_date_event(request): "timeslot", flat=True ) ) - edited_timeslots = set( - datetime.combine(date, time) - for date in daterange(start_date, end_date) - for time in timerange(start_hour, end_hour) - ) + edited_timeslots = set(timeslots) to_delete = existing_timeslots - edited_timeslots to_add = [ EventDateTimeslot(user_event=event, timeslot=ts) @@ -315,19 +309,6 @@ def edit_date_event(request): ).delete() EventDateTimeslot.objects.bulk_create(to_add) - # Add in "unavailable" entries for the new timeslots for current participants - to_add_availabilities = [] - for participant in event.participants.all(): - for ts in to_add: - to_add_availabilities.append( - EventDateAvailability( - event_participant=participant, - event_date_timeslot=ts, - is_available=False, - ) - ) - EventDateAvailability.objects.bulk_create(to_add_availabilities) - except UserEvent.DoesNotExist: return EVENT_NOT_FOUND_ERROR except DatabaseError as e: @@ -355,10 +336,7 @@ def edit_week_event(request): event_code = request.validated_data.get("event_code") title = request.validated_data.get("title") duration = request.validated_data.get("duration") - start_weekday = request.validated_data.get("start_weekday") - end_weekday = request.validated_data.get("end_weekday") - start_hour = request.validated_data.get("start_hour") - end_hour = request.validated_data.get("end_hour") + timeslots = request.validated_data.get("timeslots") time_zone = request.validated_data.get("time_zone") if not user: @@ -374,9 +352,7 @@ def edit_week_event(request): date_type=UserEvent.EventType.GENERIC, ) - errors = validate_weekday_input( - start_weekday, end_weekday, start_hour, end_hour - ) + errors = validate_weekday_timeslots(timeslots) if errors.keys(): return Response({"error": errors}, status=400) @@ -393,9 +369,7 @@ def edit_week_event(request): ) ) edited_timeslots = set( - (weekday, time) - for weekday in range(start_weekday, end_weekday + 1) - for time in timerange(start_hour, end_hour) + [(js_weekday(ts.weekday()), ts.time()) for ts in timeslots] ) to_delete = existing_timeslots - edited_timeslots to_add = [ @@ -412,19 +386,6 @@ def edit_week_event(request): EventWeekdayTimeslot.objects.bulk_create(to_add) - # Add in "unavailable" entries for the new timeslots for current participants - to_add_availabilities = [] - for participant in event.participants.all(): - for ts in to_add: - to_add_availabilities.append( - EventWeekdayAvailability( - event_participant=participant, - event_weekday_timeslot=ts, - is_available=False, - ) - ) - EventWeekdayAvailability.objects.bulk_create(to_add_availabilities) - except UserEvent.DoesNotExist: return EVENT_NOT_FOUND_ERROR except DatabaseError as e: @@ -443,18 +404,27 @@ def edit_week_event(request): @validate_output(EventDetailSerializer) def get_event_details(request): """ - Gets details about an event like title, duration, and date/time range. + Gets details about an event like title, duration, and timeslots. This is useful for both displaying an event, and preparing for event editing. - - start_date, end_date, start_weekday, and end_weekday will only have values for their - corresponding event types. """ event_code = request.validated_data.get("event_code") try: event = event_lookup(event_code) data = format_event_info(event) + match event.date_type: + case UserEvent.EventType.SPECIFIC: + timeslots = event.date_timeslots.all() + data["timeslots"] = [ts.timeslot for ts in timeslots] + case UserEvent.EventType.GENERIC: + timeslots = event.weekday_timeslots.all() + data["timeslots"] = [ + get_weekday_date(ts.weekday, ts.timeslot) for ts in timeslots + ] + + if event.duration: + data["duration"] = event.duration except UserEvent.DoesNotExist: return EVENT_NOT_FOUND_ERROR except DatabaseError as e: diff --git a/api/migrations/0020_remove_eventdateavailability_is_available_and_more.py b/api/migrations/0020_remove_eventdateavailability_is_available_and_more.py new file mode 100644 index 0000000..f320c2c --- /dev/null +++ b/api/migrations/0020_remove_eventdateavailability_is_available_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2 on 2025-12-27 18:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0019_alter_urlcode_user_event"), + ] + + operations = [ + migrations.RunPython( + code=lambda apps, schema_editor: ( + apps.get_model("api", "EventDateAvailability") + .objects.filter(is_available=False) + .delete(), + apps.get_model("api", "EventWeekdayAvailability") + .objects.filter(is_available=False) + .delete(), + ), + reverse_code=None, + ), + migrations.RemoveField( + model_name="eventdateavailability", + name="is_available", + ), + migrations.RemoveField( + model_name="eventweekdayavailability", + name="is_available", + ), + migrations.AddField( + model_name="eventdateavailability", + name="status", + field=models.CharField( + choices=[("AVAILABLE", "Available")], default="AVAILABLE", max_length=20 + ), + preserve_default=False, + ), + migrations.AddField( + model_name="eventweekdayavailability", + name="status", + field=models.CharField( + choices=[("AVAILABLE", "Available")], default="AVAILABLE", max_length=20 + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="urlcode", + name="user_event", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="url_code", + to="api.userevent", + ), + ), + ] diff --git a/api/models.py b/api/models.py index 8178307..3013dbc 100644 --- a/api/models.py +++ b/api/models.py @@ -12,6 +12,11 @@ def db_type(self, connection): return super().db_type(connection) +# Enum for availability status +class AvailabilityStatus(models.TextChoices): + AVAILABLE = "AVAILABLE", "Available" + + class UserAccount(models.Model): user_account_id = models.AutoField(primary_key=True) email = models.EmailField(unique=True, null=True) @@ -162,7 +167,10 @@ class EventWeekdayAvailability(models.Model): on_delete=models.CASCADE, related_name="participant_availabilities", ) - is_available = models.BooleanField() + status = models.CharField( + max_length=20, + choices=AvailabilityStatus.choices, + ) class Meta: constraints = [ @@ -186,7 +194,10 @@ class EventDateAvailability(models.Model): on_delete=models.CASCADE, related_name="participant_availabilities", ) - is_available = models.BooleanField() + status = models.CharField( + max_length=20, + choices=AvailabilityStatus.choices, + ) class Meta: constraints = [ diff --git a/api/utils.py b/api/utils.py index 94c7a0e..c303905 100644 --- a/api/utils.py +++ b/api/utils.py @@ -12,7 +12,8 @@ from rest_framework.response import Response from rest_framework.throttling import AnonRateThrottle -from api.models import UserAccount, UserSession +from api.availability.utils import get_weekday_date +from api.models import UserAccount, UserEvent, UserSession from api.settings import ( ACCOUNT_COOKIE_NAME, GENERIC_ERR_RESPONSE, @@ -650,3 +651,109 @@ def to_internal_value(self, data): except ZoneInfoNotFoundError: raise serializers.ValidationError("Invalid time zone.") return value + + +def get_event_type(date_type): + match date_type: + case UserEvent.EventType.SPECIFIC: + return "Date" + case UserEvent.EventType.GENERIC: + return "Week" + + +class EventBounds: + def __init__( + self, + start_date: datetime.date, + end_date: datetime.date, + start_time: datetime.time, + end_time: datetime.time, + ): + self.start_date = start_date + self.end_date = end_date + self.start_time = start_time + self.end_time = end_time + + +def get_event_bounds(event: UserEvent) -> EventBounds: + """ + Finds the start and end date/time bounds for an event. + + For query efficiency, the event's timeslots should be prefetched. + """ + all_timeslots: list[datetime] = [] + event_time_zone = ZoneInfo(event.time_zone) + + event_type = get_event_type(event.date_type) + # Sort the timeslots by the EVENT'S time zone to get the min/max of the creator + match event.date_type: + case UserEvent.EventType.SPECIFIC: + all_timeslots = [ + ts.timeslot.astimezone(event_time_zone) + for ts in event.date_timeslots.all() + ] + case UserEvent.EventType.GENERIC: + all_timeslots = [ + get_weekday_date(ts.weekday, ts.timeslot).astimezone(event_time_zone) + for ts in event.weekday_timeslots.all() + ] + + if not all_timeslots: + logger.critical( + f"Event {event.id} has no timeslots when formatting for dashboard." + ) + raise ValueError("Event has no timeslots.") + + # Earliest weekday is also sorted by date + start_date = min(ts.date() for ts in all_timeslots) + end_date = max(ts.date() for ts in all_timeslots) + start_time = min(ts.time() for ts in all_timeslots) + end_time = max(ts.time() for ts in all_timeslots) + # End time should be 15 minutes after the last timeslot + end_time = (datetime.combine(datetime.min, end_time) + timedelta(minutes=15)).time() + + # Then convert back to UTC for the frontend to use + # datetime.combine has no time zone info, so we include the event's time zone to + # make sure it doesn't convert twice + start_datetime = ( + datetime.combine(start_date, start_time) + .replace(tzinfo=event_time_zone) + .astimezone(ZoneInfo("UTC")) + ) + end_datetime = ( + datetime.combine(end_date, end_time) + .replace(tzinfo=event_time_zone) + .astimezone(ZoneInfo("UTC")) + ) + + return EventBounds( + start_date=start_datetime.date(), + end_date=end_datetime.date(), + start_time=start_datetime.time(), + end_time=end_datetime.time(), + ) + + +def format_event_info(event: UserEvent) -> dict: + """ + Formats event information. + + For query efficiency, the event's timeslots should be prefetched. + """ + bounds = get_event_bounds(event) + + data = { + "title": event.title, + "event_type": get_event_type(event.date_type), + "start_date": bounds.start_date, + "end_date": bounds.end_date, + "start_time": bounds.start_time, + "end_time": bounds.end_time, + "time_zone": event.time_zone, + "event_code": event.url_code.url_code if event.url_code else None, + } + + if event.duration is not None: + data["duration"] = event.duration + + return data