From 201764a5518027e869dad0ae158e608546ee7868 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 12:52:12 -0400 Subject: [PATCH 01/18] Update database model --- ...ate_type_eventcalendartimeslot_and_more.py | 52 +++++++++++++++++++ backend/api/models.py | 52 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 backend/api/migrations/0034_alter_userevent_date_type_eventcalendartimeslot_and_more.py diff --git a/backend/api/migrations/0034_alter_userevent_date_type_eventcalendartimeslot_and_more.py b/backend/api/migrations/0034_alter_userevent_date_type_eventcalendartimeslot_and_more.py new file mode 100644 index 00000000..63ca4c05 --- /dev/null +++ b/backend/api/migrations/0034_alter_userevent_date_type_eventcalendartimeslot_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2 on 2026-07-17 16:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0033_urlcode_unique_case_insensitive_url_code'), + ] + + operations = [ + migrations.AlterField( + model_name='userevent', + name='date_type', + field=models.CharField(choices=[('GENERIC', 'Generic'), ('SPECIFIC', 'Specific'), ('CALENDAR', 'Calendar')], max_length=20), + ), + migrations.CreateModel( + name='EventCalendarTimeslot', + fields=[ + ('event_calendar_timeslot_id', models.AutoField(primary_key=True, serialize=False)), + ('date', models.DateField()), + ('user_event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendar_timeslots', to='api.userevent')), + ], + ), + migrations.CreateModel( + name='EventCalendarAvailability', + fields=[ + ('event_calendar_availability_id', models.AutoField(primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('AVAILABLE', 'Available')], max_length=20)), + ('event_participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_calendar_availabilities', to='api.eventparticipant')), + ('event_calendar_timeslot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participant_availabilities', to='api.eventcalendartimeslot')), + ], + ), + migrations.AddIndex( + model_name='eventcalendartimeslot', + index=models.Index(fields=['user_event', 'date'], name='api_eventca_user_ev_f064a6_idx'), + ), + migrations.AddConstraint( + model_name='eventcalendartimeslot', + constraint=models.UniqueConstraint(fields=('user_event', 'date'), name='unique_calendar_timeslot_per_event'), + ), + migrations.AddIndex( + model_name='eventcalendaravailability', + index=models.Index(fields=['event_participant'], name='api_eventca_event_p_f21bab_idx'), + ), + migrations.AddConstraint( + model_name='eventcalendaravailability', + constraint=models.UniqueConstraint(fields=('event_participant', 'event_calendar_timeslot'), name='unique_participant_calendar_timeslot'), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 813fd612..464c1ec6 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -96,6 +96,7 @@ class UserEvent(models.Model): class EventType(models.TextChoices): GENERIC = "GENERIC", "Generic" SPECIFIC = "SPECIFIC", "Specific" + CALENDAR = "CALENDAR", "Calendar" date_type = models.CharField( max_length=20, @@ -204,6 +205,30 @@ class Meta: indexes = [models.Index(fields=["user_event", "utc_timeslot"])] +class EventCalendarTimeslot(models.Model): + """ + Timeslot for calendar events. + + The timeslots in this model are stored as dates only. They are not affected by time + zones or daylight saving time. + """ + + event_calendar_timeslot_id = models.AutoField(primary_key=True) + user_event = models.ForeignKey( + UserEvent, on_delete=models.CASCADE, related_name="calendar_timeslots" + ) + date = models.DateField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user_event", "date"], + name="unique_calendar_timeslot_per_event", + ) + ] + indexes = [models.Index(fields=["user_event", "date"])] + + class EventWeekdayAvailability(models.Model): event_weekday_availability_id = models.AutoField(primary_key=True) event_participant = models.ForeignKey( @@ -256,3 +281,30 @@ class Meta: ) ] indexes = [models.Index(fields=["event_participant"])] + + +class EventCalendarAvailability(models.Model): + event_calendar_availability_id = models.AutoField(primary_key=True) + event_participant = models.ForeignKey( + EventParticipant, + on_delete=models.CASCADE, + related_name="event_calendar_availabilities", + ) + event_calendar_timeslot = models.ForeignKey( + EventCalendarTimeslot, + on_delete=models.CASCADE, + related_name="participant_availabilities", + ) + status = models.CharField( + max_length=20, + choices=AvailabilityStatus.choices, + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["event_participant", "event_calendar_timeslot"], + name="unique_participant_calendar_timeslot", + ) + ] + indexes = [models.Index(fields=["event_participant"])] From 21837ff819b00a8b9e6d2958a0cc14c04ae79579 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 14:04:07 -0400 Subject: [PATCH 02/18] Create calendar event create and edit endpoints --- backend/api/event/serializers.py | 16 +++ backend/api/event/urls.py | 2 + backend/api/event/utils.py | 35 ++++++- backend/api/event/views.py | 164 ++++++++++++++++++++++++++++++- 4 files changed, 214 insertions(+), 3 deletions(-) diff --git a/backend/api/event/serializers.py b/backend/api/event/serializers.py index 490c2d0e..1989b606 100644 --- a/backend/api/event/serializers.py +++ b/backend/api/event/serializers.py @@ -28,6 +28,14 @@ class EventInfoSerializer(serializers.Serializer): time_zone = TimeZoneField(required=True) +class CalendarEventInfoSerializer(serializers.Serializer): + title = serializers.CharField(required=True, max_length=50) + timeslots = serializers.ListField( + child=serializers.DateField(), required=True, allow_empty=False + ) + time_zone = TimeZoneField(required=True) + + class DateEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): pass @@ -36,6 +44,10 @@ class WeekEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): pass +class CalendarEventCreateSerializer(CalendarEventInfoSerializer, CustomCodeSerializer): + pass + + class DateEventEditSerializer(EventInfoSerializer, EventCodeSerializer): pass @@ -44,6 +56,10 @@ class WeekEventEditSerializer(EventInfoSerializer, EventCodeSerializer): pass +class CalendarEventEditSerializer(CalendarEventInfoSerializer, EventCodeSerializer): + pass + + class EventDetailSerializer(EventInfoSerializer): is_creator = serializers.BooleanField(required=True) event_type = serializers.ChoiceField(required=True, choices=["Date", "Week"]) diff --git a/backend/api/event/urls.py b/backend/api/event/urls.py index 32ac4e2e..9530b897 100644 --- a/backend/api/event/urls.py +++ b/backend/api/event/urls.py @@ -5,10 +5,12 @@ urlpatterns = [ path("date-create/", views.create_date_event), path("week-create/", views.create_week_event), + path("calendar-create/", views.create_calendar_event), path("check-code/", views.check_code), path("get-true-code/", views.get_true_code), path("date-edit/", views.edit_date_event), path("week-edit/", views.edit_week_event), + path("calendar-edit/", views.edit_calendar_event), path("delete/", views.delete_event), path("get-details/", views.get_event_details), path("get-updates//", views.get_live_updates), diff --git a/backend/api/event/utils.py b/backend/api/event/utils.py index b8c1453c..87c82a08 100644 --- a/backend/api/event/utils.py +++ b/backend/api/event/utils.py @@ -2,7 +2,7 @@ import random import re import string -from datetime import datetime +from datetime import date, datetime from zoneinfo import ZoneInfo from django.db.models import Prefetch @@ -80,7 +80,7 @@ def check_timeslot_times(timeslots): def validate_date_timeslots( timeslots: list[datetime], - earliest_date_local: datetime.date, + earliest_date_local: date, user_time_zone: str, editing: bool = False, ): @@ -131,6 +131,37 @@ def validate_weekday_timeslots(timeslots): return {} +def validate_calendar_timeslots( + timeslots: list[date], earliest_date_local: date, editing: bool = False +): + if not timeslots: + return {"timeslots": ["At least one timeslot is required."]} + + start_date = min(timeslots) + end_date = max(timeslots) + + errors = {} + + 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 < earliest_date_local: + if editing: + add_error( + "Event cannot start earlier than today, or be moved earlier if already before today." + ) + else: + add_error("Event must start today or in the future.") + if (end_date - start_date).days > MAX_EVENT_DAYS: + add_error(f"Max event length is {MAX_EVENT_DAYS} days.") + + return errors + + def event_lookup_prefetch(event_code: str): """ Looks up an event by its URL code. diff --git a/backend/api/event/views.py b/backend/api/event/views.py index 3e3c15c3..a8801525 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -22,6 +22,8 @@ validate_query_param_input, ) from api.event.serializers import ( + CalendarEventCreateSerializer, + CalendarEventEditSerializer, DateEventCreateSerializer, DateEventEditSerializer, EventCodeSerializer, @@ -37,10 +39,17 @@ generate_code, js_weekday, touch_url_code, + validate_calendar_timeslots, validate_date_timeslots, validate_weekday_timeslots, ) -from api.models import EventDateTimeslot, EventWeekdayTimeslot, UrlCode, UserEvent +from api.models import ( + EventCalendarTimeslot, + EventDateTimeslot, + EventWeekdayTimeslot, + UrlCode, + UserEvent, +) from api.redis_pools import async_pool from api.settings import ( ACCOUNT_COOKIE_NAME, @@ -207,6 +216,69 @@ def create_week_event(request): return Response({"event_code": url_code}, status=201) +@api_endpoint("POST") +@require_auth +@validate_json_input(CalendarEventCreateSerializer) +@validate_output(EventCodeSerializer) +def create_calendar_event(request): + """ + Creates a 'calendar' type event that spans dates without specific times. + + If successful, the URL code for the event will be returned. + + The "timeslots" for this event type are just dates, so time zones do not affect the + slots. However, the creator's time zone is still stored for the event. + + A custom URL code can be specified, subject to availability. If unavailable, an error + message is returned. Only alphanumeric characters and dashes are allowed. + """ + user = request.user + title = request.validated_data.get("title") + timeslots = request.validated_data.get("timeslots") + time_zone = request.validated_data.get("time_zone") + custom_code = request.validated_data.get("custom_code") + + user_date_local = datetime.now(ZoneInfo(time_zone)).date() + errors = validate_calendar_timeslots(timeslots, user_date_local) + if errors.keys(): + return Response({"error": errors}, status=400) + + url_code = None + if custom_code: + error = check_custom_code(custom_code) + if error: + return Response({"error": {"custom_code": [error]}}, status=400) + url_code = custom_code + else: + # Generate a random code if not provided + try: + url_code = generate_code() + except Exception: + logger.critical("Failed to generate a unique URL code.") + return GENERIC_ERR_RESPONSE + + check_rate_limit(request, ThrottleScopes.EVENT_CREATION) + + with transaction.atomic(): + new_event = UserEvent.objects.create( + user_account=user, + title=title, + date_type=UserEvent.EventType.CALENDAR, + time_zone=time_zone, + ) + UrlCode.objects.create(url_code=url_code, user_event=new_event) + # Create calendar timeslot objects + EventCalendarTimeslot.objects.bulk_create( + [ + EventCalendarTimeslot(user_event=new_event, date=ts) + for ts in set(timeslots) + ] + ) + + logger.debug(f"Event created with code: {url_code}") + return Response({"event_code": url_code}, status=201) + + @api_endpoint("POST") @validate_json_input(RequiredCustomCodeSerializer) @validate_output(MessageOutputSerializer) @@ -421,6 +493,96 @@ def edit_week_event(request): return Response({"message": ["Event updated successfully."]}, status=200) +@api_endpoint("POST") +@check_auth +@validate_json_input(CalendarEventEditSerializer) +@validate_output(MessageOutputSerializer) +def edit_calendar_event(request): + """ + Edits a 'calendar' type event, identified by its URL code. + + The event must be originally created by the current user. + """ + user = request.user + event_code = request.validated_data.get("event_code") + title = request.validated_data.get("title") + timeslots = request.validated_data.get("timeslots") + time_zone = request.validated_data.get("time_zone") + + if not user: + return EVENT_NOT_FOUND_ERROR + + user_date_local = datetime.now(ZoneInfo(time_zone)).date() + try: + # Do everything inside a transaction to ensure atomicity + with transaction.atomic(): + # Find the event + event = UserEvent.objects.get( + url_code__url_code__iexact=event_code, + user_account=user, + date_type=UserEvent.EventType.CALENDAR, + ) + # Get the earliest timeslot + earliest_timeslot = ( + EventCalendarTimeslot.objects.filter(user_event=event) + .order_by("date") + .first() + ) + existing_start_date: datetime = None + if earliest_timeslot: + existing_start_date = earliest_timeslot.date + else: + logger.critical( + f"Event {event.id} has no timeslots when editing calendar event." + ) + return GENERIC_ERR_RESPONSE + + # 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_local = user_date_local + if existing_start_date < user_date_local: + earliest_date_local = existing_start_date + errors = validate_calendar_timeslots(timeslots, earliest_date_local, True) + if errors.keys(): + return Response({"error": errors}, status=400) + + # Update the event object itself + event.title = title + event.time_zone = time_zone + event.save() + + # Sort out the timeslot difference + existing_timeslots = set( + EventCalendarTimeslot.objects.filter(user_event=event).values_list( + "date", flat=True + ) + ) + edited_timeslots = set(timeslots) + to_delete = existing_timeslots - edited_timeslots + to_add = [ + EventCalendarTimeslot(user_event=event, date=ts) + for ts in edited_timeslots - existing_timeslots + ] + EventCalendarTimeslot.objects.filter( + user_event=event, date__in=to_delete + ).delete() + EventCalendarTimeslot.objects.bulk_create(to_add) + + except UserEvent.DoesNotExist: + return EVENT_NOT_FOUND_ERROR + + notify_live_update( + LiveUpdateEvent( + user_id=user.user_account_id, + event_code=event_code, + data=LiveUpdateEventEditData(), + ) + ) + + logger.debug(f"Event updated with code: {event_code}") + return Response({"message": ["Event updated successfully."]}, status=200) + + @api_endpoint("POST") @check_auth @validate_json_input(EventCodeSerializer) From 076532b59d9b5f858b6d4a387c282b2224ac250d Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:00:12 -0400 Subject: [PATCH 03/18] Add calendar support to availability add --- backend/api/availability/utils.py | 7 +++++- backend/api/availability/views.py | 38 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/backend/api/availability/utils.py b/backend/api/availability/utils.py index 0c0d4de7..40be215b 100644 --- a/backend/api/availability/utils.py +++ b/backend/api/availability/utils.py @@ -3,6 +3,7 @@ from django.db.models import Q from api.models import ( + EventCalendarTimeslot, EventDateTimeslot, EventParticipant, EventWeekdayTimeslot, @@ -16,10 +17,14 @@ def get_timeslots(event): timeslots = EventDateTimeslot.objects.filter(user_event=event).order_by( "utc_timeslot" ) - else: + elif event.date_type == UserEvent.EventType.GENERIC: timeslots = EventWeekdayTimeslot.objects.filter(user_event=event).order_by( "weekday", "local_timeslot" ) + elif event.date_type == UserEvent.EventType.CALENDAR: + timeslots = EventCalendarTimeslot.objects.filter(user_event=event).order_by( + "date" + ) return timeslots diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index c0a785e9..6aa8aaa6 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -1,3 +1,4 @@ +import datetime import logging from django.db import transaction @@ -21,6 +22,7 @@ ) from api.models import ( AvailabilityStatus, + EventCalendarAvailability, EventDateAvailability, EventParticipant, EventWeekdayAvailability, @@ -51,14 +53,16 @@ class InvalidTimeslotError(Exception): @validate_output(MessageOutputSerializer) def add_availability(request): """ - Adds availability for the current user to an event. This endpoint supports both types + Adds availability for the current user to an event. This endpoint supports all types of events. If the current user already added availability for the event, their data will be overridden. - The availability must be supplied in a 2D array, with the outermost array representing - days, and the innermost representing timeslots within that day. + The availability must be supplied in an array of timeslots that represent the dates + and times where the user is available. + + The timeslots must be in ISO format, even for calendar events. """ user = request.user event_code = request.validated_data.get("event_code") @@ -70,6 +74,12 @@ def add_availability(request): with transaction.atomic(): user_event = event_lookup(event_code) + if user_event.date_type == UserEvent.EventType.CALENDAR: + # Convert the availability to date objects for calendar events + availability = [a.date() for a in availability] + # Deduplicate any timeslots + availability = list(set(availability)) + if not check_name_available(user_event, user, display_name): return Response( { @@ -99,10 +109,14 @@ def add_availability(request): EventDateAvailability.objects.filter( event_participant=participant ).delete() - else: + elif user_event.date_type == UserEvent.EventType.GENERIC: EventWeekdayAvailability.objects.filter( event_participant=participant ).delete() + else: + EventCalendarAvailability.objects.filter( + event_participant=participant + ).delete() # Add new availability if user_event.date_type == UserEvent.EventType.SPECIFIC: @@ -135,6 +149,20 @@ def add_availability(request): ) ) EventWeekdayAvailability.objects.bulk_create(new_availabilities) + elif user_event.date_type == UserEvent.EventType.CALENDAR: + timeslot_dict = {t.date: t for t in timeslots} + new_availabilities = [] + for timeslot in availability: + if timeslot not in timeslot_dict: + raise InvalidTimeslotError() + new_availabilities.append( + EventCalendarAvailability( + event_participant=participant, + event_calendar_timeslot=timeslot_dict[timeslot], + status=AvailabilityStatus.AVAILABLE, + ) + ) + EventCalendarAvailability.objects.bulk_create(new_availabilities) # Update participant updated_at participant.save() @@ -167,7 +195,7 @@ def add_availability(request): joined_at=participant.created_at.isoformat(), updated_at=participant.updated_at.isoformat(), time_zone=participant.time_zone, - availability=[time.isoformat() for time in availability], + availability=[timeslot.isoformat() for timeslot in availability], ), ) ) From 5db0701f9735ba42d0690180dc10546282c48a27 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:05:59 -0400 Subject: [PATCH 04/18] Create to_datetime util function --- backend/api/availability/utils.py | 11 +++++++++++ backend/api/availability/views.py | 11 +++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/api/availability/utils.py b/backend/api/availability/utils.py index 40be215b..94735eec 100644 --- a/backend/api/availability/utils.py +++ b/backend/api/availability/utils.py @@ -46,3 +46,14 @@ def check_name_available(event, user, display_name): def get_weekday_date(weekday, timeslot): return datetime(2012, 1, weekday + 1, timeslot.hour, timeslot.minute) + + +def to_datetime(date): + """ + Converts a date object to a datetime object at midnight. + + If the input is already a datetime object, it is returned unchanged. + """ + if isinstance(date, datetime): + return date + return datetime(date.year, date.month, date.day) diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index 6aa8aaa6..26b516b6 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -11,7 +11,12 @@ EventAvailabilitySerializer, EventCodeSerializer, ) -from api.availability.utils import check_name_available, get_timeslots, get_weekday_date +from api.availability.utils import ( + check_name_available, + get_timeslots, + get_weekday_date, + to_datetime, +) from api.decorators import ( api_endpoint, check_auth, @@ -195,7 +200,9 @@ def add_availability(request): joined_at=participant.created_at.isoformat(), updated_at=participant.updated_at.isoformat(), time_zone=participant.time_zone, - availability=[timeslot.isoformat() for timeslot in availability], + availability=[ + to_datetime(timeslot).isoformat() for timeslot in availability + ], ), ) ) From 41575e7d42483f79984904056cc0f3c388a3bc1b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:07:22 -0400 Subject: [PATCH 05/18] Add calendar support to get self availability --- backend/api/availability/views.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index 26b516b6..70cd4e42 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -289,7 +289,7 @@ def get_self_availability(request): .order_by("event_date_timeslot__utc_timeslot") ) data = [a.event_date_timeslot.utc_timeslot for a in availabilities] - else: + elif event.date_type == UserEvent.EventType.GENERIC: availabilities = ( EventWeekdayAvailability.objects.filter(event_participant=participant) .select_related("event_weekday_timeslot") @@ -305,6 +305,14 @@ def get_self_availability(request): ) for a in availabilities ] + elif event.date_type == UserEvent.EventType.CALENDAR: + availabilities = ( + EventCalendarAvailability.objects.filter(event_participant=participant) + .select_related("event_calendar_timeslot") + .order_by("event_calendar_timeslot__date") + ) + # Convert dates to datetimes + data = [to_datetime(a.event_calendar_timeslot.date) for a in availabilities] return Response( { From 92c9a5f70a090984a67679a10d75aa79b861a08f Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:37:08 -0400 Subject: [PATCH 06/18] Consolidate event endpoints to use datetimes --- backend/api/event/serializers.py | 28 ++-------------------------- backend/api/event/utils.py | 6 +++--- backend/api/event/views.py | 24 ++++++++++-------------- 3 files changed, 15 insertions(+), 43 deletions(-) diff --git a/backend/api/event/serializers.py b/backend/api/event/serializers.py index 1989b606..04902155 100644 --- a/backend/api/event/serializers.py +++ b/backend/api/event/serializers.py @@ -28,35 +28,11 @@ class EventInfoSerializer(serializers.Serializer): time_zone = TimeZoneField(required=True) -class CalendarEventInfoSerializer(serializers.Serializer): - title = serializers.CharField(required=True, max_length=50) - timeslots = serializers.ListField( - child=serializers.DateField(), required=True, allow_empty=False - ) - time_zone = TimeZoneField(required=True) - - -class DateEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): - pass - - -class WeekEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): - pass - - -class CalendarEventCreateSerializer(CalendarEventInfoSerializer, CustomCodeSerializer): - pass - - -class DateEventEditSerializer(EventInfoSerializer, EventCodeSerializer): - pass - - -class WeekEventEditSerializer(EventInfoSerializer, EventCodeSerializer): +class EventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): pass -class CalendarEventEditSerializer(CalendarEventInfoSerializer, EventCodeSerializer): +class EventEditSerializer(EventInfoSerializer, EventCodeSerializer): pass diff --git a/backend/api/event/utils.py b/backend/api/event/utils.py index 87c82a08..2c04aa16 100644 --- a/backend/api/event/utils.py +++ b/backend/api/event/utils.py @@ -132,13 +132,13 @@ def validate_weekday_timeslots(timeslots): def validate_calendar_timeslots( - timeslots: list[date], earliest_date_local: date, editing: bool = False + timeslots: list[datetime], earliest_date_local: date, editing: bool = False ): if not timeslots: return {"timeslots": ["At least one timeslot is required."]} - start_date = min(timeslots) - end_date = max(timeslots) + start_date = min(ts.date() for ts in timeslots) + end_date = max(ts.date() for ts in timeslots) errors = {} diff --git a/backend/api/event/views.py b/backend/api/event/views.py index a8801525..c300ce4d 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -22,16 +22,12 @@ validate_query_param_input, ) from api.event.serializers import ( - CalendarEventCreateSerializer, - CalendarEventEditSerializer, - DateEventCreateSerializer, - DateEventEditSerializer, EventCodeSerializer, + EventCreateSerializer, EventDetailSerializer, + EventEditSerializer, RequiredCustomCodeSerializer, TrueCodeSerializer, - WeekEventCreateSerializer, - WeekEventEditSerializer, ) from api.event.utils import ( check_custom_code, @@ -85,7 +81,7 @@ @api_endpoint("POST") @require_auth -@validate_json_input(DateEventCreateSerializer) +@validate_json_input(EventCreateSerializer) @validate_output(EventCodeSerializer) def create_date_event(request): """ @@ -148,7 +144,7 @@ def create_date_event(request): @api_endpoint("POST") @require_auth -@validate_json_input(WeekEventCreateSerializer) +@validate_json_input(EventCreateSerializer) @validate_output(EventCodeSerializer) def create_week_event(request): """ @@ -218,7 +214,7 @@ def create_week_event(request): @api_endpoint("POST") @require_auth -@validate_json_input(CalendarEventCreateSerializer) +@validate_json_input(EventCreateSerializer) @validate_output(EventCodeSerializer) def create_calendar_event(request): """ @@ -270,7 +266,7 @@ def create_calendar_event(request): # Create calendar timeslot objects EventCalendarTimeslot.objects.bulk_create( [ - EventCalendarTimeslot(user_event=new_event, date=ts) + EventCalendarTimeslot(user_event=new_event, date=ts.date()) for ts in set(timeslots) ] ) @@ -322,7 +318,7 @@ def get_true_code(request): @api_endpoint("POST") @check_auth -@validate_json_input(DateEventEditSerializer) +@validate_json_input(EventEditSerializer) @validate_output(MessageOutputSerializer) def edit_date_event(request): """ @@ -418,7 +414,7 @@ def edit_date_event(request): @api_endpoint("POST") @check_auth -@validate_json_input(WeekEventEditSerializer) +@validate_json_input(EventEditSerializer) @validate_output(MessageOutputSerializer) def edit_week_event(request): """ @@ -495,7 +491,7 @@ def edit_week_event(request): @api_endpoint("POST") @check_auth -@validate_json_input(CalendarEventEditSerializer) +@validate_json_input(EventEditSerializer) @validate_output(MessageOutputSerializer) def edit_calendar_event(request): """ @@ -557,7 +553,7 @@ def edit_calendar_event(request): "date", flat=True ) ) - edited_timeslots = set(timeslots) + edited_timeslots = set(ts.date() for ts in timeslots) to_delete = existing_timeslots - edited_timeslots to_add = [ EventCalendarTimeslot(user_event=event, date=ts) From e83bae3053a5d36c81405eb6a2d54767ecff38ba Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:40:14 -0400 Subject: [PATCH 07/18] Move to_datetime to central utils --- backend/api/availability/utils.py | 11 ----------- backend/api/availability/views.py | 2 +- backend/api/utils.py | 11 +++++++++++ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/api/availability/utils.py b/backend/api/availability/utils.py index 94735eec..40be215b 100644 --- a/backend/api/availability/utils.py +++ b/backend/api/availability/utils.py @@ -46,14 +46,3 @@ def check_name_available(event, user, display_name): def get_weekday_date(weekday, timeslot): return datetime(2012, 1, weekday + 1, timeslot.hour, timeslot.minute) - - -def to_datetime(date): - """ - Converts a date object to a datetime object at midnight. - - If the input is already a datetime object, it is returned unchanged. - """ - if isinstance(date, datetime): - return date - return datetime(date.year, date.month, date.day) diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index 70cd4e42..5e1b6767 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -15,7 +15,6 @@ check_name_available, get_timeslots, get_weekday_date, - to_datetime, ) from api.decorators import ( api_endpoint, @@ -43,6 +42,7 @@ check_rate_limit, event_lookup, notify_live_update, + to_datetime, ) logger = logging.getLogger("api") diff --git a/backend/api/utils.py b/backend/api/utils.py index 76e580dd..b916b0dd 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -472,3 +472,14 @@ def notify_live_update(event: LiveUpdateEvent): f"event_{event.event_code}", event.dumps(), ) + + +def to_datetime(date): + """ + Converts a date object to a datetime object at midnight. + + If the input is already a datetime object, it is returned unchanged. + """ + if isinstance(date, datetime): + return date + return datetime(date.year, date.month, date.day) From fa2ce9c5c1ddc2cab8abd19aaf0423aefe6ed4ba Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:42:56 -0400 Subject: [PATCH 08/18] Fix ID fetch on error logging --- backend/api/event/views.py | 4 ++-- backend/api/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/event/views.py b/backend/api/event/views.py index c300ce4d..e9cded6c 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -356,7 +356,7 @@ def edit_date_event(request): existing_start_date = earliest_timeslot.utc_timeslot else: logger.critical( - f"Event {event.id} has no timeslots when editing date event." + f"Event {event.user_event_id} has no timeslots when editing date event." ) return GENERIC_ERR_RESPONSE # Convert it to local date for comparison @@ -529,7 +529,7 @@ def edit_calendar_event(request): existing_start_date = earliest_timeslot.date else: logger.critical( - f"Event {event.id} has no timeslots when editing calendar event." + f"Event {event.user_event_id} has no timeslots when editing calendar event." ) return GENERIC_ERR_RESPONSE diff --git a/backend/api/utils.py b/backend/api/utils.py index b916b0dd..52907d0e 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -180,7 +180,7 @@ def get_event_bounds(event: UserEvent) -> EventBounds: if not all_timeslots: logger.critical( - f"Event {event.id} has no timeslots when formatting for dashboard." + f"Event {event.user_event_id} has no timeslots when formatting for dashboard." ) raise ValueError("Event has no timeslots.") From d03b6fdbb8c71034e0ba67ce013ba5e65517b8af Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:43:50 -0400 Subject: [PATCH 09/18] Add calendar to event types --- backend/api/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/api/utils.py b/backend/api/utils.py index 52907d0e..161cc569 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -139,6 +139,8 @@ def get_event_type(date_type): return "Date" case UserEvent.EventType.GENERIC: return "Week" + case UserEvent.EventType.CALENDAR: + return "Calendar" class EventBounds: From 3edd64509c0dbe06f4f928b82e1ad4c09e609b63 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:48:19 -0400 Subject: [PATCH 10/18] Add calendar to event details endpoint --- backend/api/event/serializers.py | 4 ++- backend/api/event/utils.py | 12 ++++++- backend/api/event/views.py | 4 +++ backend/api/utils.py | 56 +++++++++++++++++++++----------- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/backend/api/event/serializers.py b/backend/api/event/serializers.py index 04902155..2f918d0b 100644 --- a/backend/api/event/serializers.py +++ b/backend/api/event/serializers.py @@ -38,7 +38,9 @@ class EventEditSerializer(EventInfoSerializer, EventCodeSerializer): class EventDetailSerializer(EventInfoSerializer): is_creator = serializers.BooleanField(required=True) - event_type = serializers.ChoiceField(required=True, choices=["Date", "Week"]) + event_type = serializers.ChoiceField( + required=True, choices=["Date", "Week", "Calendar"] + ) start_date = serializers.DateField(required=True) end_date = serializers.DateField(required=True) start_time = serializers.TimeField(required=True) diff --git a/backend/api/event/utils.py b/backend/api/event/utils.py index 2c04aa16..197a13dd 100644 --- a/backend/api/event/utils.py +++ b/backend/api/event/utils.py @@ -7,7 +7,13 @@ from django.db.models import Prefetch -from api.models import EventDateTimeslot, EventWeekdayTimeslot, UrlCode, UserEvent +from api.models import ( + EventCalendarTimeslot, + EventDateTimeslot, + EventWeekdayTimeslot, + UrlCode, + UserEvent, +) from api.settings import MAX_EVENT_DAYS, RAND_URL_CODE_ATTEMPTS, RAND_URL_CODE_LENGTH logger = logging.getLogger("api") @@ -177,6 +183,10 @@ def event_lookup_prefetch(event_code: str): "weekday_timeslots", queryset=EventWeekdayTimeslot.objects.order_by("weekday", "local_timeslot"), ), + Prefetch( + "calendar_timeslots", + queryset=EventCalendarTimeslot.objects.order_by("date"), + ), ).get(url_code__url_code__iexact=event_code) diff --git a/backend/api/event/views.py b/backend/api/event/views.py index e9cded6c..7181ea45 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -65,6 +65,7 @@ format_event_info, get_session, notify_live_update, + to_datetime, ) logger = logging.getLogger("api") @@ -638,6 +639,9 @@ def get_event_details(request): data["timeslots"] = [ get_weekday_date(ts.weekday, ts.local_timeslot) for ts in timeslots ] + case UserEvent.EventType.CALENDAR: + timeslots = event.calendar_timeslots.all() + data["timeslots"] = [to_datetime(ts.date) for ts in timeslots] except UserEvent.DoesNotExist: return EVENT_NOT_FOUND_ERROR diff --git a/backend/api/utils.py b/backend/api/utils.py index 161cc569..e68f12d1 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -179,6 +179,10 @@ def get_event_bounds(event: UserEvent) -> EventBounds: get_weekday_date(ts.weekday, ts.local_timeslot) for ts in event.weekday_timeslots.all() ] + case UserEvent.EventType.CALENDAR: + all_timeslots = [ + to_datetime(ts.date) for ts in event.calendar_timeslots.all() + ] if not all_timeslots: logger.critical( @@ -191,26 +195,40 @@ def get_event_bounds(event: UserEvent) -> EventBounds: 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() - # 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 - ) - end_datetime = datetime.combine(end_date, end_time).replace(tzinfo=event_time_zone) - if event.date_type == UserEvent.EventType.SPECIFIC: - # Convert to UTC for date events, not week events since those stay in local time - start_datetime = start_datetime.astimezone(ZoneInfo("UTC")) - end_datetime = end_datetime.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(), - ) + if event.date_type == UserEvent.EventType.CALENDAR: + return EventBounds( + start_date=start_date, + end_date=end_date, + start_time=start_time, + end_time=end_time, + ) + else: + # End time should be 15 minutes after the last timeslot + # Only applies to date and week events, since calendar events are all-day + end_time = ( + datetime.combine(datetime.min, end_time) + timedelta(minutes=15) + ).time() + + # 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 + ) + end_datetime = datetime.combine(end_date, end_time).replace( + tzinfo=event_time_zone + ) + if event.date_type == UserEvent.EventType.SPECIFIC: + # Convert to UTC for date events, not week events since those stay in local time + start_datetime = start_datetime.astimezone(ZoneInfo("UTC")) + end_datetime = end_datetime.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, include_participants: bool = False) -> dict: From 52ec6c0b00bb607929b39d4181883def6506a529 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:49:08 -0400 Subject: [PATCH 11/18] Remove unused import --- backend/api/availability/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index 5e1b6767..b0c1531b 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -1,4 +1,3 @@ -import datetime import logging from django.db import transaction From df9a9fc0beead3dad9304c280e96f1b17778433b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:55:04 -0400 Subject: [PATCH 12/18] Add calendar to get all availability --- backend/api/availability/views.py | 44 +++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index b0c1531b..e31e5ab6 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -362,11 +362,14 @@ def get_all_availability(request): if event.date_type == UserEvent.EventType.SPECIFIC: for slot in timeslots: availability_dict[slot.utc_timeslot.isoformat()] = [] - else: + elif event.date_type == UserEvent.EventType.GENERIC: for slot in timeslots: availability_dict[ get_weekday_date(slot.weekday, slot.local_timeslot).isoformat() ] = [] + elif event.date_type == UserEvent.EventType.CALENDAR: + for slot in timeslots: + availability_dict[to_datetime(slot.date).isoformat()] = [] if not len(participants): return Response( @@ -419,7 +422,7 @@ def get_all_availability(request): }, status=200, ) - else: + elif event.date_type == UserEvent.EventType.GENERIC: availabilities = ( EventWeekdayAvailability.objects.filter( event_participant__in=participants @@ -460,6 +463,43 @@ def get_all_availability(request): }, status=200, ) + elif event.date_type == UserEvent.EventType.CALENDAR: + availabilities = ( + EventCalendarAvailability.objects.filter( + event_participant__in=participants + ) + .select_related("event_calendar_timeslot", "event_participant") + .order_by( + "event_calendar_timeslot__date", + "event_participant__display_name", + ) + ) + for t in availabilities: + timeslot = to_datetime(t.event_calendar_timeslot.date).isoformat() + if timeslot not in availability_dict: + logger.error( + f"Timeslot {timeslot} not found in availability dict for event {event_code}" + ) + continue + availability_dict[timeslot].append(t.event_participant.display_name) + + return Response( + { + "user_display_name": user_display_name, + "participants": [ + { + "public_id": str(p.public_id), + "display_name": p.display_name, + "joined_at": p.created_at.isoformat(), + "updated_at": p.updated_at.isoformat(), + "time_zone": p.time_zone, + } + for p in participants + ], + "availability": availability_dict, + }, + status=200, + ) except UserEvent.DoesNotExist: return Response( From c75e5aec8a10a54bee23cb8f429c6bad9ec2c36a Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 15:57:51 -0400 Subject: [PATCH 13/18] Add calendar to dashboard --- backend/api/dashboard/views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/api/dashboard/views.py b/backend/api/dashboard/views.py index ccfdd40a..7b1567c9 100644 --- a/backend/api/dashboard/views.py +++ b/backend/api/dashboard/views.py @@ -6,6 +6,7 @@ from api.decorators import api_endpoint, check_auth, validate_output from api.models import ( + EventCalendarTimeslot, EventDateTimeslot, EventParticipant, EventWeekdayTimeslot, @@ -18,7 +19,9 @@ class DashboardEventSerializer(serializers.Serializer): title = serializers.CharField(required=True, max_length=255) - event_type = serializers.ChoiceField(required=True, choices=["Date", "Week"]) + event_type = serializers.ChoiceField( + required=True, choices=["Date", "Week", "Calendar"] + ) start_date = serializers.DateField(required=True) end_date = serializers.DateField(required=True) start_time = serializers.TimeField(required=True) @@ -78,6 +81,10 @@ def get_dashboard(request): "weekday", "local_timeslot" ), ), + Prefetch( + "calendar_timeslots", + queryset=EventCalendarTimeslot.objects.order_by("date"), + ), Prefetch( "participants", queryset=EventParticipant.objects.order_by("created_at"), From 1400f66de36383af273514a7039ff51bee5460f3 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 16:01:20 -0400 Subject: [PATCH 14/18] Add comment to calendar exemption --- backend/api/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/api/utils.py b/backend/api/utils.py index e68f12d1..8ea70e25 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -197,6 +197,7 @@ def get_event_bounds(event: UserEvent) -> EventBounds: end_time = max(ts.time() for ts in all_timeslots) if event.date_type == UserEvent.EventType.CALENDAR: + # Don't do any conversions for calendar events, time zones don't affect them return EventBounds( start_date=start_date, end_date=end_date, From 83a211a6a0fc7c8f485dc662de4db40f0ce42826 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 16:02:36 -0400 Subject: [PATCH 15/18] Update calendar event type case for consistency --- backend/api/availability/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/availability/views.py b/backend/api/availability/views.py index e31e5ab6..d261065e 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -117,7 +117,7 @@ def add_availability(request): EventWeekdayAvailability.objects.filter( event_participant=participant ).delete() - else: + elif user_event.date_type == UserEvent.EventType.CALENDAR: EventCalendarAvailability.objects.filter( event_participant=participant ).delete() From 7e224d44ef2c40f0b80f7241033b2ce472844ab5 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 16:07:03 -0400 Subject: [PATCH 16/18] Update frontend API mappings for calendar events --- frontend/src/lib/utils/api/endpoints.ts | 14 ++++++++++++++ frontend/src/lib/utils/api/types.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 72797104..f3f9015e 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -111,6 +111,13 @@ export const ROUTES = { * @throws 400 - If the custom code is not available or invalid. */ weekCreate: route("/event/week-create/"), + /** + * Creates a 'calendar' type event. + * @method POST + * @throws 400 - If the timeslots are invalid. + * @throws 400 - If the custom code is not available or invalid. + */ + calendarCreate: route("/event/calendar-create/"), /** * Checks if a custom event code is available for use. * @method POST @@ -137,6 +144,13 @@ export const ROUTES = { * @throws 404 - If the user is not the creator of the event. */ weekEdit: route("/event/week-edit/"), + /** + * Edits the details of a 'calendar' type event. + * @method POST + * @throws 400 - If the timeslots are invalid. + * @throws 404 - If the user is not the creator of the event. + */ + calendarEdit: route("/event/calendar-edit/"), /** * Deletes an event. * @method POST diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index e8761362..da16d660 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -67,7 +67,7 @@ export type EventEditData = { event_code: string; } -type ApiEventType = "Date" | "Week"; +type ApiEventType = "Date" | "Week" | "Calendar"; export type EventDetails = { title: string; From fe627af0c8b6beeabadb75c72dfe19eefb37d020 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 16:34:12 -0400 Subject: [PATCH 17/18] Add calendar prefetch for participated events --- backend/api/dashboard/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/api/dashboard/views.py b/backend/api/dashboard/views.py index 7b1567c9..df6fcdfa 100644 --- a/backend/api/dashboard/views.py +++ b/backend/api/dashboard/views.py @@ -111,6 +111,10 @@ def get_dashboard(request): "weekday", "local_timeslot" ), ), + Prefetch( + "user_event__calendar_timeslots", + queryset=EventCalendarTimeslot.objects.order_by("date"), + ), Prefetch( "user_event__participants", queryset=EventParticipant.objects.order_by("created_at"), From bf6d8056c167d3ae2a6a3ea60847b894d8e7da9b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Fri, 17 Jul 2026 16:37:05 -0400 Subject: [PATCH 18/18] Add date deduplication on calendar event creation --- backend/api/event/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/api/event/views.py b/backend/api/event/views.py index 7181ea45..62cb0be5 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -265,11 +265,9 @@ def create_calendar_event(request): ) UrlCode.objects.create(url_code=url_code, user_event=new_event) # Create calendar timeslot objects + dateslots = set(ts.date() for ts in timeslots) EventCalendarTimeslot.objects.bulk_create( - [ - EventCalendarTimeslot(user_event=new_event, date=ts.date()) - for ts in set(timeslots) - ] + [EventCalendarTimeslot(user_event=new_event, date=ds) for ds in dateslots] ) logger.debug(f"Event created with code: {url_code}")