Skip to content

Commit 9b65a39

Browse files
committed
Add endpoints for rejecting and restoring all section options
1 parent bd0c449 commit 9b65a39

10 files changed

Lines changed: 256 additions & 46 deletions

File tree

api/graphql/extensions/error_codes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,5 @@
3737
HELSINKI_PROFILE_RESERVATION_USER_MISSING = "HELSINKI_PROFILE_RESERVATION_USER_MISSING"
3838
HELSINKI_PROFILE_APPLICATION_USER_MISSING = "HELSINKI_PROFILE_APPLICATION_USER_MISSING"
3939
HELSINKI_PROFILE_USER_MISSING_PROFILE_ID = "HELSINKI_PROFILE_USER_MISSING_PROFILE_ID"
40+
41+
CANNOT_REJECT_SECTION_OPTIONS = "CANNOT_REJECT_SECTION_OPTIONS"

api/graphql/mutations.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
Import all mutation classes to this file.
33
44
This is done to avoid issues where the mutation class's model serializer has other
5-
model serializers as fields. In this case, graphene-django requires that a matching
5+
model serializers as fields. In this case, `graphene-django` requires that a matching
66
ObjectType for the sub-serializer's model is created before the mutation class is created.
77
If the import order in `schema.py` is such that the mutation class is imported first,
88
the mutation class creation fails.
99
10-
Importing all mutations to this file and then to the schema.py file AFTER importing
11-
the corresponding `queries.py` with all the ObjectTypes mitigates this issue.
10+
Importing all queries to this file from `queries.py` before importing any mutations,
11+
and then importing both to `schema.py` from these respective files solves the import order issue.
1212
"""
1313

14+
# Import all queries before importing any mutations! See explanation above.
15+
from .queries import * # noqa: F403 # isort:skip
16+
1417
from api.graphql.types.allocated_time_slot.mutations import (
1518
AllocatedTimeSlotCreateMutation,
1619
AllocatedTimeSlotDeleteMutation,
@@ -25,6 +28,8 @@
2528
ApplicationSectionCreateMutation,
2629
ApplicationSectionDeleteMutation,
2730
ApplicationSectionUpdateMutation,
31+
RejectAllSectionOptionsMutation,
32+
RestoreAllSectionOptionsMutation,
2833
)
2934
from api.graphql.types.banner_notification.mutations import (
3035
BannerNotificationCreateMutation,
@@ -99,6 +104,7 @@
99104
"RecurringReservationCreateMutation",
100105
"RecurringReservationUpdateMutation",
101106
"RefreshOrderMutation",
107+
"RejectAllSectionOptionsMutation",
102108
"ReservationAdjustTimeMutation",
103109
"ReservationApproveMutation",
104110
"ReservationCancellationMutation",
@@ -122,6 +128,7 @@
122128
"ResourceCreateMutation",
123129
"ResourceDeleteMutation",
124130
"ResourceUpdateMutation",
131+
"RestoreAllSectionOptionsMutation",
125132
"SpaceCreateMutation",
126133
"SpaceDeleteMutation",
127134
"SpaceUpdateMutation",

api/graphql/queries.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Import all ObjectTypes backing django models to this file.
33
This avoids issues in creating mutation classes (see mutation.py for more information).
4-
Note that the type should be imported here even if it's not imported to schema.py,
4+
Note that the type should be imported here even if it's not imported to `schema.py`,
55
simply so that it's registered before any mutations.
66
"""
77

api/graphql/schema.py

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,44 +20,7 @@
2020
from users.helauth.typing import UserProfileInfo
2121
from users.models import User
2222

23-
# NOTE: Queries __need__ to be imported before mutations, see mutations.py!
24-
from .queries import ( # isort:skip
25-
AgeGroupNode,
26-
AllocatedTimeSlotNode,
27-
ApplicationNode,
28-
ApplicationRoundNode,
29-
ApplicationSectionNode,
30-
BannerNotificationNode,
31-
CityNode,
32-
EquipmentCategoryNode,
33-
EquipmentNode,
34-
HelsinkiProfileDataNode,
35-
KeywordCategoryNode,
36-
KeywordGroupNode,
37-
KeywordNode,
38-
PaymentOrderNode,
39-
PurposeNode,
40-
QualifierNode,
41-
RecurringReservationNode,
42-
ReservationCancelReasonNode,
43-
ReservationDenyReasonNode,
44-
ReservationMetadataSetNode,
45-
ReservationNode,
46-
ReservationPurposeNode,
47-
ReservationUnitCancellationRuleNode,
48-
ReservationUnitNode,
49-
ReservationUnitTypeNode,
50-
ResourceNode,
51-
ServiceSectorNode,
52-
SpaceNode,
53-
TaxPercentageNode,
54-
TermsOfUseNode,
55-
UnitNode,
56-
UserNode,
57-
)
58-
from .types.merchants.permissions import PaymentOrderPermission
59-
60-
from .mutations import ( # isort:skip
23+
from .mutations import (
6124
AllocatedTimeSlotCreateMutation,
6225
AllocatedTimeSlotDeleteMutation,
6326
ApplicationCancelMutation,
@@ -81,6 +44,7 @@
8144
RecurringReservationCreateMutation,
8245
RecurringReservationUpdateMutation,
8346
RefreshOrderMutation,
47+
RejectAllSectionOptionsMutation,
8448
ReservationAdjustTimeMutation,
8549
ReservationApproveMutation,
8650
ReservationCancellationMutation,
@@ -104,12 +68,48 @@
10468
ResourceCreateMutation,
10569
ResourceDeleteMutation,
10670
ResourceUpdateMutation,
71+
RestoreAllSectionOptionsMutation,
10772
SpaceCreateMutation,
10873
SpaceDeleteMutation,
10974
SpaceUpdateMutation,
11075
UnitUpdateMutation,
11176
UserUpdateMutation,
11277
)
78+
from .queries import (
79+
AgeGroupNode,
80+
AllocatedTimeSlotNode,
81+
ApplicationNode,
82+
ApplicationRoundNode,
83+
ApplicationSectionNode,
84+
BannerNotificationNode,
85+
CityNode,
86+
EquipmentCategoryNode,
87+
EquipmentNode,
88+
HelsinkiProfileDataNode,
89+
KeywordCategoryNode,
90+
KeywordGroupNode,
91+
KeywordNode,
92+
PaymentOrderNode,
93+
PurposeNode,
94+
QualifierNode,
95+
RecurringReservationNode,
96+
ReservationCancelReasonNode,
97+
ReservationDenyReasonNode,
98+
ReservationMetadataSetNode,
99+
ReservationNode,
100+
ReservationPurposeNode,
101+
ReservationUnitCancellationRuleNode,
102+
ReservationUnitNode,
103+
ReservationUnitTypeNode,
104+
ResourceNode,
105+
ServiceSectorNode,
106+
SpaceNode,
107+
TaxPercentageNode,
108+
TermsOfUseNode,
109+
UnitNode,
110+
UserNode,
111+
)
112+
from .types.merchants.permissions import PaymentOrderPermission
113113

114114

115115
class Query(graphene.ObjectType):
@@ -264,6 +264,8 @@ class Mutation(graphene.ObjectType):
264264
create_allocated_timeslot = AllocatedTimeSlotCreateMutation.Field()
265265
delete_allocated_timeslot = AllocatedTimeSlotDeleteMutation.Field()
266266
update_reservation_unit_option = ReservationUnitOptionUpdateMutation.Field()
267+
reject_all_section_options = RejectAllSectionOptionsMutation.Field()
268+
restore_all_section_options = RestoreAllSectionOptionsMutation.Field()
267269
#
268270
# Reservable entities
269271
update_unit = UnitUpdateMutation.Field()

api/graphql/types/application_section/mutations.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
from graphene_django_extensions import CreateMutation, DeleteMutation, UpdateMutation
22
from rest_framework.exceptions import ValidationError
33

4-
from api.graphql.types.application_section.permissions import ApplicationSectionPermission
5-
from api.graphql.types.application_section.serializers import ApplicationSectionSerializer
4+
from api.graphql.types.application_section.permissions import (
5+
ApplicationSectionPermission,
6+
UpdateAllSectionOptionsPermission,
7+
)
8+
from api.graphql.types.application_section.serializers import (
9+
ApplicationSectionSerializer,
10+
RejectAllSectionOptionsSerializer,
11+
RestoreAllSectionOptionsSerializer,
12+
)
613
from applications.models import ApplicationSection
714
from common.typing import AnyUser
815

916
__all__ = [
1017
"ApplicationSectionCreateMutation",
1118
"ApplicationSectionDeleteMutation",
1219
"ApplicationSectionUpdateMutation",
20+
"RejectAllSectionOptionsMutation",
21+
"RestoreAllSectionOptionsMutation",
1322
]
1423

1524

@@ -34,3 +43,15 @@ class Meta:
3443
def validate_deletion(cls, instance: ApplicationSection, user: AnyUser) -> None:
3544
if not instance.status.can_delete:
3645
raise ValidationError("Application section has been allocated and cannot be deleted anymore.")
46+
47+
48+
class RejectAllSectionOptionsMutation(UpdateMutation):
49+
class Meta:
50+
serializer_class = RejectAllSectionOptionsSerializer
51+
permission_classes = [UpdateAllSectionOptionsPermission]
52+
53+
54+
class RestoreAllSectionOptionsMutation(UpdateMutation):
55+
class Meta:
56+
serializer_class = RestoreAllSectionOptionsSerializer
57+
permission_classes = [UpdateAllSectionOptionsPermission]

api/graphql/types/application_section/permissions.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
from api.graphql.extensions import error_codes
77
from applications.models import Application, ApplicationSection
88
from common.typing import AnyUser
9-
from permissions.helpers import can_modify_application
9+
from permissions.helpers import can_modify_application, has_general_permission, has_unit_permission
1010

1111
__all__ = [
1212
"ApplicationSectionPermission",
13+
"UpdateAllSectionOptionsPermission",
1314
]
1415

1516

@@ -39,3 +40,17 @@ def has_update_permission(cls, instance: ApplicationSection, user: AnyUser, inpu
3940
@classmethod
4041
def has_delete_permission(cls, instance: ApplicationSection, user: AnyUser, input_data: dict[str, Any]) -> bool:
4142
return can_modify_application(user, instance.application)
43+
44+
45+
class UpdateAllSectionOptionsPermission(BasePermission):
46+
@classmethod
47+
def has_update_permission(cls, instance: ApplicationSection, user: AnyUser, input_data: dict[str, Any]) -> bool:
48+
if user.is_anonymous:
49+
return False
50+
if user.is_superuser:
51+
return True
52+
if has_general_permission(user, required_permission="can_handle_applications"):
53+
return True
54+
55+
units = list(instance.reservation_unit_options.all().values_list("reservation_unit__unit__id", flat=True))
56+
return has_unit_permission(user, "can_handle_applications", units)

api/graphql/types/application_section/serializers.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from graphene_django_extensions.serializers import NotProvided
77
from rest_framework.exceptions import ValidationError
88

9+
from api.graphql.extensions import error_codes
910
from api.graphql.types.reservation_unit_option.serializers import ReservationUnitOptionApplicantSerializer
1011
from api.graphql.types.suitable_time_range.serializers import SuitableTimeRangeSerializer
11-
from applications.models import Application, ApplicationRound, ApplicationSection
12+
from applications.models import AllocatedTimeSlot, Application, ApplicationRound, ApplicationSection
1213
from common.utils import comma_sep_str
1314

1415
__all__ = [
@@ -151,3 +152,42 @@ class ApplicationSectionForApplicationSerializer(ApplicationSectionSerializer):
151152
class Meta:
152153
model = ApplicationSection
153154
fields = [item for item in ApplicationSectionSerializer.Meta.fields if item != "application"]
155+
156+
157+
class RejectAllSectionOptionsSerializer(NestingModelSerializer):
158+
instance: ApplicationSection
159+
160+
class Meta:
161+
model = ApplicationSection
162+
fields = [
163+
"pk",
164+
]
165+
166+
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
167+
slots_exist = AllocatedTimeSlot.objects.filter(
168+
reservation_unit_option__application_section=self.instance,
169+
).exists()
170+
171+
if slots_exist:
172+
msg = "Application section has allocated time slots and cannot be rejected."
173+
raise ValidationError(msg, code=error_codes.CANNOT_REJECT_SECTION_OPTIONS)
174+
175+
return data
176+
177+
def save(self, **kwargs: Any) -> ApplicationSection:
178+
self.instance.reservation_unit_options.all().update(rejected=True)
179+
return self.instance
180+
181+
182+
class RestoreAllSectionOptionsSerializer(NestingModelSerializer):
183+
instance: ApplicationSection
184+
185+
class Meta:
186+
model = ApplicationSection
187+
fields = [
188+
"pk",
189+
]
190+
191+
def save(self, **kwargs: Any) -> ApplicationSection:
192+
self.instance.reservation_unit_options.all().update(rejected=False)
193+
return self.instance

tests/test_graphql_api/test_application_section/helpers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
"ApplicationSectionDeleteMutation",
2424
fields="deleted",
2525
)
26+
REJECT_MUTATION = build_mutation(
27+
"rejectAllSectionOptions",
28+
"RejectAllSectionOptionsMutation",
29+
)
30+
RESTORE_MUTATION = build_mutation(
31+
"restoreAllSectionOptions",
32+
"RestoreAllSectionOptionsMutation",
33+
)
2634

2735

2836
def get_application_section_create_data(application: Application) -> dict[str, Any]:
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import pytest
2+
3+
from applications.choices import Weekday
4+
from tests.factories import ApplicationSectionFactory, UserFactory
5+
6+
from .helpers import REJECT_MUTATION
7+
8+
# Applied to all tests
9+
pytestmark = [
10+
pytest.mark.django_db,
11+
]
12+
13+
14+
def test_application_section__reject_all_options(graphql):
15+
application_section = ApplicationSectionFactory.create_in_status_in_allocation()
16+
option = application_section.reservation_unit_options.first()
17+
assert option.rejected is False
18+
19+
graphql.login_with_superuser()
20+
response = graphql(REJECT_MUTATION, input_data={"pk": application_section.pk})
21+
22+
assert response.has_errors is False, response
23+
24+
option.refresh_from_db()
25+
assert option.rejected is True
26+
27+
28+
def test_application_section__reject_all_options__has_allocations(graphql):
29+
application_section = ApplicationSectionFactory.create_in_status_in_allocation(
30+
reservation_unit_options__rejected=False,
31+
reservation_unit_options__allocated_time_slots__day_of_the_week=Weekday.MONDAY,
32+
)
33+
option = application_section.reservation_unit_options.first()
34+
assert option.rejected is False
35+
assert option.allocated_time_slots.exists()
36+
37+
graphql.login_with_superuser()
38+
response = graphql(REJECT_MUTATION, input_data={"pk": application_section.pk})
39+
40+
assert response.error_message() == "Mutation was unsuccessful.", response
41+
assert response.field_error_messages() == [
42+
"Application section has allocated time slots and cannot be rejected.",
43+
]
44+
45+
46+
def test_application_section__reject_all_options__general_admin(graphql):
47+
application_section = ApplicationSectionFactory.create_in_status_in_allocation()
48+
49+
admin = UserFactory.create_with_general_permissions(perms=["can_handle_applications"])
50+
graphql.force_login(admin)
51+
52+
response = graphql(REJECT_MUTATION, input_data={"pk": application_section.pk})
53+
54+
assert response.has_errors is False
55+
56+
57+
def test_application_section__reject_all_options__unit_admin(graphql):
58+
application_section = ApplicationSectionFactory.create_in_status_in_allocation()
59+
60+
unit = application_section.reservation_unit_options.first().reservation_unit.unit
61+
admin = UserFactory.create_with_unit_permissions(unit=unit, perms=["can_handle_applications"])
62+
graphql.force_login(admin)
63+
64+
response = graphql(REJECT_MUTATION, input_data={"pk": application_section.pk})
65+
66+
assert response.has_errors is False

0 commit comments

Comments
 (0)