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..d261065e 100644 --- a/backend/api/availability/views.py +++ b/backend/api/availability/views.py @@ -10,7 +10,11 @@ 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, +) from api.decorators import ( api_endpoint, check_auth, @@ -21,6 +25,7 @@ ) from api.models import ( AvailabilityStatus, + EventCalendarAvailability, EventDateAvailability, EventParticipant, EventWeekdayAvailability, @@ -36,6 +41,7 @@ check_rate_limit, event_lookup, notify_live_update, + to_datetime, ) logger = logging.getLogger("api") @@ -51,14 +57,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 +78,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 +113,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() + elif user_event.date_type == UserEvent.EventType.CALENDAR: + EventCalendarAvailability.objects.filter( + event_participant=participant + ).delete() # Add new availability if user_event.date_type == UserEvent.EventType.SPECIFIC: @@ -135,6 +153,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 +199,9 @@ 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=[ + to_datetime(timeslot).isoformat() for timeslot in availability + ], ), ) ) @@ -254,7 +288,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") @@ -270,6 +304,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( { @@ -320,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( @@ -377,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 @@ -418,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( diff --git a/backend/api/dashboard/views.py b/backend/api/dashboard/views.py index ccfdd40a..df6fcdfa 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"), @@ -104,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"), diff --git a/backend/api/event/serializers.py b/backend/api/event/serializers.py index 490c2d0e..2f918d0b 100644 --- a/backend/api/event/serializers.py +++ b/backend/api/event/serializers.py @@ -28,25 +28,19 @@ class EventInfoSerializer(serializers.Serializer): time_zone = TimeZoneField(required=True) -class DateEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): +class EventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): pass -class WeekEventCreateSerializer(EventInfoSerializer, CustomCodeSerializer): - pass - - -class DateEventEditSerializer(EventInfoSerializer, EventCodeSerializer): - pass - - -class WeekEventEditSerializer(EventInfoSerializer, EventCodeSerializer): +class EventEditSerializer(EventInfoSerializer, EventCodeSerializer): pass 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/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..197a13dd 100644 --- a/backend/api/event/utils.py +++ b/backend/api/event/utils.py @@ -2,12 +2,18 @@ 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 -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") @@ -80,7 +86,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 +137,37 @@ def validate_weekday_timeslots(timeslots): return {} +def validate_calendar_timeslots( + timeslots: list[datetime], earliest_date_local: date, editing: bool = False +): + 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) + + 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. @@ -146,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 3e3c15c3..62cb0be5 100644 --- a/backend/api/event/views.py +++ b/backend/api/event/views.py @@ -22,14 +22,12 @@ validate_query_param_input, ) from api.event.serializers import ( - DateEventCreateSerializer, - DateEventEditSerializer, EventCodeSerializer, + EventCreateSerializer, EventDetailSerializer, + EventEditSerializer, RequiredCustomCodeSerializer, TrueCodeSerializer, - WeekEventCreateSerializer, - WeekEventEditSerializer, ) from api.event.utils import ( check_custom_code, @@ -37,10 +35,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, @@ -60,6 +65,7 @@ format_event_info, get_session, notify_live_update, + to_datetime, ) logger = logging.getLogger("api") @@ -76,7 +82,7 @@ @api_endpoint("POST") @require_auth -@validate_json_input(DateEventCreateSerializer) +@validate_json_input(EventCreateSerializer) @validate_output(EventCodeSerializer) def create_date_event(request): """ @@ -139,7 +145,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): """ @@ -207,6 +213,67 @@ def create_week_event(request): return Response({"event_code": url_code}, status=201) +@api_endpoint("POST") +@require_auth +@validate_json_input(EventCreateSerializer) +@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 + dateslots = set(ts.date() for ts in timeslots) + EventCalendarTimeslot.objects.bulk_create( + [EventCalendarTimeslot(user_event=new_event, date=ds) for ds in dateslots] + ) + + 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) @@ -250,7 +317,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): """ @@ -288,7 +355,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 @@ -346,7 +413,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): """ @@ -421,6 +488,96 @@ def edit_week_event(request): return Response({"message": ["Event updated successfully."]}, status=200) +@api_endpoint("POST") +@check_auth +@validate_json_input(EventEditSerializer) +@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.user_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(ts.date() for ts in 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) @@ -480,6 +637,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/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"])] diff --git a/backend/api/utils.py b/backend/api/utils.py index 76e580dd..8ea70e25 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: @@ -177,10 +179,14 @@ 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( - 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.") @@ -189,26 +195,41 @@ 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: + # Don't do any conversions for calendar events, time zones don't affect them + 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: @@ -472,3 +493,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) 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;