diff --git a/config/settings.py b/config/settings.py index 8bc3e869e..f1db7bd0a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -496,6 +496,7 @@ def CACHES(cls): REMOVE_RECURRING_RESERVATIONS_OLDER_THAN_DAYS = 1 REMOVE_EXPIRED_APPLICATIONS_OLDER_THAN_DAYS = 365 TEXT_SEARCH_CACHE_TIME_DAYS = 30 + USER_IS_ADULT_AT_AGE = 18 APPLICATION_ROUND_RESERVATION_CREATION_TIMEOUT_MINUTES = values.IntegerValue(default=10) AFFECTING_TIME_SPANS_UPDATE_INTERVAL_MINUTES = values.IntegerValue(default=2) diff --git a/tests/factories/reservation_unit.py b/tests/factories/reservation_unit.py index 097bf342d..faf1cd67c 100644 --- a/tests/factories/reservation_unit.py +++ b/tests/factories/reservation_unit.py @@ -98,6 +98,7 @@ class Meta: is_draft = False is_archived = False require_introduction = False + require_adult_reservee = False require_reservation_handling = False reservation_block_whole_day = False can_apply_free_of_charge = False diff --git a/tests/test_graphql_api/test_reservation/test_create.py b/tests/test_graphql_api/test_reservation/test_create.py index a94f46b4f..6e428123e 100644 --- a/tests/test_graphql_api/test_reservation/test_create.py +++ b/tests/test_graphql_api/test_reservation/test_create.py @@ -6,6 +6,7 @@ import freezegun import pytest +from freezegun import freeze_time from graphene_django_extensions.testing import parametrize_helper from tilavarauspalvelu.enums import ( @@ -18,7 +19,14 @@ from tilavarauspalvelu.models import Reservation, ReservationUnitHierarchy from tilavarauspalvelu.utils.helauth.clients import HelsinkiProfileClient from tilavarauspalvelu.utils.helauth.typing import ADLoginAMR -from utils.date_utils import DEFAULT_TIMEZONE, local_datetime, local_end_of_day, local_start_of_day, next_hour +from utils.date_utils import ( + DEFAULT_TIMEZONE, + local_date, + local_datetime, + local_end_of_day, + local_start_of_day, + next_hour, +) from utils.decimal_utils import round_decimal from utils.sentry import SentryLogger @@ -1125,3 +1133,87 @@ def test_reservation__create__reservee_used_ad_login(graphql, amr, expected): reservation = Reservation.objects.get(pk=response.first_query_object["pk"]) assert reservation.reservee_used_ad_login is expected + + +@freeze_time(local_datetime(2024, 1, 1)) +def test_reservation__create__require_adult_reservee__is_adult(graphql): + reservation_unit = ReservationUnitFactory.create_reservable_now(require_adult_reservee=True) + + user = UserFactory.create(social_auth__extra_data__amr="suomi_fi", date_of_birth=local_date(2006, 1, 1)) + + graphql.force_login(user) + + data = get_create_data(reservation_unit) + response = graphql(CREATE_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + reservation = Reservation.objects.filter(pk=response.first_query_object["pk"]).first() + assert reservation is not None + + +@freeze_time(local_datetime(2024, 1, 1)) +def test_reservation__create__require_adult_reservee__is_under_age(graphql): + reservation_unit = ReservationUnitFactory.create_reservable_now(require_adult_reservee=True) + + user = UserFactory.create(social_auth__extra_data__amr="suomi_fi", date_of_birth=local_date(2006, 1, 2)) + + graphql.force_login(user) + + data = get_create_data(reservation_unit) + response = graphql(CREATE_MUTATION, input_data=data) + + assert response.error_message() == "Reservation unit can only be booked by an adult reservee" + + +@freeze_time(local_datetime(2024, 1, 1)) +def test_reservation__create__require_adult_reservee__is_under_age__reservation_unit_allows(graphql): + reservation_unit = ReservationUnitFactory.create_reservable_now(require_adult_reservee=False) + + user = UserFactory.create(social_auth__extra_data__amr="suomi_fi", date_of_birth=local_date(2006, 1, 2)) + + graphql.force_login(user) + + data = get_create_data(reservation_unit) + response = graphql(CREATE_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + reservation = Reservation.objects.filter(pk=response.first_query_object["pk"]).first() + assert reservation is not None + + +@freeze_time(local_datetime(2024, 1, 1)) +def test_reservation__create__require_adult_reservee__is_ad_user(graphql): + reservation_unit = ReservationUnitFactory.create_reservable_now(require_adult_reservee=True) + + user = UserFactory.create(social_auth__extra_data__amr="helsinkiazuread", date_of_birth=local_date(2006, 1, 1)) + + graphql.force_login(user) + + data = get_create_data(reservation_unit) + response = graphql(CREATE_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + reservation = Reservation.objects.filter(pk=response.first_query_object["pk"]).first() + assert reservation is not None + + +@freeze_time(local_datetime(2024, 1, 1)) +def test_reservation__create__require_adult_reservee__no_id_token(graphql): + reservation_unit = ReservationUnitFactory.create_reservable_now(require_adult_reservee=True) + + # We don't have an ID token, so we don't know if this is an AD user. + # Still, we have have a birthday that indicates they are an adult. + user = UserFactory.create(date_of_birth=local_date(2006, 1, 1)) + + graphql.force_login(user) + + data = get_create_data(reservation_unit) + response = graphql(CREATE_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + reservation = Reservation.objects.filter(pk=response.first_query_object["pk"]).first() + assert reservation is not None diff --git a/tilavarauspalvelu/api/graphql/extensions/validation_errors.py b/tilavarauspalvelu/api/graphql/extensions/validation_errors.py index 8791da42e..56f5845da 100644 --- a/tilavarauspalvelu/api/graphql/extensions/validation_errors.py +++ b/tilavarauspalvelu/api/graphql/extensions/validation_errors.py @@ -39,6 +39,7 @@ class ValidationErrorCodes(Enum): INVALID_WEEKDAY = "INVALID_WEEKDAY" INVALID_RECURRENCE_IN_DAY = "INVALID_RECURRENCE_IN_DAYS" RESERVATION_TYPE_NOT_ALLOWED = "RESERVATION_TYPE_NOT_ALLOWED" + RESERVATION_ADULT_RESERVEE_REQUIRED = "ADULT_RESERVEE_REQUIRED" class ValidationErrorWithCode(GraphQLError): # noqa: N818 diff --git a/tilavarauspalvelu/api/graphql/types/reservation/serializers/_base_save_serializer.py b/tilavarauspalvelu/api/graphql/types/reservation/serializers/_base_save_serializer.py index c25022a86..b22ac3946 100644 --- a/tilavarauspalvelu/api/graphql/types/reservation/serializers/_base_save_serializer.py +++ b/tilavarauspalvelu/api/graphql/types/reservation/serializers/_base_save_serializer.py @@ -23,6 +23,7 @@ from utils.date_utils import DEFAULT_TIMEZONE if TYPE_CHECKING: + from tilavarauspalvelu.models import User from tilavarauspalvelu.typing import AnyUser @@ -142,10 +143,12 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: begin = begin.astimezone(DEFAULT_TIMEZONE) end = end.astimezone(DEFAULT_TIMEZONE) + request_user: AnyUser = self.context["request"].user reservation_units = self._get_reservation_units(data) sku = None for reservation_unit in reservation_units: + self.check_if_reservee_should_be_adult(reservation_unit, request_user) self.check_reservation_time(reservation_unit) self.check_reservation_overlap(reservation_unit, begin, end) self.check_reservation_duration(reservation_unit, begin, end) @@ -164,7 +167,6 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: data["state"] = ReservationStateChoice.CREATED.value data["buffer_time_before"], data["buffer_time_after"] = self._calculate_buffers(begin, end, reservation_units) - request_user: AnyUser = self.context["request"].user data["user"] = None if request_user.is_anonymous else request_user data["reservee_used_ad_login"] = ( False if request_user.is_anonymous else getattr(request_user.id_token, "is_ad_login", False) @@ -220,3 +222,21 @@ def check_reservation_kind(self, reservation_unit: ReservationUnit) -> None: if reservation_unit.reservation_kind == ReservationKind.SEASON: msg = "Reservation unit is only available or seasonal booking." raise ValidationErrorWithCode(msg, ValidationErrorCodes.RESERVATION_UNIT_TYPE_IS_SEASON) + + def check_if_reservee_should_be_adult(self, reservation_unit: ReservationUnit, user: User) -> None: + if self.instance is not None: + # Only check for creation + return + + if not reservation_unit.require_adult_reservee: + return + + # AD users are currently never under age since we have blocked students from signing in. + if user.actions.is_ad_user: + return + + if user.actions.is_of_age: + return + + msg = "Reservation unit can only be booked by an adult reservee" + raise ValidationErrorWithCode(msg, ValidationErrorCodes.RESERVATION_ADULT_RESERVEE_REQUIRED) diff --git a/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py b/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py index 5dc18b938..670eeb1db 100644 --- a/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py +++ b/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py @@ -12,12 +12,11 @@ from utils.sentry import SentryLogger if TYPE_CHECKING: - from tilavarauspalvelu.models import Reservation from tilavarauspalvelu.typing import AnyUser, WSGIRequest class ReservationCreateSerializer(ReservationBaseSaveSerializer): - instance: Reservation + instance: None def validate(self, data: dict[str, Any]) -> dict[str, Any]: data = super().validate(data) diff --git a/tilavarauspalvelu/models/user/actions.py b/tilavarauspalvelu/models/user/actions.py index 033ec2fe8..251b89b8a 100644 --- a/tilavarauspalvelu/models/user/actions.py +++ b/tilavarauspalvelu/models/user/actions.py @@ -5,6 +5,7 @@ from auditlog.models import LogEntry from dateutil.relativedelta import relativedelta +from django.conf import settings from social_django.models import UserSocialAuth from tilavarauspalvelu.enums import ( @@ -25,7 +26,7 @@ UnitRole, ) from tilavarauspalvelu.typing import UserAnonymizationInfo -from utils.date_utils import local_datetime +from utils.date_utils import local_date, local_datetime if TYPE_CHECKING: from collections.abc import Iterable @@ -187,3 +188,27 @@ def can_anonymize(self) -> UserAnonymizationInfo: has_open_applications=has_open_applications, has_open_payments=has_open_payments, ) + + @property + def is_ad_user(self) -> bool: + id_token = self.user.id_token + return id_token is not None and id_token.is_ad_login + + @property + def is_profile_user(self) -> bool: + id_token = self.user.id_token + return id_token is not None and id_token.is_profile_login + + @property + def user_age(self) -> int | None: + birthday = self.user.date_of_birth + if birthday is None: + return None + return relativedelta(local_date(), birthday).years + + @property + def is_of_age(self) -> bool: + user_age = self.user_age + if user_age is None: + return False + return user_age >= settings.USER_IS_ADULT_AT_AGE