From 8347ca8129b07fd19694085cdfe12ed94d9e5872 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Mon, 11 Mar 2024 19:04:57 +0100 Subject: [PATCH] March update (#781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * created endpoint for listing and destroying orders * Infrastructure as Code using terraform (#684) * feat: initial terraform * chore: add basic documentation * chore: more documentation * Added IaC setup for Lepton * added checov github action * chekov allow softfail * chore: database name * hack to allow azure container apps managed certificates * small changes * tweeking values * chore: revert changes to makefile * chore: make format * chore: bump build actions (#755) * Feat(payment)/orders (#757) * added filtersearch * added filter * added filter and listing * Add status field to the ordering filter and fix retrieve method in OrderViewSet * Refactor order filters and views * Add is_index_user function to check if user is in Index * Refactor order factory and serializers, add update endpoint for orders * Add admin group user permission to order views and tests * added permission checks for order model and removed from order viewset (#760) * added permission checks for order model and removed from order viewset * format * fixed string representation for orders (#764) * removed bug that deleted paid event if event is updated. added more i… (#765) * removed bug that deleted paid event if event is updated. added more info to paid_event in adminpanel * format * Update CHANGELOG.md (#766) * Feat(kontres)/initial setup (#720) * Initial setup * Update settings.py * created draft for reservation class and state enum * config for kontres * created endpoint for creating new reservation * created serializer for create_reservation * added create_reservation to url path * added admin.py to implement admin panel logic * added __Str__ for admin panel * added seperate class for a bookable item * made "Kontoret" default value of a reservation for now and added migration * removed unnecessary code * added endpoint to edit a reservation, lacking request validation * added endpoint to fetch all reservations * updated urls.py with the newest endpoints * code cleanup * created model test for reservation class * added bookable item object to reservation serializer * created test for creating reservations * added clean and self to reservation class * added bookable item serializer * added som error handling to fetch_all_reservations.py * created endpoint to fetch reseervation by id * modifies urls.py to accomodate changes in endpoints regarding queries and arguments * removed the default value for bookable item in reservation * every new reservation will now automatically be pending * fetch_reservation.py now uses url argument instead of query parameter * fixed bug in reservation_seralizer.py regarding bookableitem id * fixed and added more tests in test_create_reservation.py * created pytests for editing a reservation * created pytests for fetching all reservations * created pytests for fetching a reservation by 1 * made toString method in reservation model cleaner * combined files to make one common reservation endpoint * adjusted tests to accommodate to endpoint url * transitioned to uuid for reservation and bookable item class * fixed tests to accommodate uuid * created endpoint to fetch all bookable items * modified urls.py to accommodate uuid and new endpoint * removed old and seperate endpoint files * added uuid to admin panel * added error handling in reservation view * initial commit * all new reservations will be pending * added test for bookable items * deleted old endpoint and url model * fixed code for pr * fixed kontres conventions * formatting * added queryset to reservation model * fixed imports * rename * re-migrated * formatting * moved tests to correct place, and refactored to use factories and conftest.py * added reservation and bookable item factories to conftest.py * created factories * fixed migration issues * fixed packaging location * removed comments * added read and write access specifications to reservation and bookable item model * added endpoint guards * fixed tests * formatting * fixed tests * refactored permission system * refactored reservation model with correct permission system * fixed viewset to accomodate new permission system * removed unnecessary test * fixed old tests to accomodate new permission system, as well as added new ones * added extensive validation logic to prevent overlapping reservations etc * formatting for pr * removed relative import * linting * removed necessary code * rewrote reservation queryset * made reservation factory use enums for state * removed necessary state validation * fixed tests * formatting * linting * Added new field to reservation model * Trigger Build * Trigger Build * format and closed INSTALLED_APPS list * closed urls list * fixed description model bug as result of git issues * added class methods to bookable_item model * reformated permission logic * removed uneccessary code * translated error messages to norwegian * reformated tests to fit new viewset and permission logic * linting * changed from write to update permission and added status code on reponse on update view * creating a reservation will now use userId from request, and ignore any other attempt * users are now unable to modify reservation after it has been confirmed. also fixed permission logic in update method * added tests to make sure users cannot change their reservation after is has been confirmed * linting --------- Co-authored-by: Frikk Balder <33499052+MindChirp@users.noreply.github.com> Co-authored-by: ConradOsvik Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Co-authored-by: Mads Nylund * LogEntry viewset and fix (#768) added viewset and serializer for logentry. Also remove date_hierachy in admin register for LogEntry * Feat(kontres)/add group to create reservation (#769) * fixed delete object permission * added group field to reservation model * added context to serializer * added group validation logic to serializer * created tests for group logic on reservation model * fixed seralizer complexity by splitting into methods * fixed time issue on tests * linting * fixed bug where bookable item was not properly inserted as payload in 2 tests * removed unnecessary assertion against database * fixed destroy logic in viewset and model * Feat(kontres)/return full objects in response (#770) * reservation response will now include the full objects for author, bookable_item and group * reservation response will now include the full objects for author, bookable_item and group * git aids * fixed some more git aids * fixed logic in serializer method for validating time (#771) * fixed logic in serializer method for validating time * skip unfinished test - waiting for updated viewset logic * Update pull_request_template.md (#774) * Fix(event)/fix priority waiting number (#775) * fix(kontres)/added basic viewset to bookable item (#773) * Removed SQL logging settings (#776) removed logging settings * fixed bug of payment countdown for registrations from waitlist to queue (#778) * Update CHANGELOG.md (#779) * Feat(kontres)/add endpoint for my reservations (#777) * added endpoint to fetch /me/reservations * added tests for /me/reservations endpoint * removed logging statement * admin can now fetch all reservations by user * created tests for admin fetching reservations by user --------- Co-authored-by: Martin Clementz Co-authored-by: Erik Skjellevik <98759397+eriskjel@users.noreply.github.com> Co-authored-by: Frikk Balder <33499052+MindChirp@users.noreply.github.com> Co-authored-by: ConradOsvik --- .github/pull_request_template.md | 7 +- CHANGELOG.md | 6 + app/content/admin/admin.py | 3 +- app/content/factories/__init__.py | 1 + app/content/factories/logentry_factory.py | 19 + app/content/models/registration.py | 50 +- app/content/serializers/content_type.py | 15 + app/content/serializers/logentry.py | 31 + app/content/urls.py | 2 + app/content/views/__init__.py | 1 + app/content/views/logentry.py | 39 + app/content/views/user.py | 11 + app/kontres/__init__.py | 0 app/kontres/admin.py | 16 + app/kontres/apps.py | 5 + app/kontres/enums.py | 7 + app/kontres/factories/__init__.py | 2 + .../factories/bookable_item_factory.py | 12 + app/kontres/factories/reservation_factory.py | 21 + app/kontres/migrations/0001_initial.py | 48 + ...remove_reservation_description_and_more.py | 22 + .../0003_reservation_description.py | 18 + .../migrations/0004_reservation_group.py | 26 + ...05_bookableitem_allows_alcohol_and_more.py | 57 ++ app/kontres/migrations/__init__.py | 0 app/kontres/models/__init__.py | 0 app/kontres/models/bookable_item.py | 48 + app/kontres/models/reservation.py | 105 ++ app/kontres/serializer/__init__.py | 0 .../serializer/bookable_item_serializer.py | 9 + .../serializer/reservation_seralizer.py | 152 +++ app/kontres/urls.py | 13 + app/kontres/views/__init__.py | 0 app/kontres/views/bookable_item.py | 12 + app/kontres/views/reservation.py | 66 ++ app/settings.py | 14 +- app/tests/conftest.py | 11 + app/tests/content/test_event_integration.py | 108 ++ .../content/test_logentry_integration.py | 89 ++ app/tests/kontres/__init__.py | 0 .../kontres/test_bookable_item_integration.py | 80 ++ .../kontres/test_reservation_integration.py | 930 ++++++++++++++++++ app/tests/kontres/test_reservation_model.py | 106 ++ app/urls.py | 1 + 44 files changed, 2136 insertions(+), 27 deletions(-) create mode 100644 app/content/factories/logentry_factory.py create mode 100644 app/content/serializers/content_type.py create mode 100644 app/content/serializers/logentry.py create mode 100644 app/content/views/logentry.py create mode 100644 app/kontres/__init__.py create mode 100644 app/kontres/admin.py create mode 100644 app/kontres/apps.py create mode 100644 app/kontres/enums.py create mode 100644 app/kontres/factories/__init__.py create mode 100644 app/kontres/factories/bookable_item_factory.py create mode 100644 app/kontres/factories/reservation_factory.py create mode 100644 app/kontres/migrations/0001_initial.py create mode 100644 app/kontres/migrations/0002_remove_reservation_description_and_more.py create mode 100644 app/kontres/migrations/0003_reservation_description.py create mode 100644 app/kontres/migrations/0004_reservation_group.py create mode 100644 app/kontres/migrations/0005_bookableitem_allows_alcohol_and_more.py create mode 100644 app/kontres/migrations/__init__.py create mode 100644 app/kontres/models/__init__.py create mode 100644 app/kontres/models/bookable_item.py create mode 100644 app/kontres/models/reservation.py create mode 100644 app/kontres/serializer/__init__.py create mode 100644 app/kontres/serializer/bookable_item_serializer.py create mode 100644 app/kontres/serializer/reservation_seralizer.py create mode 100644 app/kontres/urls.py create mode 100644 app/kontres/views/__init__.py create mode 100644 app/kontres/views/bookable_item.py create mode 100644 app/kontres/views/reservation.py create mode 100644 app/tests/content/test_logentry_integration.py create mode 100644 app/tests/kontres/__init__.py create mode 100644 app/tests/kontres/test_bookable_item_integration.py create mode 100644 app/tests/kontres/test_reservation_integration.py create mode 100644 app/tests/kontres/test_reservation_model.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 547f276fd..b8a9767b3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,15 +1,14 @@ ## Proposed changes -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. +Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. Remove this part with the description. -Issue number: closes # +Issue number: closes # (remove if not an issue) ## Pull request checklist Please check if your PR fulfills the following requirements: -- [ ] CHANGELOG.md has been updated. [Guide](https://tihlde.slab.com/posts/changelog-z8hybjom) - [ ] Tests for the changes have been added (for bug fixes / features) -- [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) +- [ ] API docs on [Codex](https://codex.tihlde.org/contributing) have been reviewed and added / updated if needed (for bug fixes / features) - [ ] The fixtures have been updated if needed (for migrations) ## Further comments diff --git a/CHANGELOG.md b/CHANGELOG.md index a11b22d62..b526f705c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ ## Neste versjon +## Versjon 2023.03.11 +- 🦟 **Vipps** Brukere som kommer fra venteliste vil nå få en payment countdown startet, slik at de blir kastet ut hvis de ikke betaler. +- ⚡ **Venteliste** Brukere vil nå se sin reelle ventelisteplass som tar hensyn til prioriteringer. +- 🎨 **Logging** SQL Debug for pytest er skrudd av. +- ✨ **Kontres** Endepunkter for reservasjoner av utstyr og kontor. + ## Versjon 2023.02.07 - 🦟 **Vipps** Brukere kan nå oppdatere betalt arrangement, uten at det betalte arrangementet blir slettet. diff --git a/app/content/admin/admin.py b/app/content/admin/admin.py index 92b9d8e6d..8afc11f84 100644 --- a/app/content/admin/admin.py +++ b/app/content/admin/admin.py @@ -206,7 +206,8 @@ def has_delete_permission(self, request, obj=None): class LogEntryAdmin(admin.ModelAdmin): actions = None - date_hierarchy = "action_time" + # This breaks the admin panel becaause of the new DB is not configured + # date_hierarchy = "action_time" list_filter = ["user", "content_type", "action_flag"] diff --git a/app/content/factories/__init__.py b/app/content/factories/__init__.py index 780110ec0..5c27302ed 100644 --- a/app/content/factories/__init__.py +++ b/app/content/factories/__init__.py @@ -10,3 +10,4 @@ from app.content.factories.toddel_factory import ToddelFactory from app.content.factories.priority_pool_factory import PriorityPoolFactory from app.content.factories.qr_code_factory import QRCodeFactory +from app.content.factories.logentry_factory import LogEntryFactory diff --git a/app/content/factories/logentry_factory.py b/app/content/factories/logentry_factory.py new file mode 100644 index 000000000..18023d0fc --- /dev/null +++ b/app/content/factories/logentry_factory.py @@ -0,0 +1,19 @@ +from django.contrib.admin.models import LogEntry +from django.utils import timezone + +import factory +from factory.django import DjangoModelFactory + +from app.content.factories.user_factory import UserFactory + + +class LogEntryFactory(DjangoModelFactory): + class Meta: + model = LogEntry + + action_time = timezone.now() + user = factory.SubFactory(UserFactory) + content_type = None + object_id = 1 + object_repr = "Test" + action_flag = 1 diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 1ccda13c4..795bdc9c2 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -132,10 +132,7 @@ def delete(self, *args, **kwargs): if moved_registration: moved_registration.save() - if ( - moved_registration.event.is_paid_event - and not moved_registration.is_on_wait - ): + if moved_registration.event.is_paid_event: try: start_payment_countdown( moved_registration.event, moved_registration @@ -294,20 +291,41 @@ def is_prioritized(self): @property def wait_queue_number(self): - """ - Returns the number of people in front of the user in the waiting list. - """ - waiting_list_count = ( - self.event.get_waiting_list() - .order_by("-created_at") - .filter(created_at__lte=self.created_at) - .count() - ) - - if waiting_list_count == 0 or not self.is_on_wait: + # Return None if the user is not on the waitlist to indicate they are not waiting for a spot. + if not self.is_on_wait: return None - return waiting_list_count + # Retrieve all registrations for the event that are on the waitlist and order them by creation time. + waiting_list_registrations = self.event.registrations.filter( + is_on_wait=True + ).order_by("created_at") + + # Separate the waiting list registrations into prioritized and non-prioritized groups. + prioritized_registrations = [ + reg for reg in waiting_list_registrations if reg.is_prioritized + ] + non_prioritized_registrations = [ + reg for reg in waiting_list_registrations if not reg.is_prioritized + ] + + # If the registration is prioritized, calculate its queue position among other prioritized registrations. + if self.is_prioritized: + if self in prioritized_registrations: + queue_position = prioritized_registrations.index(self) + 1 + else: + return None + else: + # For non-prioritized registrations, calculate queue position considering all prioritized registrations first. + if self in non_prioritized_registrations: + queue_position = ( + len(prioritized_registrations) + + non_prioritized_registrations.index(self) + + 1 + ) + else: + return None + + return queue_position def swap_users(self): """Swaps a user with a spot with a prioritized user, if such user exists""" diff --git a/app/content/serializers/content_type.py b/app/content/serializers/content_type.py new file mode 100644 index 000000000..bd8422761 --- /dev/null +++ b/app/content/serializers/content_type.py @@ -0,0 +1,15 @@ +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers + +from app.common.serializers import BaseModelSerializer + + +class ContentTypeSerializer(BaseModelSerializer): + app_label_name = serializers.SerializerMethodField() + + class Meta: + model = ContentType + fields = ("app_label_name",) + + def get_app_label_name(self, obj): + return obj.app_labeled_name diff --git a/app/content/serializers/logentry.py b/app/content/serializers/logentry.py new file mode 100644 index 000000000..9977272ab --- /dev/null +++ b/app/content/serializers/logentry.py @@ -0,0 +1,31 @@ +from django.contrib.admin.models import LogEntry +from rest_framework import serializers + +from app.common.serializers import BaseModelSerializer +from app.content.serializers.content_type import ContentTypeSerializer +from app.content.serializers.user import SimpleUserSerializer + + +class LogEntryListSerializer(BaseModelSerializer): + user = SimpleUserSerializer(many=False) + content_type = ContentTypeSerializer(many=False) + action_flag = serializers.SerializerMethodField() + + class Meta: + model = LogEntry + fields = ( + "action_time", + "user", + "content_type", + "object_id", + "object_repr", + "action_flag", + ) + + def get_action_flag(self, obj): + if obj.is_addition(): + return "ADDITION" + if obj.is_change(): + return "CHANGE" + if obj.is_deletion(): + return "DELETION" diff --git a/app/content/urls.py b/app/content/urls.py index 3ac574c31..a1f515085 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -5,6 +5,7 @@ CategoryViewSet, CheatsheetViewSet, EventViewSet, + LogEntryViewSet, NewsViewSet, PageViewSet, QRCodeViewSet, @@ -40,6 +41,7 @@ ) router.register("pages", PageViewSet) router.register("strikes", StrikeViewSet, basename="strikes") +router.register("log-entries", LogEntryViewSet, basename="log-entries") urlpatterns = [ re_path(r"", include(router.urls)), diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 2e48c38ae..9a89d3abc 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -12,3 +12,4 @@ from app.content.views.strike import StrikeViewSet from app.content.views.toddel import ToddelViewSet from app.content.views.qr_code import QRCodeViewSet +from app.content.views.logentry import LogEntryViewSet diff --git a/app/content/views/logentry.py b/app/content/views/logentry.py new file mode 100644 index 000000000..4bc968c88 --- /dev/null +++ b/app/content/views/logentry.py @@ -0,0 +1,39 @@ +from django.contrib.admin.models import LogEntry +from rest_framework.response import Response + +from app.common.mixins import ActionMixin +from app.common.pagination import BasePagination +from app.common.permissions import AdminGroup, check_has_access +from app.common.viewsets import BaseViewSet +from app.content.serializers.logentry import LogEntryListSerializer + + +class LogEntryViewSet(BaseViewSet, ActionMixin): + serializer_class = LogEntryListSerializer + pagination_class = BasePagination + queryset = LogEntry.objects.all() + + def list(self, request, *args, **kwargs): + if check_has_access(AdminGroup.admin(), request): + return super().list(request, *args, **kwargs) + + return Response({"detail": "Du har ikke tilgang til å se loggen."}, status=403) + + def retrieve(self, request, *args, **kwargs): + if check_has_access(AdminGroup.admin(), request): + return super().retrieve(request, *args, **kwargs) + + return Response({"detail": "Du har ikke tilgang til å se loggen."}, status=403) + + def create(self, request, *args, **kwargs): + return Response({"detail": "Du har ikke tilgang til å logge."}, status=403) + + def update(self, request, *args, **kwargs): + return Response( + {"detail": "Du har ikke tilgang til å oppdatere loggen."}, status=403 + ) + + def destroy(self, request, *args, **kwargs): + return Response( + {"detail": "Du har ikke tilgang til å slette loggen."}, status=403 + ) diff --git a/app/content/views/user.py b/app/content/views/user.py index c1d8ba67e..e0edc6770 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -37,6 +37,8 @@ MembershipHistorySerializer, MembershipSerializer, ) +from app.kontres.models.reservation import Reservation +from app.kontres.serializer.reservation_seralizer import ReservationSerializer from app.util.export_user_data import export_user_data from app.util.utils import CaseInsensitiveBooleanQueryParam @@ -378,3 +380,12 @@ def export_user_data(self, request, *args, **kwargs): {"detail": "Noe gikk galt, prøv igjen senere eller kontakt Index"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + @action(detail=False, methods=["get"], url_path="me/reservations") + def get_user_reservations(self, request, *args, **kwargs): + user = request.user + reservations = Reservation.objects.filter(author=user).order_by("start_time") + serializer = ReservationSerializer( + reservations, many=True, context={"request": request} + ) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/app/kontres/__init__.py b/app/kontres/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/kontres/admin.py b/app/kontres/admin.py new file mode 100644 index 000000000..2b650b44f --- /dev/null +++ b/app/kontres/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from app.kontres.models.bookable_item import BookableItem +from app.kontres.models.reservation import Reservation + + +class ReservationAdmin(admin.ModelAdmin): + readonly_fields = ("id",) + + +class BookableItemAdmin(admin.ModelAdmin): + readonly_fields = ("id",) + + +admin.site.register(Reservation, ReservationAdmin) +admin.site.register(BookableItem, BookableItemAdmin) diff --git a/app/kontres/apps.py b/app/kontres/apps.py new file mode 100644 index 000000000..5261dbe51 --- /dev/null +++ b/app/kontres/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class KontResConfig(AppConfig): + name = "app.kontres" diff --git a/app/kontres/enums.py b/app/kontres/enums.py new file mode 100644 index 000000000..44fc16700 --- /dev/null +++ b/app/kontres/enums.py @@ -0,0 +1,7 @@ +from django.db import models + + +class ReservationStateEnum(models.TextChoices): + PENDING = "PENDING" + CONFIRMED = "CONFIRMED" + CANCELLED = "CANCELLED" diff --git a/app/kontres/factories/__init__.py b/app/kontres/factories/__init__.py new file mode 100644 index 000000000..c3683ff60 --- /dev/null +++ b/app/kontres/factories/__init__.py @@ -0,0 +1,2 @@ +from app.kontres.factories.bookable_item_factory import BookableItemFactory +from app.kontres.factories.reservation_factory import ReservationFactory diff --git a/app/kontres/factories/bookable_item_factory.py b/app/kontres/factories/bookable_item_factory.py new file mode 100644 index 000000000..7accd2ee5 --- /dev/null +++ b/app/kontres/factories/bookable_item_factory.py @@ -0,0 +1,12 @@ +from factory import Faker, Sequence +from factory.django import DjangoModelFactory + +from app.kontres.models.bookable_item import BookableItem + + +class BookableItemFactory(DjangoModelFactory): + class Meta: + model = BookableItem + + name = Sequence(lambda n: f"Item_{n}") + description = Faker("text") diff --git a/app/kontres/factories/reservation_factory.py b/app/kontres/factories/reservation_factory.py new file mode 100644 index 000000000..4415f0713 --- /dev/null +++ b/app/kontres/factories/reservation_factory.py @@ -0,0 +1,21 @@ +from django.utils import timezone + +from factory import Faker, SubFactory +from factory.django import DjangoModelFactory + +from app.content.factories import UserFactory +from app.kontres.enums import ReservationStateEnum +from app.kontres.factories.bookable_item_factory import BookableItemFactory +from app.kontres.models.reservation import Reservation + + +class ReservationFactory(DjangoModelFactory): + class Meta: + model = Reservation + + author = SubFactory(UserFactory) + bookable_item = SubFactory(BookableItemFactory) + start_time = timezone.now() + timezone.timedelta(hours=1) + end_time = timezone.now() + timezone.timedelta(hours=2) + state = ReservationStateEnum.PENDING + description = Faker("text") diff --git a/app/kontres/migrations/0001_initial.py b/app/kontres/migrations/0001_initial.py new file mode 100644 index 000000000..843cf660c --- /dev/null +++ b/app/kontres/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.0.8 on 2023-10-25 13:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BookableItem', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=20)), + ('description', models.TextField(blank=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Reservation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('state', models.CharField(choices=[('PENDING', 'Pending'), ('CONFIRMED', 'Confirmed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=15)), + ('description', models.TextField(blank=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to=settings.AUTH_USER_MODEL)), + ('bookable_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reservations', to='kontres.bookableitem')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/app/kontres/migrations/0002_remove_reservation_description_and_more.py b/app/kontres/migrations/0002_remove_reservation_description_and_more.py new file mode 100644 index 000000000..385be8fbe --- /dev/null +++ b/app/kontres/migrations/0002_remove_reservation_description_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2024-02-01 16:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("kontres", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="reservation", + name="description", + ), + migrations.AddField( + model_name="reservation", + name="accepted_rules", + field=models.BooleanField(default=True), + ), + ] diff --git a/app/kontres/migrations/0003_reservation_description.py b/app/kontres/migrations/0003_reservation_description.py new file mode 100644 index 000000000..e817ac481 --- /dev/null +++ b/app/kontres/migrations/0003_reservation_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2024-02-07 11:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("kontres", "0002_remove_reservation_description_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="reservation", + name="description", + field=models.TextField(blank=True), + ), + ] diff --git a/app/kontres/migrations/0004_reservation_group.py b/app/kontres/migrations/0004_reservation_group.py new file mode 100644 index 000000000..ba8e3f583 --- /dev/null +++ b/app/kontres/migrations/0004_reservation_group.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2024-02-21 19:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0018_fine_defense"), + ("kontres", "0003_reservation_description"), + ] + + operations = [ + migrations.AddField( + model_name="reservation", + name="group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reservations", + to="group.group", + ), + ), + ] diff --git a/app/kontres/migrations/0005_bookableitem_allows_alcohol_and_more.py b/app/kontres/migrations/0005_bookableitem_allows_alcohol_and_more.py new file mode 100644 index 000000000..904308254 --- /dev/null +++ b/app/kontres/migrations/0005_bookableitem_allows_alcohol_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.5 on 2024-03-05 18:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("kontres", "0004_reservation_group"), + ] + + operations = [ + migrations.AddField( + model_name="bookableitem", + name="allows_alcohol", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="reservation", + name="alcohol_agreement", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="reservation", + name="sober_watch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sober_watch_reservations", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="reservation", + name="author", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reservations", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="reservation", + name="bookable_item", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reservations", + to="kontres.bookableitem", + ), + ), + ] diff --git a/app/kontres/migrations/__init__.py b/app/kontres/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/kontres/models/__init__.py b/app/kontres/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/kontres/models/bookable_item.py b/app/kontres/models/bookable_item.py new file mode 100644 index 000000000..6ad0ece1c --- /dev/null +++ b/app/kontres/models/bookable_item.py @@ -0,0 +1,48 @@ +import uuid + +from django.db import models + +from app.common.enums import AdminGroup, Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.util.models import BaseModel + + +class BookableItem(BaseModel, BasePermissionModel): + write_access = AdminGroup.admin() + read_access = [Groups.TIHLDE] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=20) + description = models.TextField(blank=True) + allows_alcohol = models.BooleanField(default=False) + + @classmethod + def has_read_permission(cls, request): + return check_has_access(cls.read_access, request) + + @classmethod + def has_retrieve_permission(cls, request): + return check_has_access(cls.read_access, request) + + @classmethod + def has_destroy_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_update_permission(cls, request): + return check_has_access(cls.write_access, request) + + def has_object_destroy_permission(self, request): + return self.check_has_admin_permission(request) + + def has_object_update_permission(self, request): + return self.check_has_admin_permission(request) + + def check_has_admin_permission(self, request): + return check_has_access([AdminGroup.INDEX, AdminGroup.HS], request) + + def __str__(self): + return self.name diff --git a/app/kontres/models/reservation.py b/app/kontres/models/reservation.py new file mode 100644 index 000000000..1ceb5af59 --- /dev/null +++ b/app/kontres/models/reservation.py @@ -0,0 +1,105 @@ +import uuid + +from django.db import models + +from app.common.enums import AdminGroup, Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.content.models import User +from app.group.models import Group +from app.kontres.enums import ReservationStateEnum +from app.kontres.models.bookable_item import BookableItem +from app.util.models import BaseModel + + +class Reservation(BaseModel, BasePermissionModel): + read_access = [Groups.TIHLDE] + write_access = [Groups.TIHLDE] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + author = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="reservations", + null=True, + blank=False, + ) + bookable_item = models.ForeignKey( + BookableItem, + on_delete=models.SET_NULL, + related_name="reservations", + null=True, + blank=False, + ) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + state = models.CharField( + max_length=15, + choices=ReservationStateEnum.choices, + default=ReservationStateEnum.PENDING, + ) + description = models.TextField(blank=True) + accepted_rules = models.BooleanField(default=True) + group = models.ForeignKey( + Group, + on_delete=models.SET_NULL, + related_name="reservations", + null=True, + blank=True, + ) + alcohol_agreement = models.BooleanField(default=False) + sober_watch = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="sober_watch_reservations", + null=True, + blank=True, + ) + + def __str__(self): + return f"{self.state} - Reservation request by {self.author.first_name} {self.author.last_name} to book {self.bookable_item.name}. Created at {self.created_at}" + + @classmethod + def has_read_permission(cls, request): + return check_has_access(cls.read_access, request) + + @classmethod + def has_retrieve_permission(cls, request): + return check_has_access(cls.read_access, request) + + @classmethod + def has_update_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_destroy_permission(cls, request): + return check_has_access(cls.write_access, request) + + def has_object_destroy_permission(self, request): + is_owner = self.author == request.user + is_admin = check_has_access([AdminGroup.INDEX, AdminGroup.HS], request) + return is_owner or is_admin + + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + + def has_object_update_permission(self, request): + allowed_groups = [AdminGroup.INDEX, AdminGroup.HS] + is_admin = check_has_access(allowed_groups, request) + + if ( + self.is_own_reservation(request) and "state" not in request.data + ) or is_admin: + return True + + if self.state == ReservationStateEnum.CONFIRMED and not is_admin: + return False + + # If trying to change the state, then check for admin permissions. + if "state" in request.data: + if request.data["state"] != self.state: + return check_has_access(allowed_groups, request) + + return False + + def is_own_reservation(self, request): + return self.author == request.user diff --git a/app/kontres/serializer/__init__.py b/app/kontres/serializer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/kontres/serializer/bookable_item_serializer.py b/app/kontres/serializer/bookable_item_serializer.py new file mode 100644 index 000000000..96bff199d --- /dev/null +++ b/app/kontres/serializer/bookable_item_serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from app.kontres.models.bookable_item import BookableItem + + +class BookableItemSerializer(serializers.ModelSerializer): + class Meta: + model = BookableItem + fields = "__all__" diff --git a/app/kontres/serializer/reservation_seralizer.py b/app/kontres/serializer/reservation_seralizer.py new file mode 100644 index 000000000..c178c1dcc --- /dev/null +++ b/app/kontres/serializer/reservation_seralizer.py @@ -0,0 +1,152 @@ +from django.db.models import Q +from django.utils import timezone +from rest_framework import serializers + +from app.content.models import User +from app.content.serializers import UserSerializer +from app.group.models import Group +from app.group.serializers import GroupSerializer +from app.kontres.enums import ReservationStateEnum +from app.kontres.models.bookable_item import BookableItem +from app.kontres.models.reservation import Reservation +from app.kontres.serializer.bookable_item_serializer import ( + BookableItemSerializer, +) + + +class ReservationSerializer(serializers.ModelSerializer): + bookable_item = serializers.PrimaryKeyRelatedField( + queryset=BookableItem.objects.all(), write_only=True, required=False + ) + bookable_item_detail = BookableItemSerializer( + source="bookable_item", read_only=True + ) + group = serializers.PrimaryKeyRelatedField( + queryset=Group.objects.all(), write_only=True, required=False + ) + group_detail = GroupSerializer(source="group", read_only=True) + + author = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), write_only=True, required=False + ) + author_detail = UserSerializer(source="author", read_only=True) + + class Meta: + model = Reservation + fields = "__all__" + + def validate(self, data): + user = self.context["request"].user + group = data.get("group", None) + + bookable_item = ( + data.get("bookable_item") + if "bookable_item" in data + else self.instance.bookable_item + ) + + if group: + self.validate_group(group) + + if bookable_item.allows_alcohol: + self.validate_alcohol(data) + + self.validate_state_change(data, user) + self.validate_time_and_overlapping(data) + return data + + def validate_alcohol(self, data): + if not data.get( + "alcohol_agreement", + self.instance.alcohol_agreement if self.instance else False, + ): + raise serializers.ValidationError( + "Du må godta at dere vil følge reglene for alkoholbruk." + ) + sober_watch = data.get( + "sober_watch", self.instance.sober_watch if self.instance else None + ) + if ( + not sober_watch + or not User.objects.filter(user_id=sober_watch.user_id).exists() + ): + raise serializers.ValidationError( + "Du må velge en edruvakt for reservasjonen." + ) + + def validate_group(self, value): + user = self.context["request"].user + group = value + + if self.instance and group != self.instance.group: + if ( + not user.is_HS_or_Index_member + and self.instance.state != ReservationStateEnum.PENDING + ): + raise serializers.ValidationError( + "Du har ikke tilgang til å endre gruppen til denne reservasjonsforespørselen." + ) + + if group and not user.is_member_of(group): + raise serializers.ValidationError( + f"Du er ikke medlem av {group.slug} og kan dermed ikke legge inn bestilling på deres vegne." + ) + + return group + + def validate_state_change(self, data, user): + # Validate the state change permission + if "state" in data: + if self.instance and data["state"] != self.instance.state: + if not (user and user.is_authenticated and user.is_HS_or_Index_member): + raise serializers.ValidationError( + { + "state": "Du har ikke rettigheter til å endre reservasjonsstatusen." + } + ) + pass + + def validate_time_and_overlapping(self, data): + + # Check if this is an update operation and if start_time is being modified. + is_update_operation = self.instance is not None + start_time_being_modified = "start_time" in data + + # Retrieve the start and end times from the data if provided, else from the instance. + start_time = data.get( + "start_time", self.instance.start_time if self.instance else None + ) + end_time = data.get( + "end_time", self.instance.end_time if self.instance else None + ) + + # Skip the past start time check if this is an update and the start time isn't being modified. + if not (is_update_operation and not start_time_being_modified): + if start_time < timezone.now(): + raise serializers.ValidationError( + "Start-tiden kan ikke være i fortiden." + ) + + # Ensure the end time is after the start time for all operations. + if start_time and end_time and end_time <= start_time: + raise serializers.ValidationError("Slutt-tid må være etter start-tid") + bookable_item = data.get( + "bookable_item", self.instance.bookable_item if self.instance else None + ) + # Check for overlapping reservations only if necessary fields are present + if bookable_item and start_time and end_time: + # Build the query for overlapping reservations + overlapping_reservations_query = Q( + bookable_item=bookable_item, + end_time__gt=start_time, + start_time__lt=end_time, + ) + # Exclude the current instance if updating + if self.instance: + overlapping_reservations_query &= ~Q(pk=self.instance.pk) + # Check for overlapping reservations + if Reservation.objects.filter(overlapping_reservations_query).exists(): + raise serializers.ValidationError( + "Det er en reservasjonsoverlapp for det gitte tidsrommet." + ) + pass diff --git a/app/kontres/urls.py b/app/kontres/urls.py new file mode 100644 index 000000000..f00e8baf5 --- /dev/null +++ b/app/kontres/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from app.kontres.views.bookable_item import BookableItemViewSet +from app.kontres.views.reservation import ReservationViewSet + +router = DefaultRouter() +router.register(r"reservations", ReservationViewSet, basename="reservation") +router.register(r"bookable_items", BookableItemViewSet, basename="bookable_item") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/app/kontres/views/__init__.py b/app/kontres/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/kontres/views/bookable_item.py b/app/kontres/views/bookable_item.py new file mode 100644 index 000000000..246937ca3 --- /dev/null +++ b/app/kontres/views/bookable_item.py @@ -0,0 +1,12 @@ +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.kontres.models.bookable_item import BookableItem +from app.kontres.serializer.bookable_item_serializer import ( + BookableItemSerializer, +) + + +class BookableItemViewSet(BaseViewSet): + queryset = BookableItem.objects.all() + serializer_class = BookableItemSerializer + permission_classes = [BasicViewPermission] diff --git a/app/kontres/views/reservation.py b/app/kontres/views/reservation.py new file mode 100644 index 000000000..49e67e441 --- /dev/null +++ b/app/kontres/views/reservation.py @@ -0,0 +1,66 @@ +from django.db.models import Q +from django.utils.dateparse import parse_datetime +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.kontres.enums import ReservationStateEnum +from app.kontres.models.reservation import Reservation +from app.kontres.serializer.reservation_seralizer import ReservationSerializer + + +class ReservationViewSet(BaseViewSet): + permission_classes = [BasicViewPermission] + serializer_class = ReservationSerializer + + def get_queryset(self): + start_date = self.request.GET.get("start_date") + end_date = self.request.GET.get("end_date") + user_id = self.request.query_params.get("user_id") + queryset = Reservation.objects.all() + + if start_date: + start_date = parse_datetime(start_date) + if end_date: + end_date = parse_datetime(end_date) + + if start_date and end_date: + queryset = Reservation.objects.filter( + Q(start_time__lt=end_date) & Q(end_time__gt=start_date) + ) + return queryset + + if user_id: + if self.request.user.is_HS_or_Index_member: + queryset = queryset.filter(author__user_id=user_id) + else: + raise PermissionDenied( + "Du har ikke tilgang til å se andres reservasjoner." + ) + + return queryset + + def create(self, request, *args, **kwargs): + serializer = ReservationSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + serializer.validated_data["author"] = request.user + serializer.validated_data["state"] = ReservationStateEnum.PENDING + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + reservation = self.get_object() + serializer = self.get_serializer(reservation, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + super().destroy(self, request, *args, **kwargs) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/settings.py b/app/settings.py index 49137f7ba..9427eee6a 100644 --- a/app/settings.py +++ b/app/settings.py @@ -99,6 +99,7 @@ "app.gallery", "app.badge", "app.payment", + "app.kontres", "app.emoji", ] @@ -271,12 +272,13 @@ "formatter": "verbose", }, }, - "loggers": { - "django": { - "propagate": True, - "level": "DEBUG", - }, - }, + # REMOVE COMMENTS TO ADD SQL LOGGING + # "loggers": { + # "django": { + # "propagate": True, + # "level": "DEBUG", + # }, + # }, "root": { "handlers": ["file"], }, diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 38c7ee57e..02d22d5ed 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -32,6 +32,7 @@ from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.membership_factory import MembershipHistoryFactory +from app.kontres.factories import BookableItemFactory, ReservationFactory from app.payment.factories.order_factory import OrderFactory from app.payment.factories.paid_event_factory import PaidEventFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -243,6 +244,16 @@ def toddel(): return ToddelFactory() +@pytest.fixture() +def bookable_item(): + return BookableItemFactory() + + +@pytest.fixture() +def reservation(): + return ReservationFactory() + + @pytest.fixture() def news_reaction(member, news): return NewsReactionFactory(user=member, content_object=news) diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index 12c508bfd..a4b4d3f29 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -12,6 +12,7 @@ from app.forms.tests.form_factories import EventFormFactory from app.group.factories import GroupFactory from app.group.models import Group +from app.tests.conftest import _add_user_to_group from app.util import now from app.util.test_utils import ( add_user_to_group_with_name, @@ -978,3 +979,110 @@ def test_create_paid_event(api_client, admin_user): assert data["is_paid_event"] assert data["paid_information"]["price"] == "200.00" assert data["paid_information"]["paytime"] == "01:00:00" + + +@pytest.mark.django_db +def test_wait_queue_number_for_prioritized_registration( + event_with_priority_pool, user_in_priority_pool, member, priority_group +): + prioritized_user_1 = UserFactory() + _add_user_to_group(prioritized_user_1, priority_group) + + prioritized_user_2 = UserFactory() + _add_user_to_group(prioritized_user_2, priority_group) + + prioritized_user_3 = UserFactory() + _add_user_to_group(prioritized_user_3, priority_group) + + RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_1, is_on_wait=True + ) + second_prioritized_registration = RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_2, is_on_wait=True + ) + third_prioritized_registration = RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_3, is_on_wait=True + ) + + assert second_prioritized_registration.wait_queue_number == 1 + assert third_prioritized_registration.wait_queue_number == 2 + + +@pytest.mark.django_db +def test_wait_queue_number_respects_priority_pools( + event_with_priority_pool, user_in_priority_pool, member, priority_group +): + prioritized_user_0 = UserFactory() + _add_user_to_group(prioritized_user_0, priority_group) + RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_0, is_on_wait=False + ) + + non_prioritized_registration = RegistrationFactory( + event=event_with_priority_pool, user=member, is_on_wait=True + ) + + prioritized_user_2 = UserFactory() + _add_user_to_group(prioritized_user_2, priority_group) + prioritized_user_3 = UserFactory() + _add_user_to_group(prioritized_user_3, priority_group) + + second_prioritized_registration = RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_2, is_on_wait=True + ) + third_prioritized_registration = RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_3, is_on_wait=True + ) + + assert second_prioritized_registration.wait_queue_number == 1 + assert third_prioritized_registration.wait_queue_number == 2 + assert non_prioritized_registration.wait_queue_number == 3 + + +@pytest.mark.django_db +def test_prioritized_users_always_ahead_of_non_prioritized( + event_with_priority_pool, priority_group +): + non_prioritized_users = [ + UserFactory() for _ in range(2) + ] # Create 2 non-prioritized users + prioritized_users = [UserFactory() for _ in range(2)] # Create 2 prioritized users + + # simulate the event being filled + prioritized_user_0 = UserFactory() + _add_user_to_group(prioritized_user_0, priority_group) + RegistrationFactory( + event=event_with_priority_pool, user=prioritized_user_0, is_on_wait=False + ) + + # Assign users to priority group and register them + for user in prioritized_users: + _add_user_to_group(user, priority_group) + + # Non-prioritized users register first and are placed on the waitlist + for user in non_prioritized_users: + RegistrationFactory(event=event_with_priority_pool, user=user, is_on_wait=True) + + # Prioritized users register after and are also placed on the waitlist + for user in prioritized_users: + RegistrationFactory(event=event_with_priority_pool, user=user, is_on_wait=True) + + # Fetch registrations that are specifically on the waitlist and prioritize accordingly + waitlist_registrations = event_with_priority_pool.registrations.filter( + is_on_wait=True + ).order_by("created_at") + + # Extract wait queue numbers for prioritized and non-prioritized users on the waitlist + prioritized_wait_numbers = [ + reg.wait_queue_number for reg in waitlist_registrations if reg.is_prioritized + ] + non_prioritized_wait_numbers = [ + reg.wait_queue_number + for reg in waitlist_registrations + if not reg.is_prioritized + ] + + # Ensure all prioritized users have lower wait queue numbers than any non-prioritized user + assert all( + p_num < min(non_prioritized_wait_numbers) for p_num in prioritized_wait_numbers + ), "Prioritized users do not all precede non-prioritized users in the wait queue" diff --git a/app/tests/content/test_logentry_integration.py b/app/tests/content/test_logentry_integration.py new file mode 100644 index 000000000..0e9e6829b --- /dev/null +++ b/app/tests/content/test_logentry_integration.py @@ -0,0 +1,89 @@ +from rest_framework import status + +import pytest + +from app.content.factories import LogEntryFactory +from app.util.test_utils import get_api_client + +API_EVENTS_BASE_URL = "/log-entries/" + + +@pytest.mark.django_db +def test_logentry_list(admin_user): + """ + An admin should be able to list log entries. + """ + client = get_api_client(user=admin_user) + response = client.get(API_EVENTS_BASE_URL) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_logentry_list_no_access(user): + """ + An user should not be able to list log entries. + """ + client = get_api_client(user=user) + response = client.get(API_EVENTS_BASE_URL) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_logentry_retrieve(admin_user): + """ + An admin should be able to retrieve a log entry. + """ + log = LogEntryFactory() + client = get_api_client(user=admin_user) + response = client.get(f"{API_EVENTS_BASE_URL}{log.id}/") + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_logentry_retrieve_no_access(user): + """ + An user should not be able to retrieve a log entry. + """ + log = LogEntryFactory() + client = get_api_client(user=user) + response = client.get(f"{API_EVENTS_BASE_URL}{log.id}/") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_logentry_create(admin_user): + """ + An admin should not be able to create a log entry. + """ + client = get_api_client(user=admin_user) + response = client.post(API_EVENTS_BASE_URL, data={}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_logentry_update(admin_user): + """ + An admin should not be able to update a log entry. + """ + log = LogEntryFactory() + client = get_api_client(user=admin_user) + response = client.put(f"{API_EVENTS_BASE_URL}{log.id}/", data={}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_logentry_destroy(admin_user): + """ + An admin should not be able to destroy a log entry. + """ + log = LogEntryFactory() + client = get_api_client(user=admin_user) + response = client.delete(f"{API_EVENTS_BASE_URL}{log.id}/") + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/app/tests/kontres/__init__.py b/app/tests/kontres/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/tests/kontres/test_bookable_item_integration.py b/app/tests/kontres/test_bookable_item_integration.py new file mode 100644 index 000000000..ec1cbab9c --- /dev/null +++ b/app/tests/kontres/test_bookable_item_integration.py @@ -0,0 +1,80 @@ +from rest_framework import status + +import pytest + +from app.util.test_utils import get_api_client + + +@pytest.mark.django_db +def test_unauthenticated_request_cannot_create_bookable_item(): + client = get_api_client() + response = client.post("/kontres/bookable_items/", {"name": "test"}, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_admin_can_delete_bookable_item(admin_user, bookable_item): + client = get_api_client(user=admin_user) + response = client.delete( + f"/kontres/bookable_items/{bookable_item.id}/", format="json" + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_member_cannot_delete_bookable_item(member, bookable_item): + client = get_api_client(user=member) + response = client.delete( + f"/kontres/bookable_items/{bookable_item.id}/", format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_delete_bookable_item_sets_reservation_bookable_item_to_null( + admin_user, bookable_item, reservation +): + # Ensure the bookable_item is part of the reservation + reservation.bookable_item = bookable_item + reservation.save() + + client = get_api_client(user=admin_user) + response = client.delete( + f"/kontres/bookable_items/{bookable_item.id}/", format="json" + ) + + # Refresh the reservation from the database to check the updated state + reservation.refresh_from_db() + + # The deletion should succeed + assert response.status_code == 204, "Expected successful deletion of bookable item." + + # After deletion, the reservation's bookable_item should be set to null + assert ( + reservation.bookable_item is None + ), "Expected reservation.bookable_item to be set to null after bookable item deletion." + + +@pytest.mark.django_db +def test_delete_bookable_item_with_invalid_id(admin_user): + client = get_api_client(user=admin_user) + invalid_id = 99999 + response = client.delete(f"/kontres/bookable_items/{invalid_id}/", format="json") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_member_cannot_edit_bookable_item(member, bookable_item): + client = get_api_client(user=member) + response = client.put("/kontres/bookable_items/", {"name": "test"}, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_admin_can_edit_bookable_item(admin_user, bookable_item): + client = get_api_client(user=admin_user) + response = client.put( + f"/kontres/bookable_items/{bookable_item.id}/", {"name": "test"}, format="json" + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "test" diff --git a/app/tests/kontres/test_reservation_integration.py b/app/tests/kontres/test_reservation_integration.py new file mode 100644 index 000000000..5666ab2f2 --- /dev/null +++ b/app/tests/kontres/test_reservation_integration.py @@ -0,0 +1,930 @@ +from datetime import timedelta + +from django.utils import timezone +from rest_framework import status + +import pytest + +from app.common.enums import AdminGroup +from app.group.factories import GroupFactory +from app.kontres.enums import ReservationStateEnum +from app.kontres.factories.bookable_item_factory import BookableItemFactory +from app.kontres.factories.reservation_factory import ReservationFactory +from app.kontres.models.bookable_item import BookableItem +from app.kontres.models.reservation import Reservation +from app.tests.conftest import _add_user_to_group +from app.util.test_utils import get_api_client + + +@pytest.mark.django_db +def test_member_can_create_reservation(member, bookable_item): + client = get_api_client(user=member) + + response = client.post( + "/kontres/reservations/", + { + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + }, + format="json", + ) + + assert response.status_code == 201 + assert response.data["author_detail"]["user_id"] == str(member.user_id) + assert response.data["bookable_item_detail"]["id"] == str(bookable_item.id) + assert response.data["state"] == "PENDING" + + +@pytest.mark.django_db +def test_member_can_create_reservation_with_alcohol_agreement(member, bookable_item): + client = get_api_client(user=member) + + bookable_item.allows_alcohol = True + bookable_item.save() + + response = client.post( + "/kontres/reservations/", + { + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + "alcohol_agreement": True, + "sober_watch": member.user_id, + }, + format="json", + ) + + assert response.status_code == 201, response.data + assert response.data.get("alcohol_agreement") is True + assert response.data.get("sober_watch") == str(member.user_id) + + +@pytest.mark.django_db +def test_reservation_creation_fails_without_alcohol_agreement(member, bookable_item): + client = get_api_client(user=member) + + bookable_item.allows_alcohol = True + bookable_item.save() + + response = client.post( + "/kontres/reservations/", + { + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + # Notice the absence of "alcohol_agreement": True, + "sober_watch": member.user_id, + }, + format="json", + ) + + assert response.status_code == 400 + expected_error_message = "Du må godta at dere vil følge reglene for alkoholbruk." + actual_error_messages = response.data.get("non_field_errors", []) + assert any( + expected_error_message in error for error in actual_error_messages + ), f"Expected specific alcohol agreement validation error: {expected_error_message}" + + +@pytest.mark.django_db +def test_reservation_creation_fails_without_sober_watch(member, bookable_item): + client = get_api_client(user=member) + + bookable_item.allows_alcohol = True + bookable_item.save() + + response = client.post( + "/kontres/reservations/", + { + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + "alcohol_agreement": True, + # Notice the absence of "sober_watch", + }, + format="json", + ) + + assert response.status_code == 400 + expected_error_message = "Du må velge en edruvakt for reservasjonen." + actual_error_messages = response.data.get("non_field_errors", []) + assert any( + expected_error_message in error for error in actual_error_messages + ), f"Expected specific alcohol agreement validation error: {expected_error_message}" + + +@pytest.mark.django_db +def test_member_cannot_set_different_author_in_reservation( + member, bookable_item, sosialen_user +): + client = get_api_client(user=member) + + # Attempt to create a reservation with a different author specified in the request body + response = client.post( + "/kontres/reservations/", + { + "author": sosialen_user.user_id, + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + }, + format="json", + ) + + # Check that the reservation is created successfully + assert response.status_code == 201 + + # Check that the author of the reservation is actually the requesting user + assert response.data["author_detail"]["user_id"] == member.user_id + assert response.data["author_detail"]["user_id"] != "different_user_id" + + # Check other attributes of the reservation + assert response.data["bookable_item_detail"]["id"] == str(bookable_item.id) + assert response.data["state"] == "PENDING" + + +@pytest.mark.django_db +def test_non_tihlde_cannot_create_reservation(user, bookable_item): + client = get_api_client(user=user) + + response = client.post( + "/kontres/reservations/", + { + "author": user.user_id, + "bookable_item": bookable_item.id, + "start_time": "2025-10-10T10:00:00Z", + "end_time": "2025-10-10T11:00:00Z", + }, + format="json", + ) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_creating_reservation_with_past_start_time(member, bookable_item): + client = get_api_client(user=member) + past_time = timezone.now() - timezone.timedelta(days=1) + response = client.post( + "/kontres/reservations/", + { + "author": member.user_id, + "bookable_item": bookable_item.id, + "start_time": past_time, + "end_time": timezone.now(), + }, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_member_deleting_own_reservation(member, reservation): + reservation.author = member + reservation.save() + client = get_api_client(user=member) + response = client.delete(f"/kontres/reservations/{reservation.id}/", format="json") + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_member_cannot_update_random_reservation(member, reservation): + client = get_api_client(user=member) + + new_description = "Updated description" + response = client.patch( + f"/kontres/reservations/{reservation.id}/", + {"description": new_description}, + format="json", + ) + + assert response.status_code == 403 + assert "description" not in response.data + + +@pytest.mark.django_db +def test_user_cannot_create_confirmed_reservation(bookable_item, member): + client = get_api_client(user=member) + + # Set start_time to one hour from the current time + start_time = timezone.now() + timedelta(hours=1) + # Set end_time to two hours from the current time + end_time = timezone.now() + timedelta(hours=2) + + response = client.post( + "/kontres/reservations/", + { + "author": member.user_id, + "bookable_item": bookable_item.id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "state": "CONFIRMED", + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["state"] == "PENDING" + + +@pytest.mark.django_db +def test_user_cannot_create_reservation_with_invalid_date_format(member, bookable_item): + client = get_api_client(user=member) + response = client.post( + "/kontres/reservations/", + { + "author": member.user_id, + "bookable_item": bookable_item.id, + "start_time": "invalid_date_format", + "end_time": "2023-10-10T11:00:00Z", + }, + format="json", + ) + + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_admin_can_edit_reservation_to_confirmed(reservation, admin_user): + client = get_api_client(user=admin_user) + + assert reservation.state == ReservationStateEnum.PENDING + + response = client.put( + f"/kontres/reservations/{reservation.id}/", + {"state": "CONFIRMED"}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["state"] == ReservationStateEnum.CONFIRMED + + +@pytest.mark.django_db +def test_admin_can_edit_reservation_to_cancelled(reservation, admin_user): + client = get_api_client(user=admin_user) + + response = client.put( + f"/kontres/reservations/{reservation.id}/", + {"state": "CANCELLED"}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["state"] == "CANCELLED" + + +@pytest.mark.django_db +def test_updating_reservation_with_valid_times(member, reservation): + + reservation.author = member + reservation.save() + client = get_api_client(user=member) + + start_time = timezone.now() + timedelta(hours=1) + end_time = timezone.now() + timedelta(hours=2) + + response = client.put( + f"/kontres/reservations/{reservation.id}/", + { + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + }, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + # Parse the response times as timezone-aware datetimes + response_start_time = timezone.datetime.fromisoformat(response.data["start_time"]) + response_end_time = timezone.datetime.fromisoformat(response.data["end_time"]) + + # Ensure that the response_end_time is greater than response_start_time + assert response_end_time > response_start_time + + +@pytest.mark.django_db +def test_admin_cannot_edit_nonexistent_reservation(admin_user): + client = get_api_client(user=admin_user) + + nonexistent_uuid = "123e4567-e89b-12d3-a456-426655440000" + response = client.put( + f"/kontres/reservations/{nonexistent_uuid}/", + {"state": "CONFIRMED"}, + format="json", + ) + + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_user_can_fetch_all_reservations(reservation, member): + client = get_api_client(user=member) + + reservations = [reservation] + for _ in range(2): + additional_reservation = ReservationFactory() + reservations.append(additional_reservation) + + response = client.get("/kontres/reservations/", format="json") + + assert response.status_code == 200 + assert len(response.data) == 3 + + first_reservation = Reservation.objects.first() + assert str(response.data[0]["id"]) == str(first_reservation.id) + assert ( + response.data[0]["author_detail"]["user_id"] == first_reservation.author.user_id + ) + assert response.data[0]["bookable_item_detail"]["id"] == str( + first_reservation.bookable_item.id + ) + assert response.data[0]["state"] == "PENDING" + + +@pytest.mark.django_db +def test_can_fetch_all_bookable_items(bookable_item, member): + client = get_api_client(user=member) + + bookable_items = [bookable_item] + for _ in range(2): + additional_bookable_item = BookableItemFactory() + bookable_items.append(additional_bookable_item) + + response = client.get("/kontres/bookable_items/", format="json") + + assert response.status_code == 200 + assert len(response.data) == 3 + + first_bookable_item = BookableItem.objects.first() + assert str(response.data[0]["id"]) == str(first_bookable_item.id) + assert response.data[0]["name"] == first_bookable_item.name + + +@pytest.mark.django_db +def test_user_can_fetch_bookable_items_when_none_exist(member): + client = get_api_client(user=member) + response = client.get("/kontres/bookable_items/", format="json") + + assert response.status_code == 200, response + + +@pytest.mark.django_db +def test_can_fetch_single_reservation(reservation, member): + client = get_api_client(user=member) + response = client.get(f"/kontres/reservations/{reservation.id}/", format="json") + + assert response.status_code == 200 + assert str(response.data["id"]) == str(reservation.id) + assert response.data["author_detail"]["user_id"] == reservation.author.user_id + assert str(response.data["bookable_item_detail"]["id"]) == str( + reservation.bookable_item.id + ) # Convert both to string + assert response.data["state"] == "PENDING" + + +@pytest.mark.django_db +def test_user_cannot_fetch_nonexistent_reservation(member): + client = get_api_client(user=member) + + non_existent_uuid = "12345678-1234-5678-1234-567812345678" + response = client.get(f"/kontres/reservations/{non_existent_uuid}/", format="json") + + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_admin_can_delete_any_reservation(admin_user, reservation): + client = get_api_client(user=admin_user) + response = client.delete( + f"/kontres/reservations/{reservation.id}/", + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_user_cannot_edit_others_reservation(user, reservation): + client = get_api_client(user=user) + reservation_id = str(reservation.id) + response = client.put( + f"/kontres/reservations/{reservation_id}/", + {"description": "New Description"}, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_user_cannot_delete_others_reservation(user, reservation): + client = get_api_client(user=user) + reservation_id = str(reservation.id) + response = client.delete( + f"/kontres/reservations/{reservation_id}/", + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_admin_cannot_set_invalid_reservation_state(member, reservation): + client = get_api_client(user=member, group_name=AdminGroup.INDEX) + reservation_id = str(reservation.id) + response = client.put( + f"/kontres/reservations/{reservation_id}/", + {"state": "INVALID_STATE"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_member_cannot_set_own_reservation_to_invalid_state(member, reservation): + reservation.author = member + reservation.save() + client = get_api_client(user=member) + reservation_id = str(reservation.id) + response = client.put( + f"/kontres/reservations/{reservation_id}/", + {"state": "INVALID_STATE"}, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_user_cannot_create_reservation_with_end_time_before_start_time( + member, bookable_item +): + client = get_api_client(user=member) + response = client.post( + "/kontres/reservations/", + { + "author": member.user_id, + "bookable_item": bookable_item.id, + "start_time": "2025-10-10T12:00:00Z", + "end_time": "2023-10-10T11:00:00Z", + }, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_user_can_update_own_reservation_details(member, reservation): + reservation.author = member + reservation.save() + client = get_api_client(user=member) + reservation_id = str(reservation.id) + new_description = "Updated Description" + response = client.put( + f"/kontres/reservations/{reservation_id}/", + {"description": new_description}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["description"] == new_description + + +@pytest.mark.django_db +def test_unauthenticated_request_cannot_create_reservation(bookable_item): + client = get_api_client() + response = client.post( + "/kontres/reservations/", + { + "bookable_item": bookable_item.id, + "start_time": "2025-10-10T10:00:00Z", + "end_time": "2025-10-10T11:00:00Z", + }, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_creating_overlapping_reservation(member, bookable_item, admin_user): + # Create a confirmed reservation using the ReservationFactory + existing_confirmed_reservation = ReservationFactory( + bookable_item=bookable_item, + start_time=timezone.now() + timezone.timedelta(hours=1), + end_time=timezone.now() + timezone.timedelta(hours=2), + state=ReservationStateEnum.CONFIRMED, # Set the reservation as confirmed + ) + + # Now attempt to create an overlapping reservation + client = get_api_client(user=member) + overlapping_start_time = ( + existing_confirmed_reservation.start_time + timezone.timedelta(minutes=30) + ) + response = client.post( + "/kontres/reservations/", + { + "author": member.user_id, + "bookable_item": bookable_item.id, + "start_time": overlapping_start_time, + "end_time": existing_confirmed_reservation.end_time + + timezone.timedelta(hours=1), + "state": ReservationStateEnum.PENDING, + }, + format="json", + ) + + # The system should not allow this, as it overlaps with a confirmed reservation + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_retrieve_specific_reservation_within_its_date_range(member, bookable_item): + client = get_api_client(user=member) + + # Create a reservation with the current time + reservation = ReservationFactory( + author=member, + bookable_item=bookable_item, + start_time=timezone.now(), + end_time=timezone.now() + timezone.timedelta(hours=1), + ) + + # Broaden the query time range significantly for debugging + start_time = reservation.start_time - timezone.timedelta(hours=1) + end_time = reservation.end_time + timezone.timedelta(hours=1) + + # Format the start and end times in ISO 8601 format + start_time_iso = start_time.isoformat() + end_time_iso = end_time.isoformat() + + response = client.get( + f"/kontres/reservations/?start_date={start_time_iso}&end_date={end_time_iso}" + ) + + assert response.status_code == status.HTTP_200_OK + assert any(res["id"] == str(reservation.id) for res in response.data) + + +@pytest.mark.skip +@pytest.mark.django_db +def test_retrieve_subset_of_reservations(member, bookable_item): + client = get_api_client(user=member) + + # Create three reservations with different times + # Use current time as a base to ensure consistency + current_time = timezone.now() + + times = [ + ( + current_time.replace(hour=10, minute=0, second=0, microsecond=0), + current_time.replace(hour=11, minute=0, second=0, microsecond=0), + ), + ( + current_time.replace(hour=10, minute=0, second=0, microsecond=0) + + timedelta(days=1), + current_time.replace(hour=11, minute=0, second=0, microsecond=0) + + timedelta(days=1), + ), + ( + current_time.replace(hour=10, minute=0, second=0, microsecond=0) + + timedelta(days=2), + current_time.replace(hour=11, minute=0, second=0, microsecond=0) + + timedelta(days=2), + ), + ] + + for start_time, end_time in times: + client.post( + "/kontres/reservations/", + { + "author": member.user_id, + "bookable_item": bookable_item.id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + }, + format="json", + ) + + from django.utils.timezone import get_current_timezone + + # Example of formatting the datetime with timezone information + query_start_date = ( + current_time.replace(hour=9, minute=0, second=0, microsecond=0) + .astimezone(get_current_timezone()) + .isoformat() + ) + + query_end_date = ( + current_time.replace( + hour=9, minute=0, second=0, microsecond=0, day=current_time.day + 1 + ) + .astimezone(get_current_timezone()) + .isoformat() + ) + + # Retrieve reservations for the specific date range + response = client.get( + f"/kontres/reservations/?start_date={query_start_date}&end_date={query_end_date}" + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + +@pytest.mark.django_db +def test_admin_can_update_confirmed_reservation_state(admin_user, reservation): + client = get_api_client(user=admin_user) + # Set the reservation state to CONFIRMED and save + reservation.state = ReservationStateEnum.CONFIRMED + reservation.save() + + new_state = "CANCELLED" + + response = client.put( + f"/kontres/reservations/{reservation.id}/", + {"state": new_state}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["state"] == new_state + + +@pytest.mark.django_db +def test_user_cannot_update_confirmed_reservation(member, reservation): + client = get_api_client(user=member) + # Confirm the reservation before the test + reservation.state = ReservationStateEnum.CONFIRMED + reservation.save() + + response = client.patch( + f"/kontres/reservations/{reservation.id}/", + {"description": "Updated description"}, + format="json", + ) + + # Assuming 403 is the status code for a forbidden action + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_member_can_update_own_reservation(member, reservation): + client = get_api_client(user=member) + + reservation.author = member + reservation.save() + + new_description = "Updated description" + response = client.patch( + f"/kontres/reservations/{reservation.id}/", + {"description": new_description}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["description"] == new_description + + +@pytest.mark.django_db +def test_admin_can_update_details_of_confirmed_reservation(admin_user, reservation): + client = get_api_client(user=admin_user) + + reservation.state = ReservationStateEnum.CONFIRMED + reservation.save() + + new_description = "New details after confirmation" + response = client.patch( + f"/kontres/reservations/{reservation.id}/", + {"description": new_description}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["description"] == new_description + + +@pytest.mark.django_db +def test_user_can_change_reservation_group(member, reservation): + # Setup: Create two groups and add the user to both + original_group = GroupFactory() + new_group = GroupFactory() + _add_user_to_group(member, original_group) + _add_user_to_group(member, new_group) + + # Assign the original group to the reservation and save + reservation.group = original_group + reservation.author = member + reservation.save() + + # Prepare the client and attempt to update the reservation's group + client = get_api_client(user=member) + reservation_id = str(reservation.id) + response = client.put( + f"/kontres/reservations/{reservation_id}/", + { + "group": new_group.slug, + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK, response.data + assert response.data["group_detail"]["slug"] == new_group.slug + + +@pytest.mark.django_db +def test_user_can_create_reservation_for_group(member, bookable_item, group): + client = get_api_client(user=member) + + _add_user_to_group(member, group) + + response = client.post( + "/kontres/reservations/", + { + "group": group.slug, + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + }, + format="json", + ) + + assert response.status_code == 201 + + +@pytest.mark.django_db +def test_user_cannot_create_reservation_for_group_if_not_member_of_group( + member, bookable_item, group +): + client = get_api_client(user=member) + + response = client.post( + "/kontres/reservations/", + { + "group": group.slug, + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + }, + format="json", + ) + + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_user_cannot_create_reservation_for_another_group(member, bookable_item): + client = get_api_client(user=member) + + group1 = GroupFactory() + group2 = GroupFactory() + + _add_user_to_group(member, group1) + + response = client.post( + "/kontres/reservations/", + { + "group": group2.slug, + "bookable_item": bookable_item.id, + "start_time": "2030-10-10T10:00:00Z", + "end_time": "2030-10-10T11:00:00Z", + }, + format="json", + ) + + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_user_can_change_reservation_group_if_state_is_pending(member, reservation): + original_group = GroupFactory() + new_group = GroupFactory() + _add_user_to_group(member, original_group) + _add_user_to_group(member, new_group) + + reservation.group = original_group + reservation.author = member + reservation.save() + + client = get_api_client(user=member) + reservation_id = str(reservation.id) + response = client.put( + f"/kontres/reservations/{reservation_id}/", + { + "group": new_group.slug, + }, + format="json", + ) + + # Verify the response + assert response.status_code == status.HTTP_200_OK, response.data + assert response.data["group_detail"]["slug"] == new_group.slug + + +@pytest.mark.django_db +def test_user_cannot_change_reservation_group_if_state_is_not_pending( + member, reservation +): + original_group = GroupFactory() + new_group = GroupFactory() + _add_user_to_group(member, original_group) + _add_user_to_group(member, new_group) + + reservation.group = original_group + reservation.author = member + reservation.state = ReservationStateEnum.CONFIRMED + reservation.save() + + client = get_api_client(user=member) + reservation_id = str(reservation.id) + response = client.put( + f"/kontres/reservations/{reservation_id}/", + { + "group": new_group.slug, + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_user_can_fetch_own_reservations(member, reservation): + client = get_api_client(user=member) + + reservation.author = member + reservation.save() + + response = client.get("/users/me/reservations/") + + assert response.status_code == 200 + assert all( + reservation["author_detail"]["user_id"] == str(member.user_id) + for reservation in response.data + ) + + +@pytest.mark.django_db +def test_user_reservations_endpoint_returns_correct_reservations( + member, bookable_item, reservation +): + client = get_api_client(user=member) + + Reservation.objects.bulk_create( + [ + Reservation( + author=member, + bookable_item=bookable_item, + start_time=f"2030-10-10T1{num}:00:00Z", + end_time=f"2030-10-10T1{num + 1}:00:00Z", + description=f"Test reservation {num}", + ) + for num in range(3) + ] + ) + + response = client.get("/users/me/reservations/") + + assert response.status_code == 200 + assert len(response.data) == 3 + + fixture_reservation_id = str(reservation.id) + returned_reservation_ids = [res["id"] for res in response.data] + assert fixture_reservation_id not in returned_reservation_ids + + +@pytest.mark.django_db +def test_admin_can_fetch_reservations_for_specific_user( + admin_user, member, bookable_item +): + client = get_api_client(user=admin_user) + + Reservation.objects.bulk_create( + [ + Reservation( + author=member, + bookable_item=bookable_item, + start_time=f"2030-10-{10 + num}T10:00:00Z", + end_time=f"2030-10-{10 + num}T11:00:00Z", + description=f"Member's reservation {num}", + ) + for num in range(3) # Create 3 reservations for the member + ] + ) + + created_reservations = Reservation.objects.filter(author=member).order_by( + "start_time" + ) + created_reservation_ids = {str(res.id) for res in created_reservations} + + response = client.get(f"/kontres/reservations/?user_id={member.user_id}") + + assert response.status_code == 200 + assert len(response.data) == 3 + + response_reservation_ids = {res["id"] for res in response.data} + + assert created_reservation_ids == response_reservation_ids + + +@pytest.mark.django_db +def test_member_cannot_fetch_reservations_for_specific_user(member): + client = get_api_client(user=member) + + response = client.get(f"/kontres/reservations/?user_id={member.user_id}") + + assert response.status_code == 403 diff --git a/app/tests/kontres/test_reservation_model.py b/app/tests/kontres/test_reservation_model.py new file mode 100644 index 000000000..727288828 --- /dev/null +++ b/app/tests/kontres/test_reservation_model.py @@ -0,0 +1,106 @@ +from django.utils import timezone + +import pytest + +from app.content.models import User +from app.kontres.models.bookable_item import BookableItem +from app.kontres.models.reservation import Reservation, ReservationStateEnum + + +@pytest.fixture() +def reservation(): + user = User.objects.create(user_id="test_user") + bookable_item = BookableItem.objects.create(name="Test Item") + return Reservation.objects.create( + author=user, + bookable_item=bookable_item, + start_time=timezone.now(), + end_time=timezone.now() + timezone.timedelta(hours=1), + ) + + +@pytest.mark.django_db +def test_reservation_defaults_to_pending(reservation): + assert reservation.state == ReservationStateEnum.PENDING + + +@pytest.mark.django_db +def test_reservation_start_and_end_time(): + user = User.objects.create(user_id="test_user", email="test@test.com") + bookable_item = BookableItem.objects.create(name="Test Item") + start_time = timezone.now() + end_time = start_time + timezone.timedelta(hours=1) + reservation = Reservation.objects.create( + author=user, + bookable_item=bookable_item, + start_time=start_time, + end_time=end_time, + ) + assert reservation.start_time == start_time + assert reservation.end_time == end_time + + +@pytest.mark.django_db +def test_state_transitions(reservation): + """Should correctly transition between states.""" + + # Start with a PENDING reservation + assert reservation.state == ReservationStateEnum.PENDING + + # Move to CONFIRMED + reservation.state = ReservationStateEnum.CONFIRMED + reservation.save() + assert reservation.state == ReservationStateEnum.CONFIRMED + + # Move to CANCELLED + reservation.state = ReservationStateEnum.CANCELLED + reservation.save() + assert reservation.state == ReservationStateEnum.CANCELLED + + +@pytest.mark.django_db +def test_created_at_field(): + user = User.objects.create(user_id="test_user", email="test@test.com") + bookable_item = BookableItem.objects.create(name="Test Item") + reservation = Reservation.objects.create( + author=user, + bookable_item=bookable_item, + start_time=timezone.now(), + end_time=timezone.now() + timezone.timedelta(hours=1), + ) + assert reservation.created_at is not None + + +@pytest.mark.django_db +def test_multiple_reservations(): + user1 = User.objects.create(user_id="test_user_1", email="test1@test.com") + user2 = User.objects.create(user_id="test_user_2", email="test2@test.com") + bookable_item = BookableItem.objects.create(name="Test Item") + reservation1 = Reservation.objects.create( + author=user1, + bookable_item=bookable_item, + start_time=timezone.now(), + end_time=timezone.now() + timezone.timedelta(hours=1), + ) + reservation2 = Reservation.objects.create( + author=user2, + bookable_item=bookable_item, + start_time=timezone.now(), + end_time=timezone.now() + timezone.timedelta(hours=1), + ) + assert reservation1 is not None + assert reservation2 is not None + + +@pytest.mark.django_db +def test_reservation_with_group(group): + user = User.objects.create(user_id="test_user") + bookable_item = BookableItem.objects.create(name="Test Item") + reservation = Reservation.objects.create( + author=user, + bookable_item=bookable_item, + start_time=timezone.now(), + end_time=timezone.now() + timezone.timedelta(hours=1), + group=group, + ) + assert reservation.group == group diff --git a/app/urls.py b/app/urls.py index 9dd10e08b..3e8029cd9 100644 --- a/app/urls.py +++ b/app/urls.py @@ -31,5 +31,6 @@ path("forms/", include("app.forms.urls")), path("galleries/", include("app.gallery.urls")), path("badges/", include("app.badge.urls")), + path("kontres/", include("app.kontres.urls")), path("emojis/", include("app.emoji.urls")), ]