diff --git a/.gitignore b/.gitignore index eccd28e..b23b794 100644 --- a/.gitignore +++ b/.gitignore @@ -297,4 +297,6 @@ pyrightconfig.json opt/ # google api -server/api/booking/google_calendar/google_calendar_service.json \ No newline at end of file +server/api/booking/google_calendar/google_calendar_service.json + +server/media/ \ No newline at end of file diff --git a/server/api/room/README.md b/server/api/room/README.md new file mode 100644 index 0000000..581393f --- /dev/null +++ b/server/api/room/README.md @@ -0,0 +1,96 @@ +# Room API Endpoints + +## Authentication + +- **Read (GET):** Anyone can list or retrieve rooms. +- **Write (POST, PATCH, PUT):** Only authenticated users can create or update rooms. +- **Delete:** Not allowed (returns 405). + +--- + +## List Rooms + +**GET** `/api/rooms/` + +### Query Parameters (all optional): + +- `name`: Filter rooms by name (case-insensitive, partial match). +- `location`: Filter by location name (case-insensitive, partial match). +- `min_capacity`: Minimum capacity (integer). +- `max_capacity`: Maximum capacity (integer). +- `min_datetime`: Start datetime after or equal to (ISO 8601). +- `max_datetime`: End datetime before or equal to (ISO 8601). +- `amenity`: Filter by amenity name(s). Comma-separated for multiple. + Example: `?amenity=Projector,Whiteboard` + +**Pagination:** +Results are paginated (10 per page by default). Use `?page=2` for next page. + +**Example Request:** + +``` +GET /api/rooms/?name=meeting&location=Main&min_capacity=5&amenity=Projector,Whiteboard +``` + +--- + +## Retrieve Room + +**GET** `/api/rooms/{id}/` + +Returns details for a single room. + +--- + +## Create Room + +**POST** `/api/rooms/` +**Auth required** + +**Body Example:** + +```json +{ + "name": "Conference Room", + "location": 1, + "capacity": 20, + "amenities_id": [1, 2], + "start_datetime": "2025-12-11T09:00:00Z", + "end_datetime": "2025-12-11T18:00:00Z", + "recurrence_rule": "FREQ=DAILY;BYDAY=MO,TU,WE", + "is_active": true +} +``` + +--- + +## Update Room + +**PATCH/PUT** `/api/rooms/{id}/` +**Auth required** + +**Body:** Same as create. Partial updates allowed. + +--- + +## Delete Room + +**DELETE** `/api/rooms/{id}/` +**Not allowed** (returns 405 Method Not Allowed). + +--- + +## Notes + +- Unauthenticated users only see rooms where `is_active=true`. +- A room cannot be deleted but you can change its `is_active` +- Filtering by multiple amenities returns rooms that have **all** specified amenities. +- Validation: `end_datetime` must be after `start_datetime`. +- Validation: `recurrence_rule` must start with FREQ= and contain valid frequency + +--- + +## Related Endpoints + +- **Locations:** `/api/locations/` +- **Amenities:** `/api/amenities/` diff --git a/server/api/room/__init__.py b/server/api/room/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/room/admin.py b/server/api/room/admin.py new file mode 100644 index 0000000..043606a --- /dev/null +++ b/server/api/room/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from .models import Room, Location, Amenity +# Register your models here. + + +@admin.register(Room) +class RoomAdmin(admin.ModelAdmin): + list_display = ("id", "name", "location", + "start_datetime", "end_datetime", "recurrence_rule", "is_active") + search_fields = ("name", "location__name", "amenities__name") + list_display_links = ("name",) + + +@admin.register(Location) +class LocationAdmin(admin.ModelAdmin): + list_display = ("id", "name") + search_fields = ("name",) + list_display_links = ("name",) + + +@admin.register(Amenity) +class AmenitiesAdmin(admin.ModelAdmin): + list_display = ("id", "name") + search_fields = ("name",) + list_display_links = ("name",) diff --git a/server/api/room/apps.py b/server/api/room/apps.py new file mode 100644 index 0000000..ac48e88 --- /dev/null +++ b/server/api/room/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RoomConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.room" diff --git a/server/api/room/migrations/0001_initial.py b/server/api/room/migrations/0001_initial.py new file mode 100644 index 0000000..9635430 --- /dev/null +++ b/server/api/room/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.9 on 2025-12-10 13:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Amenities", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=32)), + ], + ), + migrations.CreateModel( + name="Location", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=64)), + ], + ), + migrations.CreateModel( + name="Room", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=32)), + ("img", models.ImageField(upload_to="room_images/")), + ("capacity", models.IntegerField()), + ("is_active", models.BooleanField(default=True)), + ("start_datetime", models.DateTimeField()), + ("end_datetime", models.DateTimeField()), + ("recurrence_rule", models.CharField(blank=True, max_length=64)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("amenities", models.ManyToManyField(blank=True, to="room.amenities")), + ( + "location", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="room.location" + ), + ), + ], + ), + ] diff --git a/server/api/room/migrations/0002_alter_room_capacity.py b/server/api/room/migrations/0002_alter_room_capacity.py new file mode 100644 index 0000000..a896d04 --- /dev/null +++ b/server/api/room/migrations/0002_alter_room_capacity.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.9 on 2025-12-10 13:48 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("room", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="room", + name="capacity", + field=models.PositiveIntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ] diff --git a/server/api/room/migrations/0003_alter_room_img.py b/server/api/room/migrations/0003_alter_room_img.py new file mode 100644 index 0000000..808121c --- /dev/null +++ b/server/api/room/migrations/0003_alter_room_img.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2025-12-10 14:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("room", "0002_alter_room_capacity"), + ] + + operations = [ + migrations.AlterField( + model_name="room", + name="img", + field=models.ImageField(blank=True, null=True, upload_to="room_images/"), + ), + ] diff --git a/server/api/room/migrations/0004_alter_room_location.py b/server/api/room/migrations/0004_alter_room_location.py new file mode 100644 index 0000000..398504e --- /dev/null +++ b/server/api/room/migrations/0004_alter_room_location.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.9 on 2025-12-10 15:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("room", "0003_alter_room_img"), + ] + + operations = [ + migrations.AlterField( + model_name="room", + name="location", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="room.location" + ), + ), + ] diff --git a/server/api/room/migrations/0005_alter_amenities_options_alter_location_options.py b/server/api/room/migrations/0005_alter_amenities_options_alter_location_options.py new file mode 100644 index 0000000..8524b43 --- /dev/null +++ b/server/api/room/migrations/0005_alter_amenities_options_alter_location_options.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.9 on 2025-12-11 16:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("room", "0004_alter_room_location"), + ] + + operations = [ + migrations.AlterModelOptions( + name="amenities", + options={"ordering": ["id"]}, + ), + migrations.AlterModelOptions( + name="location", + options={"ordering": ["id"]}, + ), + ] diff --git a/server/api/room/migrations/0006_rename_amenities_amenity.py b/server/api/room/migrations/0006_rename_amenities_amenity.py new file mode 100644 index 0000000..188a73e --- /dev/null +++ b/server/api/room/migrations/0006_rename_amenities_amenity.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.9 on 2025-12-11 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("room", "0005_alter_amenities_options_alter_location_options"), + ] + + operations = [ + migrations.RenameModel( + old_name="Amenities", + new_name="Amenity", + ), + ] diff --git a/server/api/room/migrations/__init__.py b/server/api/room/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/room/models.py b/server/api/room/models.py new file mode 100644 index 0000000..759da9a --- /dev/null +++ b/server/api/room/models.py @@ -0,0 +1,43 @@ +from django.db import models +from django.core.validators import MinValueValidator + + +class Location(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=64, blank=False) + + class Meta: + ordering = ['id'] + + def __str__(self): + return self.name + + +class Amenity(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=32, blank=False) + + class Meta: + ordering = ['id'] + + def __str__(self): + return self.name + + +class Room(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=32, blank=False) + img = models.ImageField(upload_to='room_images/', blank=True, null=True) + location = models.ForeignKey( + Location, on_delete=models.PROTECT, blank=False) + capacity = models.PositiveIntegerField(validators=[MinValueValidator(1)]) + amenities = models.ManyToManyField(Amenity, blank=True) + is_active = models.BooleanField(default=True) + start_datetime = models.DateTimeField(blank=False) + end_datetime = models.DateTimeField(blank=False) + recurrence_rule = models.CharField(max_length=64, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name diff --git a/server/api/room/serializers.py b/server/api/room/serializers.py new file mode 100644 index 0000000..6070d7e --- /dev/null +++ b/server/api/room/serializers.py @@ -0,0 +1,70 @@ +from rest_framework import serializers +from .models import Room, Amenity, Location +import re + + +class AmenitySerializer(serializers.ModelSerializer): + class Meta: + model = Amenity + fields = ["id", "name"] + read_only_fields = ['id'] + + +class LocationSerializer(serializers.ModelSerializer): + class Meta: + model = Location + fields = ["id", "name"] + read_only_fields = ['id'] + + +class RoomSerializer(serializers.ModelSerializer): + location = LocationSerializer(read_only=True) + amenities = AmenitySerializer(many=True, read_only=True) + location_id = serializers.PrimaryKeyRelatedField( + queryset=Location.objects.all(), write_only=True, source='location' + ) + amenities_id = serializers.PrimaryKeyRelatedField( + queryset=Amenity.objects.all(), many=True, write_only=True, source='amenities' + ) + + class Meta: + model = Room + fields = [ + "id", + "name", + "img", + "location", + "location_id", + "capacity", + "amenities", + "amenities_id", + "start_datetime", + "end_datetime", + "recurrence_rule", + "is_active", + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate(self, data): + start = data.get('start_datetime') or getattr( + self.instance, 'start_datetime', None) + end = data.get('end_datetime') or getattr( + self.instance, 'end_datetime', None) + recurrence_rule = data.get('recurrence_rule') or getattr( + self.instance, 'recurrence_rule', None) + + if start and end and end <= start: + raise serializers.ValidationError({ + 'end_datetime': 'End datetime must be after start datetime.' + }) + + # Validate recurrence_rule (Google Calendar RFC 5545 format) + if recurrence_rule: + # Basic RFC 5545 RRULE validation: must start with FREQ= and contain valid frequency + freq_pattern = r'^FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(;.*)?$' + if not re.match(freq_pattern, recurrence_rule): + raise serializers.ValidationError({ + 'recurrence_rule': 'Recurrence rule must start with FREQ= and use a valid frequency (DAILY, WEEKLY, MONTHLY, YEARLY).' + }) + + return data diff --git a/server/api/room/tests.py b/server/api/room/tests.py new file mode 100644 index 0000000..2b8bf8e --- /dev/null +++ b/server/api/room/tests.py @@ -0,0 +1,128 @@ +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from .models import Room, Location, Amenity +from django.utils import timezone + +User = get_user_model() + + +class RoomAPITest(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") + self.loc3 = Location.objects.create(name="Building C") + + # Amenities + self.amenity1 = Amenity.objects.create(name="Projector") + self.amenity2 = Amenity.objects.create(name="Whiteboard") + self.amenity4 = Amenity.objects.create(name="House") + + # Rooms + self.room1 = Room.objects.create( + name="Conference 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=DAILY;BYDAY=MO,TU,WE", + is_active=True + ) + self.room1.amenities.set([self.amenity1, self.amenity2]) + + self.room2 = Room.objects.create( + name="Meeting Room A", + location=self.loc3, + capacity=5, + start_datetime=timezone.make_aware( + timezone.datetime(2025, 11, 1, 10, 0)), + end_datetime=timezone.make_aware( + timezone.datetime(2025, 11, 1, 17, 0)), + recurrence_rule="FREQ=WEEKLY;BYDAY=MO,WE,FR", + is_active=False # Inactive room for unauthenticated test + ) + self.room2.amenities.set([self.amenity4]) + + # -------- LIST & FILTER TESTS -------- + def test_list_all_rooms_authenticated(self): + client = APIClient() + client.force_authenticate(user=self.abc) + response = client.get("/api/rooms/") + print("\nAll Rooms (Authenticated) Response:") + + print("Is authenticated:", response.wsgi_request.user.is_authenticated) + self.assertTrue(response.wsgi_request.user.is_authenticated) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 2) + + def test_list_only_active_rooms_unauthenticated(self): + self.client.logout() + response = self.client.get("/api/rooms/") + print("\nAll Rooms (Unauthenticated) Response:") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Only room1 is active + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], self.room1.id) + + def test_filter_rooms_by_name(self): + response = self.client.get("/api/rooms/?name=Conference Room 1") + print("\nFilter by Name Response:") + + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"] + [0]["name"], "Conference Room 1") + + def test_filter_rooms_by_location_name(self): + response = self.client.get("/api/rooms/?location=Building A") + print("\nFilter by location Name Response:") + + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"] + [0]["location"]["id"], self.loc1.id) + + # -------- RETRIEVE TEST -------- + def test_retrieve_room(self): + room = Room.objects.first() + response = self.client.get(f"/api/rooms/{room.id}/") + print("\nRetrieve Room Response:") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], room.id) + + # -------- UPDATE TEST -------- + def test_update_room(self): + client = APIClient() + client.force_authenticate(user=self.abc) + room = Room.objects.first() + response = client.patch( + f"/api/rooms/{room.id}/", + {"name": "Updated Room"}, + format="json" + ) + print("\nUpdate Room Response:") + print(response.content.decode()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], "Updated Room") + + # Confirm update + response = self.client.get(f"/api/rooms/{room.id}/") + self.assertEqual(response.data["name"], "Updated Room") + + # -------- DELETE TEST (should not be allowed) -------- + def test_delete_room_not_allowed(self): + client = APIClient() + client.force_authenticate(user=self.abc) + room = Room.objects.first() + response = client.delete(f"/api/rooms/{room.id}/") + print("\nDelete Room Response:") + print(response.content.decode()) + self.assertEqual(response.status_code, + status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/server/api/room/urls.py b/server/api/room/urls.py new file mode 100644 index 0000000..9872728 --- /dev/null +++ b/server/api/room/urls.py @@ -0,0 +1,14 @@ +from rest_framework.routers import DefaultRouter +from .views import RoomViewSet, LocationViewSet, AmenityViewSet +from django.urls import include, path + +app_name = 'room' + +router = DefaultRouter() +router.register(r'rooms', RoomViewSet, basename='rooms') +router.register(r'locations', LocationViewSet, basename='locations') +router.register(r'amenities', AmenityViewSet, basename='amenities') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/server/api/room/views.py b/server/api/room/views.py new file mode 100644 index 0000000..a88417a --- /dev/null +++ b/server/api/room/views.py @@ -0,0 +1,80 @@ +from rest_framework import viewsets, permissions +from rest_framework.exceptions import MethodNotAllowed +from .models import Room, Location, Amenity +from .serializers import RoomSerializer, LocationSerializer, AmenitySerializer + +# 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 + +# per issue thing: +# Update has custom response with id name updated_at +# Delete has custom response message + + +class RoomViewSet(viewsets.ModelViewSet): + queryset = Room.objects.all() + serializer_class = RoomSerializer + + def get_permissions(self): + if self.action in ["create", "update", "partial_update", "destroy"]: + return [permissions.IsAuthenticated()] + return [permissions.AllowAny()] + + def get_queryset(self): + qs = super().get_queryset() + params = self.request.query_params + + if name := params.get("name"): + qs = qs.filter(name__icontains=name) + + if location_name := params.get("location"): + qs = qs.filter(location__name__icontains=location_name) + + if min_cap := params.get("min_capacity"): + qs = qs.filter(capacity__gte=min_cap) + + if max_cap := params.get("max_capacity"): + qs = qs.filter(capacity__lte=max_cap) + + if min_datetime := params.get("min_datetime"): + qs = qs.filter(start_datetime__gte=min_datetime) + + if max_datetime := params.get("max_datetime"): + qs = qs.filter(end_datetime__lte=max_datetime) + + # Filter by amenity name (case-insensitive, supports multiple names comma-separated) + if amenity_names := params.get("amenity"): + names = [n.strip() for n in amenity_names.split(",") if n.strip()] + if names: + for n in names: + qs = qs.filter(amenities__name__iexact=n) + qs = qs.distinct() + + if not self.request.user.is_authenticated: + qs = qs.filter(is_active=True) + + return qs + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed("DELETE") + + +class LocationViewSet(viewsets.ModelViewSet): + queryset = Location.objects.all() + serializer_class = LocationSerializer + + def get_permissions(self): + if self.action in ["create", "update", "partial_update", "destroy"]: + return [permissions.IsAuthenticated()] + return [permissions.AllowAny()] + + +class AmenityViewSet(viewsets.ModelViewSet): + queryset = Amenity.objects.all() + serializer_class = AmenitySerializer + + def get_permissions(self): + if self.action in ["create", "update", "partial_update", "destroy"]: + return [permissions.IsAuthenticated()] + return [permissions.AllowAny()] diff --git a/server/api/settings.py b/server/api/settings.py index a135fe0..b3cc2ed 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -56,6 +56,7 @@ "api.healthcheck", "storages", "api.user", + "api.room", ] MIDDLEWARE = [ @@ -166,6 +167,8 @@ # STATIC_ROOT is where the static files get copied to when "collectstatic" is run. STATIC_ROOT = "static_files" +MEDIA_ROOT = BASE_DIR / "media" + # This is where to _find_ static files when 'collectstatic' is run. # These files are then copied to the STATIC_ROOT location. STATICFILES_DIRS = ("static",) @@ -178,6 +181,8 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 10, } AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") diff --git a/server/api/urls.py b/server/api/urls.py index 8ce33d9..841b150 100644 --- a/server/api/urls.py +++ b/server/api/urls.py @@ -16,6 +16,8 @@ """ from django.contrib import admin +from django.conf import settings +from django.conf.urls.static import static from django.urls import path, include from drf_spectacular.views import ( SpectacularAPIView, @@ -33,4 +35,9 @@ path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("api/users/", include(("api.user.urls"))), + path("api/", include(("api.room.urls"))), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT)