From 786295b25cc61b96eaf80cea08c01d8b4befff04 Mon Sep 17 00:00:00 2001 From: Kari Salminen Date: Thu, 28 May 2026 15:37:12 +0300 Subject: [PATCH] feat(enrolment): add is_part_of_cultural_route boolean field support Add a required `is_part_of_cultural_route` field to enrolments and queue enrolments and their related reports. Updating enrolments through admin view intentionally does not require `is_part_of_cultural_route` to be always updated. Add `is_part_of_cultural_route` to Django admin's EnrolmentReportAdmin, to its list display and add a boolean filter for the value. Also update snapshots as they changed to some other set of random values, probably because of model change. Refs: PT-453 --- gdpr/tests/__snapshots__/test_gdpr_api.ambr | 32 +- occurrences/factories.py | 2 + ...ment_is_part_of_cultural_route_and_more.py | 30 ++ occurrences/models.py | 8 + occurrences/schema.py | 17 +- occurrences/schema_services.py | 4 + occurrences/tests/__snapshots__/test_api.ambr | 13 +- .../__snapshots__/test_notifications.ambr | 8 +- occurrences/tests/test_api.py | 296 +++++++++++++++++- occurrences/tests/test_notifications.py | 11 +- .../tests/__snapshots__/test_api.ambr | 13 +- reports/admin.py | 13 + ...rolmentreport_is_part_of_cultural_route.py | 21 ++ reports/models.py | 9 + reports/serializers.py | 44 +++ reports/tests/test_models.py | 10 + reports/tests/test_serializers.py | 17 +- reports/views/csv_api.py | 24 +- 18 files changed, 531 insertions(+), 41 deletions(-) create mode 100644 occurrences/migrations/0045_enrolment_is_part_of_cultural_route_and_more.py create mode 100644 reports/migrations/0002_enrolmentreport_is_part_of_cultural_route.py diff --git a/gdpr/tests/__snapshots__/test_gdpr_api.ambr b/gdpr/tests/__snapshots__/test_gdpr_api.ambr index e48fdc25..5e2f4331 100644 --- a/gdpr/tests/__snapshots__/test_gdpr_api.ambr +++ b/gdpr/tests/__snapshots__/test_gdpr_api.ambr @@ -18,11 +18,11 @@ 'children': list([ dict({ 'key': 'START_TIME', - 'value': '2010-07-23T14:55:55.542261+00:00', + 'value': '2000-01-15T19:13:18.149871+00:00', }), dict({ 'key': 'END_TIME', - 'value': '2008-11-04T18:51:30.730104+00:00', + 'value': '1987-01-24T05:08:40.298940+00:00', }), dict({ 'key': 'CREATED_AT', @@ -36,7 +36,7 @@ 'children': list([ dict({ 'key': 'LINKED_EVENT_ID', - 'value': 'FrTcm', + 'value': 'Tcmct', }), dict({ 'key': 'ORGANISATION', @@ -65,11 +65,11 @@ }), dict({ 'key': 'UNIT_NAME', - 'value': 'National since collection goal natural. Subject take stop debate.', + 'value': 'Adult economy a to analysis senior officer. What method admit fact force.', }), dict({ 'key': 'GROUP_SIZE', - 'value': 857, + 'value': 186, }), dict({ 'key': 'AMOUNT_OF_ADULT', @@ -81,7 +81,7 @@ }), dict({ 'key': 'EXTRA_NEEDS', - 'value': 'Whose our politics contain blood interview. Cup must tough edge part. Respond pressure people.', + 'value': 'So two avoid use actually difficult. House society blood trial. Top really study young whose our.', }), dict({ 'key': 'PREFERRED_TIMES', @@ -178,11 +178,11 @@ 'children': list([ dict({ 'key': 'LINKED_EVENT_ID', - 'value': 'NoYfT', + 'value': 'EguWh', }), dict({ 'key': 'ORGANISATION', - 'value': 'Rosario and Sons', + 'value': 'Allen and Sons', }), dict({ 'key': 'CONTACT_PERSON', @@ -483,11 +483,11 @@ 'children': list([ dict({ 'key': 'START_TIME', - 'value': '2010-07-23T14:55:55.542261+00:00', + 'value': '2000-01-15T19:13:18.149871+00:00', }), dict({ 'key': 'END_TIME', - 'value': '2008-11-04T18:51:30.730104+00:00', + 'value': '1987-01-24T05:08:40.298940+00:00', }), dict({ 'key': 'CREATED_AT', @@ -501,7 +501,7 @@ 'children': list([ dict({ 'key': 'LINKED_EVENT_ID', - 'value': 'FrTcm', + 'value': 'Tcmct', }), dict({ 'key': 'ORGANISATION', @@ -605,11 +605,11 @@ 'children': list([ dict({ 'key': 'LINKED_EVENT_ID', - 'value': 'NoYfT', + 'value': 'EguWh', }), dict({ 'key': 'ORGANISATION', - 'value': 'Rosario and Sons', + 'value': 'Allen and Sons', }), dict({ 'key': 'CONTACT_PERSON', @@ -641,11 +641,11 @@ 'children': list([ dict({ 'key': 'START_TIME', - 'value': '2010-07-23T14:55:55.542261+00:00', + 'value': '2000-01-15T19:13:18.149871+00:00', }), dict({ 'key': 'END_TIME', - 'value': '2008-11-04T18:51:30.730104+00:00', + 'value': '1987-01-24T05:08:40.298940+00:00', }), dict({ 'key': 'CREATED_AT', @@ -659,7 +659,7 @@ 'children': list([ dict({ 'key': 'LINKED_EVENT_ID', - 'value': 'FrTcm', + 'value': 'Tcmct', }), dict({ 'key': 'ORGANISATION', diff --git a/occurrences/factories.py b/occurrences/factories.py index 99fe55f4..69912ae7 100644 --- a/occurrences/factories.py +++ b/occurrences/factories.py @@ -119,6 +119,7 @@ class Meta: class EventQueueEnrolmentFactory(factory.django.DjangoModelFactory): + is_part_of_cultural_route = factory.Faker("boolean") study_group = factory.SubFactory(StudyGroupFactory) p_event = factory.SubFactory(PalvelutarjotinEventFactory) person = factory.SubFactory(PersonFactory) @@ -129,6 +130,7 @@ class Meta: class EnrolmentFactory(factory.django.DjangoModelFactory): + is_part_of_cultural_route = factory.Faker("boolean") study_group = factory.SubFactory(StudyGroupFactory) occurrence = factory.SubFactory(OccurrenceFactory) person = factory.SubFactory(PersonFactory) diff --git a/occurrences/migrations/0045_enrolment_is_part_of_cultural_route_and_more.py b/occurrences/migrations/0045_enrolment_is_part_of_cultural_route_and_more.py new file mode 100644 index 00000000..70f5e1cd --- /dev/null +++ b/occurrences/migrations/0045_enrolment_is_part_of_cultural_route_and_more.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("occurrences", "0044_add_group_name_to_studygroup_order_by"), + ] + + operations = [ + migrations.AddField( + model_name="enrolment", + name="is_part_of_cultural_route", + field=models.BooleanField( + default=False, + help_text="Is enrolment part of a cultural route? True means yes, False means no, I don't know or unanswered.", + verbose_name="is part of cultural route", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="eventqueueenrolment", + name="is_part_of_cultural_route", + field=models.BooleanField( + default=False, + help_text="Is enrolment part of a cultural route? True means yes, False means no, I don't know or unanswered.", + verbose_name="is part of cultural route", + ), + preserve_default=False, + ), + ] diff --git a/occurrences/models.py b/occurrences/models.py index ba71c331..6c15a8d3 100644 --- a/occurrences/models.py +++ b/occurrences/models.py @@ -843,6 +843,13 @@ def user_can_view(self, user: User): class EnrolmentBase(WithDeletablePersonModel): + is_part_of_cultural_route = models.BooleanField( + verbose_name=_("is part of cultural route"), + help_text=_( + "Is enrolment part of a cultural route? " + "True means yes, False means no, I don't know or unanswered." + ), + ) notification_type = models.CharField( max_length=250, choices=NOTIFICATION_TYPES, @@ -976,6 +983,7 @@ def create_enrolment(self, occurrence: Occurrence): enrolment = Enrolment( occurrence=occurrence, study_group=self.study_group, + is_part_of_cultural_route=self.is_part_of_cultural_route, notification_type=self.notification_type, person=self.person, ) diff --git a/occurrences/schema.py b/occurrences/schema.py index a288e0f6..cd99a43a 100644 --- a/occurrences/schema.py +++ b/occurrences/schema.py @@ -710,6 +710,13 @@ class StudyGroupInput(graphene.InputObjectType): class EnrolInputBase: study_group = StudyGroupInput(description="Study group data", required=True) + is_part_of_cultural_route = graphene.Boolean( + description=( + "Is the enrolment part of a cultural route? " + "False = No / I don't know, True = Yes." + ), + required=True, + ) notification_type = NotificationTypeEnum() person = PersonNodeInput( description="Leave blank if the contact person is " @@ -747,6 +754,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): p_event = PalvelutarjotinEvent.objects.get(id=p_event_id) study_group = create_study_group(kwargs.pop("study_group")) contact_person_data = kwargs.pop("person", None) + is_part_of_cultural_route = kwargs.pop("is_part_of_cultural_route") notification_type = kwargs.pop( "notification_type", EventQueueEnrolment._meta.get_field("notification_type").get_default(), @@ -760,6 +768,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): study_group=study_group, p_event=p_event, person=person, + is_part_of_cultural_route=is_part_of_cultural_route, notification_type=notification_type, ) return EnrolEventQueueMutation(event_queue_enrolment=event_queue_enrolment) @@ -860,6 +869,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): kwargs.pop("captcha_key", None) study_group = create_study_group(kwargs.pop("study_group")) contact_person_data = kwargs.pop("person", None) + is_part_of_cultural_route = kwargs.pop("is_part_of_cultural_route") notification_type = kwargs.pop( "notification_type", Enrolment._meta.get_field("notification_type").get_default(), @@ -880,6 +890,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): study_group=study_group, occurrences=occurrences, person=person, + is_part_of_cultural_route=is_part_of_cultural_route, notification_type=notification_type, send_notifications_on_auto_acceptance=send_notifications, ) @@ -921,6 +932,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): class UpdateEnrolmentMutation(graphene.relay.ClientIDMutation): class Input: enrolment_id = graphene.GlobalID() + is_part_of_cultural_route = graphene.Boolean(required=False) notification_type = NotificationTypeEnum() study_group = StudyGroupInput(description="Study group input") person = PersonNodeInput( @@ -935,9 +947,12 @@ class Input: @transaction.atomic @map_enums_to_values_in_kwargs def mutate_and_get_payload(cls, root, info, **kwargs): - enrolment = get_editable_obj_from_global_id( + enrolment: Enrolment = get_editable_obj_from_global_id( info, kwargs.pop("enrolment_id"), Enrolment ) + # Use enrolment's existing value if not given or given None: + if kwargs.get("is_part_of_cultural_route") is None: + kwargs["is_part_of_cultural_route"] = enrolment.is_part_of_cultural_route study_group = enrolment.study_group study_group_data = kwargs.pop("study_group", None) if study_group_data: diff --git a/occurrences/schema_services.py b/occurrences/schema_services.py index 40f0db17..b738da9f 100644 --- a/occurrences/schema_services.py +++ b/occurrences/schema_services.py @@ -260,6 +260,7 @@ def enrol_to_occurrence( study_group: StudyGroup, occurrences: List[Occurrence], person: Person, + is_part_of_cultural_route: bool, notification_type, send_notifications_on_auto_acceptance=True, ): @@ -272,6 +273,7 @@ def enrol_to_occurrence( study_group=study_group, occurrence=occurrence, person=person, + is_part_of_cultural_route=is_part_of_cultural_route, notification_type=notification_type, ) @@ -300,6 +302,7 @@ def enrol_to_event_queue( study_group: StudyGroup, p_event: PalvelutarjotinEvent, person: Person, + is_part_of_cultural_route: bool, notification_type: str, ): if not p_event.is_queueing_allowed: @@ -315,6 +318,7 @@ def enrol_to_event_queue( p_event=p_event, study_group=study_group, person=person, + is_part_of_cultural_route=is_part_of_cultural_route, notification_type=notification_type, enrolment_time=timezone.now(), ) diff --git a/occurrences/tests/__snapshots__/test_api.ambr b/occurrences/tests/__snapshots__/test_api.ambr index febd54d6..7d63bae8 100644 --- a/occurrences/tests/__snapshots__/test_api.ambr +++ b/occurrences/tests/__snapshots__/test_api.ambr @@ -988,8 +988,8 @@ }), 'status': 'HAS_NO_ENROLMENTS', 'studyGroup': dict({ - 'groupName': 'Away watch above bad car. List short color produce include threat.', - 'preferredTimes': 'Street sign education field.', + 'groupName': 'That story claim. Case skin machine. Congress night street good break likely skill.', + 'preferredTimes': 'Much theory pay color fight.', }), }), }), @@ -1005,7 +1005,10 @@ }), 'status': 'HAS_NO_ENROLMENTS', 'studyGroup': dict({ - 'groupName': 'Before charge difficult number. Leave part and test benefit.', + 'groupName': ''' + These international majority stuff. + Poor crime chair eight. Leave part and test benefit. + ''', 'preferredTimes': 'Hotel near deal.', }), }), @@ -1056,8 +1059,8 @@ }), 'status': 'HAS_NO_ENROLMENTS', 'studyGroup': dict({ - 'groupName': 'Project hope eight week still. Mission program point piece simple too walk. Talk hand price author.', - 'preferredTimes': 'On performance detail sure.', + 'groupName': 'Focus author huge great. Institution more happen million hit listen.', + 'preferredTimes': 'House hear culture.', }), }), }), diff --git a/occurrences/tests/__snapshots__/test_notifications.ambr b/occurrences/tests/__snapshots__/test_notifications.ambr index 203c8717..65cc09a9 100644 --- a/occurrences/tests/__snapshots__/test_notifications.ambr +++ b/occurrences/tests/__snapshots__/test_notifications.ambr @@ -258,17 +258,17 @@ no-reply@hel.ninja|['brett47@example.com']|Occurrence enrolment EN| Event EN: Raija Malka & Kaija Saariaho: Blick Extra event info: ytHjL - Study group: Close term where up notice environment father stay. Hold project month similar support line. + Study group: Stop available listen specific. Occurrence: 18.04.2002 09.53 - Person: sandra56@example.net + Person: srobinson@example.net ''', ''' no-reply@hel.ninja|['brett47@example.com']|Occurrence unenrolment EN| Event EN: Raija Malka & Kaija Saariaho: Blick Extra event info: ytHjL - Study group: Close term where up notice environment father stay. Hold project month similar support line. + Study group: Stop available listen specific. Occurrence: 18.04.2002 09.53 - Person: sandra56@example.net + Person: srobinson@example.net ''', ]) # --- diff --git a/occurrences/tests/test_api.py b/occurrences/tests/test_api.py index 695c0765..e71b5d0f 100644 --- a/occurrences/tests/test_api.py +++ b/occurrences/tests/test_api.py @@ -151,6 +151,7 @@ def _enrol_event_queue_mutation_variables( ): return { "input": { + "isPartOfCulturalRoute": True, "pEventId": to_global_id("PalvelutarjotinEventNode", p_event.id), "notificationType": "EMAIL_SMS", "studyGroup": { @@ -815,6 +816,7 @@ def test_enrol_not_started_occurrence( variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": [ to_global_id("OccurrenceNode", not_started_occurrence.id), ], @@ -855,6 +857,7 @@ def test_enrol_past_occurrence( variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [to_global_id("OccurrenceNode", past_occurrence.id)], "studyGroup": { "person": { @@ -893,6 +896,7 @@ def test_enrol_invalid_group_size( variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -913,6 +917,7 @@ def test_enrol_invalid_group_size( variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -948,6 +953,7 @@ def test_enrol_without_participants( variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -1029,6 +1035,7 @@ def test_enrol_full_people_count_seat_type_occurrence( # A group of 15 woul make the event over booked by 1! variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": occurrence_ids, "studyGroup": { "person": { @@ -1081,6 +1088,7 @@ def test_enrol_full_enrolment_count_seat_type_occurrence( # 3rd enrolment will make the occurrence over booked variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -1109,6 +1117,7 @@ def test_enrol_cancelled_occurrence( variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -1140,6 +1149,7 @@ def test_enrol_occurrence_without_required_information( variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -1185,6 +1195,7 @@ def test_enrol_occurrence(snapshot, api_client, mock_get_event_data): variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": [ to_global_id("OccurrenceNode", occurrence.id), to_global_id("OccurrenceNode", occurrence_2.id), @@ -1235,6 +1246,7 @@ def test_enrol_occurrence_without_unit_info_should_raise_error( variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [ to_global_id("OccurrenceNode", occurrence.id), to_global_id("OccurrenceNode", occurrence_2.id), @@ -1256,6 +1268,97 @@ def test_enrol_occurrence_without_unit_info_should_raise_error( assert_match_error_code(executed, INVALID_STUDY_GROUP_UNIT_INFO_ERROR) +@pytest.mark.parametrize("is_part_of_cultural_route", [True, False]) +def test_enrol_occurrence_saves_is_part_of_cultural_route( + api_client, mock_get_event_data, is_part_of_cultural_route +): + """ + Test that the enrolling to an occurrence saves the input is_part_of_cultural_route + value correctly to the enrolment. + """ + study_group = StudyGroupFactory(group_size=15) + p_event = PalvelutarjotinEventFactory( + enrolment_start=datetime(2020, 1, 3, 0, 0, 0, tzinfo=tzinfo), + enrolment_end_days=2, + ) + occurrence = OccurrenceFactory( + start_time=datetime(2020, 1, 6, 0, 0, 0, tzinfo=tzinfo), + p_event=p_event, + min_group_size=10, + max_group_size=20, + amount_of_seats=50, + ) + variables = { + "input": { + "isPartOfCulturalRoute": is_part_of_cultural_route, + "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], + "studyGroup": { + "person": { + "id": to_global_id("PersonNode", study_group.person.id), + "name": study_group.person.name, + "emailAddress": study_group.person.email_address, + }, + "unitName": "To be created group", + "groupSize": study_group.group_size, + "groupName": study_group.group_name, + "studyLevels": [sl.upper() for sl in study_group.study_levels.all()], + "amountOfAdult": study_group.amount_of_adult, + }, + } + } + executed = api_client.execute(ENROL_OCCURRENCE_MUTATION, variables=variables) + assert "errors" not in executed + assert Enrolment.objects.count() == 1 + assert ( + Enrolment.objects.first().is_part_of_cultural_route is is_part_of_cultural_route + ) + + +def test_enrol_occurrence_without_is_part_of_cultural_route_fails( + api_client, mock_get_event_data +): + """ + Test that omitting the required isPartOfCulturalRoute field when enrolling to an + occurrence results in a GraphQL error. + """ + study_group = StudyGroupFactory(group_size=15) + p_event = PalvelutarjotinEventFactory( + enrolment_start=datetime(2020, 1, 3, 0, 0, 0, tzinfo=tzinfo), + enrolment_end_days=2, + ) + occurrence = OccurrenceFactory( + start_time=datetime(2020, 1, 6, 0, 0, 0, tzinfo=tzinfo), + p_event=p_event, + min_group_size=10, + max_group_size=20, + amount_of_seats=50, + ) + variables = { + "input": { + # isPartOfCulturalRoute intentionally omitted + "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], + "studyGroup": { + "person": { + "id": to_global_id("PersonNode", study_group.person.id), + "name": study_group.person.name, + "emailAddress": study_group.person.email_address, + }, + "unitName": "To be created group", + "groupSize": study_group.group_size, + "groupName": study_group.group_name, + "studyLevels": [sl.upper() for sl in study_group.study_levels.all()], + "amountOfAdult": study_group.amount_of_adult, + }, + } + } + executed = api_client.execute(ENROL_OCCURRENCE_MUTATION, variables=variables) + assert ( + "Field 'isPartOfCulturalRoute' of required type 'Boolean!' was not provided" + in executed["errors"][0]["message"] + ) + assert not Enrolment.objects.exists() + + def test_enrol_occurrence_with_captcha( snapshot, api_client, mock_get_event_data, settings, mock_recaptcha_data ): @@ -1276,6 +1379,7 @@ def test_enrol_occurrence_with_captcha( variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -1328,6 +1432,7 @@ def test_enrol_auto_acceptance_occurrence(snapshot, api_client, mock_get_event_d variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [to_global_id("OccurrenceNode", occurrence.id)], "studyGroup": { "person": { @@ -1348,6 +1453,7 @@ def test_enrol_auto_acceptance_occurrence(snapshot, api_client, mock_get_event_d variables = { "input": { + "isPartOfCulturalRoute": False, "occurrenceIds": [ to_global_id("OccurrenceNode", auto_accept_occurrence.id), ], @@ -1391,6 +1497,7 @@ def test_enrol_max_needed_occurrences(snapshot, api_client, mock_get_event_data) variables = { "input": { + "isPartOfCulturalRoute": True, "occurrenceIds": occurrence_ids, "studyGroup": { "person": { @@ -1438,6 +1545,7 @@ def test_auto_accept_message_is_used_as_custom_message_in_auto_approved_enrolmen ) variables = { "input": { + "isPartOfCulturalRoute": False, "sendNotifications": send_notifications, "occurrenceIds": [ to_global_id("OccurrenceNode", auto_accept_occurrence.id), @@ -1926,6 +2034,115 @@ def test_update_enrolment( assert e.notification_type == NOTIFICATION_TYPE_EMAIL +def _create_enrolment_for_update( + event_staff_api_client, *, is_part_of_cultural_route: bool +): + # Current date froze on 2020-01-04: + p_event = PalvelutarjotinEventFactory( + enrolment_start=datetime(2020, 1, 3, 0, 0, 0, tzinfo=tzinfo), + enrolment_end_days=2, + needed_occurrences=1, + ) + occurrence = OccurrenceFactory( + start_time=datetime(2020, 1, 6, 0, 0, 0, tzinfo=tzinfo), + p_event=p_event, + min_group_size=10, + max_group_size=20, + amount_of_seats=50, + ) + enrolment = EnrolmentFactory( + occurrence=occurrence, + study_group=StudyGroupFactory(group_size=15), + is_part_of_cultural_route=is_part_of_cultural_route, + ) + event_staff_api_client.user.person.organisations.add(p_event.organisation) + return enrolment + + +@pytest.mark.parametrize("is_part_of_cultural_route", [True, False]) +def test_update_enrolment_saves_is_part_of_cultural_route( + event_staff_api_client, mock_get_event_data, is_part_of_cultural_route +): + """ + Test that a valid isPartOfCulturalRoute input value in an enrolment update + overwrites its existing value in the enrolment. + """ + enrolment = _create_enrolment_for_update( + event_staff_api_client, is_part_of_cultural_route=not is_part_of_cultural_route + ) + variables = { + "input": { + "enrolmentId": to_global_id("EnrolmentNode", enrolment.id), + "isPartOfCulturalRoute": is_part_of_cultural_route, + } + } + executed = event_staff_api_client.execute( + UPDATE_ENROLMENT_MUTATION, variables=variables + ) + assert "errors" not in executed + enrolment.refresh_from_db() + assert enrolment.is_part_of_cultural_route is is_part_of_cultural_route + + +@pytest.mark.parametrize("existing_is_part_of_cultural_route_value", [True, False]) +def test_update_enrolment_is_part_of_cultural_route_not_provided_preserves_value( + event_staff_api_client, + mock_get_event_data, + existing_is_part_of_cultural_route_value, +): + """ + Test that omitting isPartOfCulturalRoute input value from an enrolment update + preserves its existing value. + """ + enrolment = _create_enrolment_for_update( + event_staff_api_client, + is_part_of_cultural_route=existing_is_part_of_cultural_route_value, + ) + # isPartOfCulturalRoute intentionally omitted from input + variables = { + "input": { + "enrolmentId": to_global_id("EnrolmentNode", enrolment.id), + } + } + executed = event_staff_api_client.execute( + UPDATE_ENROLMENT_MUTATION, variables=variables + ) + assert "errors" not in executed + enrolment.refresh_from_db() + assert ( + enrolment.is_part_of_cultural_route is existing_is_part_of_cultural_route_value + ) + + +@pytest.mark.parametrize("existing_is_part_of_cultural_route_value", [True, False]) +def test_update_enrolment_is_part_of_cultural_route_null_preserves_value( + event_staff_api_client, + mock_get_event_data, + existing_is_part_of_cultural_route_value, +): + """ + Test that explicitly passing None for isPartOfCulturalRoute preserves its existing value. + """ + enrolment = _create_enrolment_for_update( + event_staff_api_client, + is_part_of_cultural_route=existing_is_part_of_cultural_route_value, + ) + variables = { + "input": { + "enrolmentId": to_global_id("EnrolmentNode", enrolment.id), + "isPartOfCulturalRoute": None, + } + } + executed = event_staff_api_client.execute( + UPDATE_ENROLMENT_MUTATION, variables=variables + ) + assert "errors" not in executed + enrolment.refresh_from_db() + assert ( + enrolment.is_part_of_cultural_route is existing_is_part_of_cultural_route_value + ) + + def test_occurrences_filter_by_date( api_client, snapshot, mock_get_event_data, mock_update_event_data ): @@ -2409,7 +2626,8 @@ def test_occurrence_study_groups_unauthorized( } """ study_groups = StudyGroupFactory.create_batch(5) - occurrence.study_groups.set(study_groups) + for study_group in study_groups: + EnrolmentFactory(occurrence=occurrence, study_group=study_group) # Invalid case: Not using the API client as a staff member executed = api_client.execute( @@ -2908,6 +3126,52 @@ def test_enrol_event_queue_mutation_queueing_not_allowed( assert executed["data"] == {"enrolEventQueue": None} +@pytest.mark.parametrize("is_part_of_cultural_route", [True, False]) +def test_enrol_event_queue_saves_is_part_of_cultural_route( + api_client, organisation, mock_get_event_data, is_part_of_cultural_route +): + """ + Test that the isPartOfCulturalRoute value from the input is saved correctly to the + queue enrolment. + """ + study_group = StudyGroupFactory(group_size=15) + p_event = PalvelutarjotinEventFactory(organisation=organisation) + variables = { + "input": { + **_enrol_event_queue_mutation_variables(study_group, p_event)["input"], + "isPartOfCulturalRoute": is_part_of_cultural_route, + } + } + assert not EventQueueEnrolment.objects.exists() + executed = api_client.execute(ENROL_EVENT_QUEUE_MUTATION, variables=variables) + assert "errors" not in executed + assert EventQueueEnrolment.objects.count() == 1 + assert ( + EventQueueEnrolment.objects.first().is_part_of_cultural_route + is is_part_of_cultural_route + ) + + +def test_enrol_event_queue_without_is_part_of_cultural_route_fails( + api_client, organisation, mock_get_event_data +): + """ + Test that omitting the required isPartOfCulturalRoute field when enrolling + to an event queue results in a GraphQL error. + """ + study_group = StudyGroupFactory(group_size=15) + p_event = PalvelutarjotinEventFactory(organisation=organisation) + base_input = _enrol_event_queue_mutation_variables(study_group, p_event)["input"] + base_input.pop("isPartOfCulturalRoute") + variables = {"input": base_input} + executed = api_client.execute(ENROL_EVENT_QUEUE_MUTATION, variables=variables) + assert ( + "Field 'isPartOfCulturalRoute' of required type 'Boolean!' was not provided" + in executed["errors"][0]["message"] + ) + assert not EventQueueEnrolment.objects.exists() + + @pytest.mark.parametrize("is_queueing_allowed", [False, True]) def test_unenrol_event_queue_mutation( snapshot, @@ -2977,6 +3241,36 @@ def test_pick_enrolment_from_queue( snapshot.assert_match(executed) +@pytest.mark.parametrize("is_part_of_cultural_route", [True, False]) +def test_pick_enrolment_from_queue_copies_is_part_of_cultural_route( + event_staff_api_client, organisation, mock_get_event_data, is_part_of_cultural_route +): + """ + Test that picking from queue copies is_part_of_cultural_route as is to the new enrolment. + """ + p_event = PalvelutarjotinEventFactory(organisation=organisation) + occurrence = OccurrenceFactory(p_event=p_event) + queue = EventQueueEnrolmentFactory( + p_event=p_event, is_part_of_cultural_route=is_part_of_cultural_route + ) + variables = { + "input": { + "occurrenceId": to_global_id("OccurrenceNode", occurrence.id), + "eventQueueEnrolmentId": to_global_id("EventQueueEnrolmentNode", queue.id), + } + } + event_staff_api_client.user.person.organisations.add(organisation) + assert not Enrolment.objects.exists() + executed = event_staff_api_client.execute( + PICK_ENROLMENT_FROM_QUEUE_MUTATION, variables=variables + ) + assert "errors" not in executed + assert Enrolment.objects.count() == 1 + assert ( + Enrolment.objects.first().is_part_of_cultural_route is is_part_of_cultural_route + ) + + def test_pick_enrolment_from_queue_unauthorized( api_client, event_staff_api_client, organisation, mock_get_event_data ): diff --git a/occurrences/tests/test_notifications.py b/occurrences/tests/test_notifications.py index 2e22a261..c27a4303 100644 --- a/occurrences/tests/test_notifications.py +++ b/occurrences/tests/test_notifications.py @@ -106,6 +106,7 @@ def test_occurrence_enrolment_notification_sms_and_email( Enrolment.objects.create( study_group=study_group, occurrence=occurrence, + is_part_of_cultural_route=False, notification_type=NOTIFICATION_TYPE_ALL, person=study_group.person, ) @@ -205,7 +206,10 @@ def test_occurrence_enrolment_notifications_to_contact_person( ): contact_person = PersonFactory(email_address="email_me@dommain.com") Enrolment.objects.create( - study_group=study_group, occurrence=occurrence, person=contact_person + study_group=study_group, + occurrence=occurrence, + person=contact_person, + is_part_of_cultural_route=False, ) occurrence.study_groups.remove(study_group) # Test notification language @@ -213,7 +217,10 @@ def test_occurrence_enrolment_notifications_to_contact_person( person=PersonFactory(language="en", email_address="do_not_email_me@domain.com") ) Enrolment.objects.create( - study_group=en_study_group, occurrence=occurrence, person=contact_person + study_group=en_study_group, + occurrence=occurrence, + person=contact_person, + is_part_of_cultural_route=True, ) occurrence.study_groups.remove(en_study_group) assert len(mail.outbox) == 4 diff --git a/organisations/tests/__snapshots__/test_api.ambr b/organisations/tests/__snapshots__/test_api.ambr index 39417946..e9224881 100644 --- a/organisations/tests/__snapshots__/test_api.ambr +++ b/organisations/tests/__snapshots__/test_api.ambr @@ -264,38 +264,35 @@ dict({ 'node': dict({ 'studyGroup': dict({ - 'groupName': 'Hand human value base pattern democratic focus. Kind various laugh smile behavior.', + 'groupName': 'Conference thing much like test.', }), }), }), dict({ 'node': dict({ 'studyGroup': dict({ - 'groupName': 'Close term where up notice environment father stay. Hold project month similar support line.', + 'groupName': 'Region protect likely day.', }), }), }), dict({ 'node': dict({ 'studyGroup': dict({ - 'groupName': 'Eat design give per kind history ahead. Herself consider fight us claim. Age feeling speech eye.', + 'groupName': 'Note increase role big be bar. Behind car soldier. Actually floor involve huge young and establish.', }), }), }), dict({ 'node': dict({ 'studyGroup': dict({ - 'groupName': ''' - Apply somebody especially far. Color price environmental. - Market him beyond. - ''', + 'groupName': 'Their start concern song social. Agency happen sure indicate.', }), }), }), dict({ 'node': dict({ 'studyGroup': dict({ - 'groupName': 'Civil find learn follow. Tend practice other poor.', + 'groupName': 'Anyone conference should feel produce wife. Wonder pressure stage research direction forget list.', }), }), }), diff --git a/reports/admin.py b/reports/admin.py index 06a16eb6..15f98f06 100644 --- a/reports/admin.py +++ b/reports/admin.py @@ -116,6 +116,17 @@ def queryset(self, request, queryset): return queryset.filter(_enrolment__status=F("enrolment_status")) +class IsPartOfCulturalRouteFilter(BooleanListFilterBase): + title = _("is part of cultural route") + parameter_name = "is-part-of-cultural-route" + + def queryset(self, request, queryset): + if self.value() == "True": + return queryset.filter(is_part_of_cultural_route=True) + if self.value() == "False": + return queryset.filter(is_part_of_cultural_route=False) + + class EnrolmentReportAdminForm(forms.ModelForm): class Meta: model = EnrolmentReport @@ -150,6 +161,7 @@ class EnrolmentReportAdmin(AuditlogAdminViewAccessLogMixin, admin.ModelAdmin): "_study_group_id", "_occurrence_id", "linked_event_id", + "is_part_of_cultural_route", "enrolment_time", "enrolment_status", "enrolment_start_time", @@ -184,6 +196,7 @@ class EnrolmentReportAdmin(AuditlogAdminViewAccessLogMixin, admin.ModelAdmin): HasOccurrenceListFilter, HasStudyGroupListFilter, HasPublisherListFilter, + IsPartOfCulturalRouteFilter, ] search_fields = [ "linked_event_id", diff --git a/reports/migrations/0002_enrolmentreport_is_part_of_cultural_route.py b/reports/migrations/0002_enrolmentreport_is_part_of_cultural_route.py new file mode 100644 index 00000000..8ca98764 --- /dev/null +++ b/reports/migrations/0002_enrolmentreport_is_part_of_cultural_route.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.14 on 2026-05-28 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reports", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="enrolmentreport", + name="is_part_of_cultural_route", + field=models.BooleanField( + default=False, + help_text="Is enrolment part of a cultural route? True means yes, False means no or unknown.", + verbose_name="is part of cultural route", + ), + ), + ] diff --git a/reports/models.py b/reports/models.py index 4dbd0cfd..47befca6 100644 --- a/reports/models.py +++ b/reports/models.py @@ -149,6 +149,14 @@ class EnrolmentReport(TimestampedModel): null=True, db_column="enrolment_id", ) + is_part_of_cultural_route = models.BooleanField( + default=False, + verbose_name=_("is part of cultural route"), + help_text=_( + "Is enrolment part of a cultural route? " + "True means yes, False means no or unknown." + ), + ) enrolment_time = models.DateTimeField(verbose_name=_("enrolment time")) enrolment_status = models.CharField( max_length=255, verbose_name=_("enrolment status") @@ -337,6 +345,7 @@ def enrolment( obj: occurrences_models.Enrolment, ): self._enrolment = obj + self.is_part_of_cultural_route = obj.is_part_of_cultural_route self.enrolment_time = obj.enrolment_time self.enrolment_status = obj.status diff --git a/reports/serializers.py b/reports/serializers.py index fa54ecce..b9f01c63 100644 --- a/reports/serializers.py +++ b/reports/serializers.py @@ -1,5 +1,8 @@ from typing import List, Optional +from django.conf import settings +from django.utils import translation +from django.utils.translation import gettext as _ from rest_framework import serializers from reports.models import EnrolmentReport @@ -164,6 +167,46 @@ def to_internal_value(self, data: dict) -> list: ] +class BaseMappedBooleanField(serializers.Field): + """ + Bidirectional serializer for a BooleanField with translatable labels for + False and True values. + """ + + @property + def false_label(self) -> str: + raise NotImplementedError("Subclasses must define a false_label property.") + + @property + def true_label(self) -> str: + raise NotImplementedError("Subclasses must define a true_label property.") + + def to_representation(self, value) -> str: + return self.true_label if value else self.false_label + + def to_internal_value(self, data) -> bool: + # This is likely worst case 6 ifs (fi/sv/en languages and two labels): + for lang_code, _lang_name in settings.LANGUAGES: + with translation.override(lang_code): + if data == self.false_label: + return False + if data == self.true_label: + return True + raise serializers.ValidationError(f"Unable to parse {data} as a boolean.") + + +class IsPartOfCulturalRouteField(BaseMappedBooleanField): + @property + def false_label(self) -> str: + # Django has fi/sv/en translations for "No" and "Unknown" out of the box: + return f"{_('No')} / {_('Unknown')}" + + @property + def true_label(self) -> str: + # Django has fi/sv/en translations for "Yes" out of the box: + return _("Yes") + + class EnrolmentReportSerializer(serializers.ModelSerializer): occurrence_place_position = PositionField() study_group_unit_position = PositionField() @@ -176,6 +219,7 @@ class EnrolmentReportSerializer(serializers.ModelSerializer): first_value_name="id", second_value_name="name" ) keywords = NamedPairField(first_value_name="id", second_value_name="name") + is_part_of_cultural_route = IsPartOfCulturalRouteField() class Meta: model = EnrolmentReport diff --git a/reports/tests/test_models.py b/reports/tests/test_models.py index 230eed8b..e048ff3f 100644 --- a/reports/tests/test_models.py +++ b/reports/tests/test_models.py @@ -28,6 +28,16 @@ def test_enrolment_report_enrolment_hydration(mock_get_event_data, enrolment): assert report.linked_event_id != "" +@pytest.mark.parametrize("is_part_of_cultural_route", [False, True]) +@pytest.mark.django_db +def test_enrolment_report_enrolment_hydration_hydrates_is_part_of_cultural_route( + mock_get_event_data, enrolment, is_part_of_cultural_route: bool +): + enrolment.is_part_of_cultural_route = is_part_of_cultural_route + report = EnrolmentReport(enrolment=enrolment) + assert report.is_part_of_cultural_route is is_part_of_cultural_route + + @pytest.mark.django_db def test_enrolment_report_occurrence_hydration( mock_get_event_data_with_locations_and_keywords, occurrence diff --git a/reports/tests/test_serializers.py b/reports/tests/test_serializers.py index 1e464fe5..c1d20d5a 100644 --- a/reports/tests/test_serializers.py +++ b/reports/tests/test_serializers.py @@ -1,3 +1,5 @@ +import pytest + from reports.models import EnrolmentReport from reports.serializers import EnrolmentReportSerializer @@ -65,7 +67,16 @@ def test_enrolment_report_to_representation(): } -def test_enrolment_report_to_internal(): +@pytest.mark.parametrize( + "is_part_of_cultural_route_label, expected_is_part_of_cultural_route_value", + [ + ("Kyllä", True), + ("Ei / Tuntematon", False), + ], +) +def test_enrolment_report_to_internal( + is_part_of_cultural_route_label, expected_is_part_of_cultural_route_value +): report_json = { "study_group_study_levels": [{"id": "age_0_2", "label": "age 0-2"}], "occurrence_languages": [{"id": "fi", "name": "Finnish"}], @@ -105,10 +116,12 @@ def test_enrolment_report_to_internal(): "occurrence_end_time": "2021-11-14T01:00:00+02:00", "linked_event_id": "local-kultus:af6p5lyfxu", "enrolment_start_time": "2021-11-08T10:45:38.439000+02:00", + "is_part_of_cultural_route": is_part_of_cultural_route_label, } report = EnrolmentReportSerializer(data=report_json) - report.is_valid() + assert report.is_valid(), report.errors data = report.validated_data + assert data["is_part_of_cultural_route"] is expected_is_part_of_cultural_route_value assert data["occurrence_languages"][0] == ["fi", "Finnish"] assert data["study_group_study_levels"][0] == ["age_0_2", "age 0-2"] assert data["keywords"][0] == ["kultus:5", "Musiikki"] diff --git a/reports/views/csv_api.py b/reports/views/csv_api.py index 9673feeb..e0712437 100644 --- a/reports/views/csv_api.py +++ b/reports/views/csv_api.py @@ -21,7 +21,7 @@ from organisations.models import Organisation, Person from palvelutarjotin.oidc import KultusApiTokenAuthentication from reports.models import EnrolmentReport -from reports.serializers import EnrolmentReportSerializer +from reports.serializers import EnrolmentReportSerializer, IsPartOfCulturalRouteField from reports.services import get_place_json_from_linkedevents from reports.views.mixins import ( ExportReportViewMixin, @@ -100,6 +100,9 @@ def _create_csv_response_writer(self, filename): return writer, response + def get_field_value(self, obj, field_name): + return getattr(obj, field_name) + def get(self, request, *args, **kwargs): queryset = self.get_queryset() meta = self.model._meta @@ -109,7 +112,9 @@ def get(self, request, *args, **kwargs): with set_actor(actor=request.user, remote_addr=get_client_ip(request)): for obj in queryset: accessed.send(sender=obj.__class__, instance=obj) - writer.writerow([getattr(obj, field) for field in field_names]) + writer.writerow( + [self.get_field_value(obj, field) for field in field_names] + ) return response @@ -267,10 +272,12 @@ def _write_csv_header_row(self, writer): _("Extra needs"), _("Contact mail"), _("Contact phone"), + _("Is part of cultural route"), ] ) def _write_csv_data_row(self, writer, enrolment): + is_part_of_cultural_route_field = IsPartOfCulturalRouteField() start_datetime = timezone.localtime(enrolment.occurrence.start_time) end_datetime = timezone.localtime(enrolment.occurrence.end_time) enrolment_datetime = timezone.localtime(enrolment.enrolment_time) @@ -326,6 +333,9 @@ def _write_csv_data_row(self, writer, enrolment): enrolment.study_group.extra_needs, person_email_address, person_phone_number, + is_part_of_cultural_route_field.to_representation( + enrolment.is_part_of_cultural_route + ), ] ) @@ -391,6 +401,16 @@ class EnrolmentReportCsvView(ExportReportCsvView): model = EnrolmentReport serializer_class = EnrolmentReportSerializer + _is_part_of_cultural_route_field = IsPartOfCulturalRouteField() + + def get_field_value(self, obj, field_name): + """ + Overridden to serialize is_part_of_cultural_route field properly + """ + field_value = super().get_field_value(obj, field_name) + if field_name == "is_part_of_cultural_route": + return self._is_part_of_cultural_route_field.to_representation(field_value) + return field_value @extend_schema( operation_id="exportEnrolmentReportsCsv",