diff --git a/server/api/room/README.md b/server/api/room/README.md index 581393f..115d431 100644 --- a/server/api/room/README.md +++ b/server/api/room/README.md @@ -80,6 +80,47 @@ Returns details for a single room. --- +## Retrieve rooms availability + +**GET** `/api/rooms/availability` + +Returns availability of rooms in a boolean format. A room is available if the room has any free slot that overlaps with the requested time range (the time range must be at least partly later than now). In other words, a room is available if it has slot that can be booked. + +### Query Parameters (all optional): + +- `start_datetime`: Start datetime after or equal to (ISO 8601). Note: Date string is not accepted. +- `end_datetime`: End datetime before or equal to (ISO 8601). Note: Date string is not accepted. + +**Pagination:** +Results are paginated (10 per page by default). Use `?page=2` for next page. Room_id returned in each page will be the same as /rooms. + +**Example Request:** + +``` +http://localhost:8000/api/rooms/availability/?start_datetime=2026-01-19T12:00:00+0800&end_datetime=2026-01-19T14:00:00+0800 +``` + +--- + +## Retrieve room availability + +**GET** `/api/rooms/{id}/availability` + +Returns available slots for a single room. Slots returned will only include time later than now. + +### Query Parameters: + +- `start_date`: Start date after or equal to (YYYY-MM-DD). Optional. +- `end_date`: End date before or equal to (YYYY-MM-DD). Required. + +**Example Request:** + +``` +GET /api/rooms/1/availability/?start_date=2026-01-05&end_date=2026-01-11 +``` + +--- + ## Notes - Unauthenticated users only see rooms where `is_active=true`. diff --git a/server/api/room/tests.py b/server/api/room/tests.py index 2b8bf8e..e9fe888 100644 --- a/server/api/room/tests.py +++ b/server/api/room/tests.py @@ -3,10 +3,33 @@ from django.contrib.auth import get_user_model from .models import Room, Location, Amenity from django.utils import timezone +from datetime import timedelta, time +from api.booking.models import Booking User = get_user_model() +def next_monday_and_sunday(): + today = timezone.localdate() + # Monday = 0, Sunday = 6 + days_until_monday = (0 - today.weekday()) % 7 + if days_until_monday == 0: + days_until_monday = 7 + + next_monday = today + timedelta(days=days_until_monday) + next_tuesday = next_monday + timedelta(days=1) + next_wednesday = next_monday + timedelta(days=2) + next_thursday = next_monday + timedelta(days=3) + next_sunday = next_monday + timedelta(days=6) + + return next_monday, next_tuesday, next_wednesday, next_thursday, next_sunday + + +next_monday, next_tuesday, next_wednesday, next_thursday, next_sunday = next_monday_and_sunday() +today = timezone.localdate() +next_year = today.year + 1 + + class RoomAPITest(APITestCase): def setUp(self): # abc user @@ -126,3 +149,348 @@ def test_delete_room_not_allowed(self): print(response.content.decode()) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + +class AvailabilityAPITest(APITestCase): + def setUp(self): + # abc user + self.abc = User.objects.create_superuser( + "abc", "abc@test.com", "pass") + self.client.login(username="abc", password="pass") + + # Locations + self.loc1 = Location.objects.create(name="Building A") + + # Amenities + self.amenity1 = Amenity.objects.create(name="Projector") + self.amenity2 = Amenity.objects.create(name="Whiteboard") + + # Rooms + # Room with a recurrence rule (no end date) + self.room1 = Room.objects.create( + name="Meeting Room 1", + location=self.loc1, + capacity=10, + start_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 9, 0)), + end_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 18, 0)), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO,TU,WE", + is_active=True + ) + self.room1.amenities.set([self.amenity1, self.amenity2]) + + # Room without a recurrence rule + self.room2 = Room.objects.create( + name="Meeting Room 2", + location=self.loc1, + capacity=5, + start_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(9, 0))), + end_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(18, 0))), + is_active=True + ) + self.room2.amenities.set([self.amenity1, self.amenity2]) + + # Room available today (to test timezone handling) + self.room3 = Room.objects.create( + name="Meeting Room 3", + location=self.loc1, + capacity=8, + start_datetime=timezone.make_aware( + timezone.datetime.combine(today, time(0, 0)) + ), + end_datetime=timezone.make_aware( + timezone.datetime.combine(today, time(23, 59)) + ), + is_active=True + ) + self.room3.amenities.set([self.amenity1, self.amenity2]) + + # Rooms with a recurrence rule (with end date) + self.room4 = Room.objects.create( + name="Meeting Room 4", + location=self.loc1, + capacity=12, + start_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 9, 0)), + end_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 18, 0)), + recurrence_rule=f"FREQ=DAILY;UNTIL={next_year}1230000000Z", + is_active=True + ) + self.room4.amenities.set([self.amenity1, self.amenity2]) + + # Rooms with a recurrence rule (with count) + self.room5 = Room.objects.create( + name="Meeting Room 5", + location=self.loc1, + capacity=12, + start_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(9, 0))), + end_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(18, 0))), + recurrence_rule="FREQ=DAILY;COUNT=5", + is_active=True + ) + self.room5.amenities.set([self.amenity1, self.amenity2]) + + # Bookings + # Booking for room with a recurrence rule (no end date) + self.booking1 = Booking.objects.create( + room=self.room1, + visitor_name='John Doe', + visitor_email='john@example.com', + start_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 11, 0)), + end_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 12, 0)), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO", + status='CONFIRMED' + ) + + # Booking for room without a recurrence rule + self.booking2 = Booking.objects.create( + room=self.room2, + visitor_name='John Doe', + visitor_email='john@example.com', + start_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(13, 0))), + end_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(14, 0))), + recurrence_rule="", + status='CONFIRMED' + ) + + # Booking for room with a recurrence rule (with end date) + self.booking3 = Booking.objects.create( + room=self.room4, + visitor_name='John Doe', + visitor_email='john@example.com', + start_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 15, 0)), + end_datetime=timezone.make_aware( + timezone.datetime(2025, 10, 1, 16, 0)), + recurrence_rule=f"FREQ=DAILY;UNTIL={next_year}1130000000Z", + status='CONFIRMED' + ) + + # Booking for room with a recurrence rule (with count) + self.booking4 = Booking.objects.create( + room=self.room5, + visitor_name='John Doe', + visitor_email='john@example.com', + start_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(15, 0))), + end_datetime=timezone.make_aware( + timezone.datetime.combine(next_monday, time(16, 0))), + recurrence_rule="FREQ=DAILY;COUNT=3", + status='CONFIRMED' + ) + + # Test availability when there are no bookings + def test_availability_no_bookings(self): + # Check availability for next Tuesday to next Sunday + response = self.client.get( + f"/api/rooms/{self.room1.id}/availability/?start_date={next_tuesday.isoformat()}&end_date={next_sunday.isoformat()}") + print("\nAvailability No Bookings Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room1.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 2) + self.assertEqual(availability[0]['date'], next_tuesday.isoformat()) + self.assertEqual(len(availability[0]["slots"]), 1) + self.assertEqual(availability[1]['date'], next_wednesday.isoformat()) + self.assertEqual(len(availability[1]["slots"]), 1) + + # Test availability when room and booking have recurrence rules + def test_availability_recurrence_rules(self): + # Check availability for next Monday to next Sunday + response = self.client.get( + f"/api/rooms/{self.room1.id}/availability/?start_date={next_monday.isoformat()}&end_date={next_sunday.isoformat()}") + print("\nAvailability Recurrence Rules Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room1.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 3) + self.assertEqual(availability[0]['date'], next_monday.isoformat()) + self.assertEqual(len(availability[0]["slots"]), 2) + # Monday has a booking from 11:00 to 12:00 + self.assertEqual(availability[0]["slots"][0]['end'], timezone.make_aware( + timezone.datetime.combine(next_monday, time(11, 0))).isoformat()) + self.assertEqual(availability[0]["slots"][1]['start'], timezone.make_aware( + timezone.datetime.combine(next_monday, time(12, 0))).isoformat()) + self.assertEqual(len(availability[1]["slots"]), 1) + self.assertEqual(len(availability[1]["slots"]), 1) + + # Test availability when room and booking have no recurrence rules + def test_availability_no_recurrence_rules(self): + # Check availability for next Monday to next Sunday + response = self.client.get( + f"/api/rooms/{self.room2.id}/availability/?start_date={next_monday.isoformat()}&end_date={next_sunday.isoformat()}") + print("\nAvailability No Recurrence Rules Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room2.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 1) + self.assertEqual(availability[0]['date'], next_monday.isoformat()) + self.assertEqual(len(availability[0]["slots"]), 2) + # Room 2 has a booking from 13:00 to 14:00 + self.assertEqual(availability[0]["slots"][0]['end'], timezone.make_aware( + timezone.datetime.combine(next_monday, time(13, 0))).isoformat()) + self.assertEqual(availability[0]["slots"][1]['start'], timezone.make_aware( + timezone.datetime.combine(next_monday, time(14, 0))).isoformat()) + + # Test availability when start_date is at today + def test_availability_today(self): + response = self.client.get( + f"/api/rooms/{self.room3.id}/availability/?start_date={today.isoformat()}&end_date={today.isoformat()}" + ) + print("\nAvailability Today Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room3.id) + availability = response.data["availability"] + self.assertEqual(availability[0]["date"], today.isoformat()) + # Only one slot should remain (from now → 24:00) + self.assertEqual(len(availability[0]["slots"]), 1) + now_local = timezone.localtime(timezone.now()) + room_start = timezone.make_aware( + timezone.datetime.combine(today, time(0, 0)) + ) + room_end = timezone.make_aware( + timezone.datetime.combine(today, time(23, 59)) + ) + expected_start = max(room_start, now_local) + expected_end = room_end + actual_start = timezone.datetime.fromisoformat(availability[0]["slots"][0]["start"]) + self.assertLess(abs(actual_start - expected_start), timedelta(seconds=1)) + self.assertEqual(availability[0]["slots"][0]["end"], expected_end.isoformat()) + + # Test availability when room and booking have recurrence rules with end date + def test_availability_recurrence_rule_with_end_date(self): + response = self.client.get( + f"/api/rooms/{self.room4.id}/availability/?start_date={next_year}-11-01&end_date={next_year}-11-01" + ) + print("\nAvailability Recurrence Rule with End Date Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room4.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 1) + self.assertEqual(availability[0]['date'], f"{next_year}-11-01") + self.assertEqual(len(availability[0]["slots"]), 2) + # Monday has a booking from 15:00 to 16:00 + target_date = timezone.datetime.strptime(f"{next_year}-11-01", "%Y-%m-%d").date() + self.assertEqual(availability[0]["slots"][0]['end'], timezone.make_aware( + timezone.datetime.combine(target_date, time(15, 0))).isoformat()) + self.assertEqual(availability[0]["slots"][1]['start'], timezone.make_aware( + timezone.datetime.combine(target_date, time(16, 0))).isoformat()) + + # Test availability when room and booking have recurrence rules when booking end date is in the past + def test_availability_recurrence_rule_with_booking_end_date_past(self): + response = self.client.get( + f"/api/rooms/{self.room4.id}/availability/?start_date={next_year}-12-01&end_date={next_year}-12-01" + ) + print("\nAvailability Recurrence Rule with Booking End Date Past Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room4.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 1) + self.assertEqual(availability[0]['date'], f"{next_year}-12-01") + self.assertEqual(len(availability[0]["slots"]), 1) + + # Test availability when room and booking have recurrence rules when room end date is in the past + def test_availability_recurrence_rule_with_room_end_date_past(self): + # Check availability for next Monday to next Sunday + response = self.client.get( + f"/api/rooms/{self.room4.id}/availability/?start_date={next_year}-12-31&end_date={next_year}-12-31" + ) + print("\nAvailability Recurrence Rule with End Date Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room4.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 0) + + # Test availability when room and booking have recurrence rules with count + def test_availability_recurrence_rule_with_count(self): + response = self.client.get( + f"/api/rooms/{self.room5.id}/availability/?start_date={next_monday.isoformat()}&end_date={next_sunday.isoformat()}" + ) + print("\nAvailability Recurrence Rule with End Date Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + room_id = response.data["room_id"] + self.assertEqual(room_id, self.room5.id) + availability = response.data["availability"] + self.assertEqual(len(availability), 5) + # Monday + self.assertEqual(availability[0]['date'], f"{next_monday.isoformat()}") + self.assertEqual(len(availability[0]["slots"]), 2) + # Monday has a booking from 15:00 to 16:00 + self.assertEqual(availability[0]["slots"][0]['end'], timezone.make_aware( + timezone.datetime.combine(next_monday, time(15, 0))).isoformat()) + self.assertEqual(availability[0]["slots"][1]['start'], timezone.make_aware( + timezone.datetime.combine(next_monday, time(16, 0))).isoformat()) + # Friday (booking count ends on Wednesday) + self.assertEqual(availability[3]['date'], f"{next_thursday.isoformat()}") + self.assertEqual(len(availability[3]["slots"]), 1) + + def test_rooms_availability(self): + start_datetime = timezone.make_aware( + timezone.datetime.combine(next_monday, time(11, 0)) + ) + end_datetime = timezone.make_aware( + timezone.datetime.combine(next_monday, time(12, 0)) + ) + response = self.client.get( + f"/api/rooms/availability/?start_datetime={start_datetime.isoformat()}&end_datetime={end_datetime.isoformat()}" + ) + print("\nRooms Availability Response:") + print(response.content.decode()) + print() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data["results"] + self.assertEqual(len(data), 5) + # The room is booked 11-12 on next Monday + self.assertEqual(data[0]['room_id'], self.room1.id) + self.assertEqual(data[0]['availability'], False) + self.assertEqual(data[1]['room_id'], self.room2.id) + self.assertEqual(data[1]['availability'], True) + # The room is available only today + self.assertEqual(data[2]['room_id'], self.room3.id) + self.assertEqual(data[2]['availability'], False) + self.assertEqual(data[3]['room_id'], self.room4.id) + self.assertEqual(data[3]['availability'], True) + self.assertEqual(data[4]['room_id'], self.room5.id) + self.assertEqual(data[4]['availability'], True) diff --git a/server/api/room/views.py b/server/api/room/views.py index 1e9528e..7b61042 100644 --- a/server/api/room/views.py +++ b/server/api/room/views.py @@ -6,6 +6,15 @@ from .filters import RoomFilter from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.decorators import action +from django.db.models import Q +from django.utils.dateparse import parse_date, parse_datetime +from dateutil.rrule import rruleset, rrulestr +from api.booking.models import Booking +from datetime import datetime, time +from django.utils.timezone import localdate, make_aware, localtime, now +from collections import defaultdict +from rest_framework.exceptions import ValidationError # Viewset is library that provides CRUD operations for api # Admin have create update delete permissions everyone can read # get request can filter by name, location, capacity for get @@ -15,6 +24,46 @@ # Delete has custom response message +# Helper function to expand recurrence rules +def _expand_recurrences(base_start_datetime, rrule_str, rdate_list=None, exdate_list=None): + # Convert DTSTART to local timezone (where the recurrence rule apply) + start_local = localtime(base_start_datetime) + rrule_set = rruleset() + if rrule_str: + rrule_set.rrule(rrulestr(rrule_str, dtstart=start_local)) + if rdate_list: + for dt in rdate_list: + rrule_set.rdate(dt) + if exdate_list: + for dt in exdate_list: + rrule_set.exdate(dt) + return rrule_set + + +# Helper function to validate optional datetime string (excluding date string) +def parse_optional_datetime(value, field_name): + # Allow None + if value is None: + return None + # Fix URL-encoded '+' turning into space + value = value.replace(" ", "+", 1) if " " in value and "+" not in value else value + # Parse datetime string + parsed_datetime = parse_datetime(value) + if parsed_datetime is None: + raise ValidationError({ + "detail": f"Invalid datetime format for {field_name}." + }) + # Reject date-only strings (YYYY-MM-DD) + if "T" not in value: + raise ValidationError({ + "detail": f"Date-only strings are not allowed for {field_name}." + }) + # If parsed datetime is naive, apply Django default timezone + if parsed_datetime.tzinfo is None: + parsed_datetime = make_aware(parsed_datetime) + return parsed_datetime + + class RoomViewSet(viewsets.ModelViewSet): queryset = Room.objects.all() serializer_class = RoomSerializer @@ -46,6 +95,235 @@ def get_queryset(self): return qs + # get boolean availability (whether they can be booked) for rooms + @action(detail=False, methods=["get"], url_path="availability") + def get_rooms_availability(self, request, pk=None): + """ + Get availability (whether they can be booked) for rooms within a datetime range. + Returns: JSON response with room ID and availability (boolean). + """ + # Get the same base queryset as /rooms + queryset = self.get_queryset() + # Apply filter and order as /rooms + queryset = self.filter_queryset(queryset) + # Apply pagination as /rooms + page = self.paginate_queryset(queryset) + # Get start_datetime & end_datetime from params + start_datetime = request.query_params.get("start_datetime") + end_datetime = request.query_params.get("end_datetime") + start_datetime = parse_optional_datetime(start_datetime, 'start_datetime') + end_datetime = parse_optional_datetime(end_datetime, 'end_datetime') + # if start_datetime is earlier than now, set to now + current_time = localtime(now()) + if start_datetime is None or start_datetime < current_time: + start_datetime = current_time + # Compute availability for each room in the page + results = [] + # If there is no end_datetime, theoretically the room cannot be fully booked + if end_datetime is None: + for room in page: + # Availability is based on is_active status + availability = bool(room.is_active) + results.append({"room_id": room.id, "availability": availability}) + # If end_datetime is earlier than now, the room cannot be booked + elif end_datetime < current_time: + for room in page: + results.append({"room_id": room.id, "availability": False}) + else: + for room in page: + # If a room is inactive, its availability is always False + if not room.is_active: + availability = False + else: + availability = self._calculate_boolean_availability(room, start_datetime, end_datetime) + results.append({"room_id": room.id, "availability": availability}) + # Return paginated response + return self.get_paginated_response(results) + + # get availability slots (when a room can be booked) for a single room + @action(detail=True, methods=["get"], url_path="availability") + def get_room_availability(self, request, pk=None): + """ + Get availability for a specific room within a date range. + Returns: JSON response with room ID and availability slots. + """ + room = self.get_object() + # If a room is inactive, it has no availability. + if not room.is_active: + return Response({"room_id": room.id, "availability": []}, status=200) + start_date = request.query_params.get("start_date") + end_date = request.query_params.get("end_date") + # end_date is required (else there may be infinitely many slots) + if not end_date: + return Response({"detail": "end_date is required"}, status=400) + today = localdate() + # Assign start_date to be today if it does not exist) + # Parse date strings while validate format + try: + start_date = parse_date(start_date) if start_date else today + end_date = parse_date(end_date) + except ValueError as e: + # handle error like out of range month/day + return Response({"detail": f"Invalid date format. Error: {e}"}, status=400) + if start_date is None or end_date is None: + return Response({"detail": "Invalid date format."}, status=400) + # If the date range is larger than 42 days, return error + if (end_date - start_date).days > 42: + return Response( + {"detail": "Date range too large. Please limit to 42 days or fewer."}, + status=400 + ) + # Update start_date to be today if it's in the past + if start_date < today: + start_date = today + # If the date range is invalid or end_date is in the past, return empty + if start_date > end_date or end_date < today: + return Response({"room_id": room.id, "availability": []}, status=200) + availability = self._calculate_availability(room, start_date, end_date) + return Response({"room_id": room.id, "availability": availability}, status=200) + + def _calculate_boolean_availability(self, room, start_datetime, end_datetime): + """ + Returns True if the room has any free slot that overlaps with the requested time range. + Returns False only if no free interval intersects the requested time range. + Assumption: Room starttime and endtime are on the same day. + """ + availability_slots = self._get_availability_slots(room, start_datetime, end_datetime) + for as_start, as_end in availability_slots: + if as_end > start_datetime and as_start < end_datetime: + return True + return False + + def _calculate_availability(self, room, start_date, end_date): + """ + Returns available slots grouped by date in a dictionary format. + Assumption: Room starttime and endtime are on the same day. + """ + start_datetime = make_aware(datetime.combine(start_date, time.min)) + end_datetime = make_aware(datetime.combine(end_date, time.max)) + flattened_availability_slots = self._get_availability_slots(room, start_datetime, end_datetime) + # group slots by date + availability_slots = defaultdict(list) + for fi_start, fi_end in flattened_availability_slots: + date_str = localtime(fi_start).date().isoformat() + availability_slots[date_str].append({ + "start": localtime(fi_start).isoformat(), + "end": localtime(fi_end).isoformat() + }) + return [{"date": date, "slots": slots} for date, slots in sorted(availability_slots.items())] + + # Helper function to get availability slots after subtracting booked slots + def _get_availability_slots(self, room, start_datetime, end_datetime): + """ + Returns a flat list of (free_start, free_end) datetime tuples. + No sorting and formatting about output. + """ + # Step 1: get all slots that have been booked + booked_slots = [] + bookings = Booking.objects.filter(room=room, status="CONFIRMED").filter( + Q(recurrence_rule__isnull=False) | + Q(start_datetime__lt=end_datetime, end_datetime__gt=start_datetime) + ) + for booking in bookings: + duration = booking.end_datetime - booking.start_datetime + if booking.recurrence_rule: + booking_recurrence_rule = booking.recurrence_rule # handle rdate_list, exdate_list if needed + # get start time of occurrences between the date of start_datetime and the date of end_datetime + booking_occurrences = _expand_recurrences( + localtime(booking.start_datetime), + booking_recurrence_rule, + ).between( + make_aware(datetime.combine(start_datetime.date(), time.min)), + make_aware(datetime.combine(end_datetime.date(), time.max)) + ) + for occurrence_start in booking_occurrences: + booked_slots.append((localtime(occurrence_start), localtime(occurrence_start) + duration)) + else: + booked_slots.append((localtime(booking.start_datetime), localtime(booking.end_datetime))) + + # Step 2: get all available slots based on room's recurrence rules + availability_slots = [] + if not room.recurrence_rule: + # assume room.start_datetime and room.end_datetime are on the same date + available_date = localtime(room.start_datetime).date() + if start_datetime.date() <= available_date <= end_datetime.date(): + availability_slots.extend( + self._calculate_free_intervals( + localtime(room.start_datetime), + localtime(room.end_datetime), + booked_slots + )) + else: + duration = room.end_datetime - room.start_datetime + room_recurrence_rule = room.recurrence_rule # handle rdate_list, exdate_list if needed + # get start time of occurrences between the date of start_datetime and the date of end_datetime + room_occurrences = _expand_recurrences( + localtime(room.start_datetime), + room_recurrence_rule + ).between( + make_aware(datetime.combine(start_datetime.date(), time.min)), + make_aware(datetime.combine(end_datetime.date(), time.max)) + ) + for occurrence_start in room_occurrences: + occurrence_end = occurrence_start + duration + availability_slots.extend( + self._calculate_free_intervals( + localtime(occurrence_start), + localtime(occurrence_end), + booked_slots + )) + return availability_slots + + # Helper function to subtract booked slots from room availability + def _subtract_booked_from_room_availability(self, room_start, room_end, booked_slots): + booked_slots = sorted(booked_slots, key=lambda x: x[0]) + free_intervals = [] + current_start = room_start + for b_start, b_end in booked_slots: + # Skip bookings that end before current_start + if b_end <= current_start: + continue + # If booking starts after current_start, add the slot to free_intervals + if b_start > current_start: + free_intervals.append((current_start, min(b_start, room_end))) + # If b_end > current_start, move current_start forward + current_start = max(current_start, b_end) + # If current_start reaches room_end, end the loop + if current_start >= room_end: + break + # Add any remaining free interval after latest booking + if current_start < room_end: + free_intervals.append((current_start, room_end)) + return free_intervals + + # Helper function to get free intervals and trim past time + def _calculate_free_intervals(self, room_start, room_end, booked_slots): + # Filter only overlapping bookings + overlapping_bookings = [ + (b_start, b_end) + for b_start, b_end in booked_slots + if b_end > room_start and b_start < room_end + ] + # Subtract booked intervals + free_intervals = self._subtract_booked_from_room_availability( + room_start, room_end, overlapping_bookings + ) + # Trim past intervals + current_time = localtime(now()) + trimmed_free_intervals = [] + for fi_start, fi_end in free_intervals: + fi_start = localtime(fi_start) + fi_end = localtime(fi_end) + # If the interval is today, include only future times + if fi_start.date() == current_time.date(): + # Skip intervals that end in the past + if fi_end <= current_time: + continue + # Adjust fi_start to be current_time if it's in the past + fi_start = max(fi_start, current_time) + trimmed_free_intervals.append((fi_start, fi_end)) + return trimmed_free_intervals + class LocationViewSet(viewsets.ModelViewSet): queryset = Location.objects.all()