From cc0a3a9cdb6c54ed5e37508c5c9f2a7ba08975f4 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Thu, 17 Feb 2022 16:23:12 +0100 Subject: [PATCH 01/32] refactor(admissions): refactor InterviewScheduleTemplate Add fields to allow the start time and end time for each day to be specified instead of doing this implicitly. --- .../migrations/0014_auto_20220217_1345.py | 49 ++++++++++++ ...eduletemplate_default_interview_day_end.py | 19 +++++ admissions/models.py | 15 +++- admissions/tests/test_utils.py | 78 +++++++++++-------- admissions/utils.py | 29 ++++++- common/util.py | 12 +++ 6 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 admissions/migrations/0014_auto_20220217_1345.py create mode 100644 admissions/migrations/0015_alter_interviewscheduletemplate_default_interview_day_end.py diff --git a/admissions/migrations/0014_auto_20220217_1345.py b/admissions/migrations/0014_auto_20220217_1345.py new file mode 100644 index 00000000..3a6cf5c7 --- /dev/null +++ b/admissions/migrations/0014_auto_20220217_1345.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.12 on 2022-02-17 13:45 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0013_auto_20220208_1232"), + ] + + operations = [ + migrations.RemoveField( + model_name="interviewscheduletemplate", + name="interview_period_end", + ), + migrations.RemoveField( + model_name="interviewscheduletemplate", + name="interview_period_start", + ), + migrations.AddField( + model_name="interviewscheduletemplate", + name="default_interview_day_end", + field=models.TimeField(default=datetime.time(20, 0)), + ), + migrations.AddField( + model_name="interviewscheduletemplate", + name="default_interview_day_start", + field=models.TimeField(default=datetime.time(12, 0)), + ), + migrations.AddField( + model_name="interviewscheduletemplate", + name="interview_period_end_date", + field=models.DateField( + default=datetime.datetime(2022, 2, 17, 13, 45, 9, 437339, tzinfo=utc) + ), + preserve_default=False, + ), + migrations.AddField( + model_name="interviewscheduletemplate", + name="interview_period_start_date", + field=models.DateField( + default=datetime.datetime(2022, 3, 3, 13, 45, 27, 976268, tzinfo=utc) + ), + preserve_default=False, + ), + ] diff --git a/admissions/migrations/0015_alter_interviewscheduletemplate_default_interview_day_end.py b/admissions/migrations/0015_alter_interviewscheduletemplate_default_interview_day_end.py new file mode 100644 index 00000000..0e5fc906 --- /dev/null +++ b/admissions/migrations/0015_alter_interviewscheduletemplate_default_interview_day_end.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-02-17 13:56 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0014_auto_20220217_1345"), + ] + + operations = [ + migrations.AlterField( + model_name="interviewscheduletemplate", + name="default_interview_day_end", + field=models.TimeField(default=datetime.time(18, 0)), + ), + ] diff --git a/admissions/models.py b/admissions/models.py index adf0f09f..c7f141bc 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -13,7 +13,7 @@ from secrets import token_urlsafe from os.path import join as osjoin from admissions.utils import send_welcome_to_interview_email -from django.db.utils import DatabaseError +import datetime class AdmissionAvailableInternalGroupPositionData(models.Model): @@ -302,8 +302,17 @@ def __str__(self): class InterviewScheduleTemplate(models.Model): """Default template for generating interviews""" - interview_period_start = models.DateTimeField(null=False) - interview_period_end = models.DateTimeField(null=False) + interview_period_start_date = models.DateField(null=False) + interview_period_end_date = models.DateField(null=False) + + # The time of the first interview for each day in the interview period + default_interview_day_start = models.TimeField( + default=datetime.time(hour=12, minute=0) + ) + # The time for the last interview for each day in the interview period + default_interview_day_end = models.TimeField( + default=datetime.time(hour=18, minute=0) + ) default_interview_duration = models.DurationField( default=timezone.timedelta(minutes=30) ) diff --git a/admissions/tests/test_utils.py b/admissions/tests/test_utils.py index d6c6696e..ad1b6d6b 100644 --- a/admissions/tests/test_utils.py +++ b/admissions/tests/test_utils.py @@ -13,6 +13,8 @@ InterviewLocationAvailability, ) from django.utils import timezone +from common.util import date_time_combiner +import datetime class TestGetAvailableInterviewLocations(TestCase): @@ -23,77 +25,89 @@ def setUp(self) -> None: self.bodegaen = InterviewLocation.objects.create(name="Bodegaen") # Initialize the start of the interview period to 12:00 - now = timezone.datetime.now() - self.start = timezone.datetime( - now.year, - now.month, - now.day, - hour=12, - minute=0, - second=0, - tzinfo=timezone.timezone(timezone.timedelta()), - ) + self.start = datetime.date.today() + self.datetime_start = date_time_combiner(self.start, datetime.time(hour=12)) - # End of interview period is next day at 23:00 - self.interview_period_end = self.start + timezone.timedelta(days=1, hours=14) + # End of interview period is two days later giving us a three day interview period + self.interview_period_end_date = self.start + timezone.timedelta(days=2) self.schedule = InterviewScheduleTemplate.objects.create( - interview_period_start=self.start, - interview_period_end=self.interview_period_end, + interview_period_start_date=self.start, + interview_period_end_date=self.interview_period_end_date, + default_interview_day_start=datetime.time(hour=12), + default_interview_day_end=datetime.time(hour=20), ) # 12:00 to 20:00 day 1 InterviewLocationAvailability.objects.create( interview_location=self.knaus, - datetime_from=self.start, - datetime_to=self.start + timezone.timedelta(hours=8), + datetime_from=self.datetime_start, + datetime_to=self.datetime_start + timezone.timedelta(hours=8), ) # 12:00 to 20:00 day 2 InterviewLocationAvailability.objects.create( interview_location=self.knaus, - datetime_from=self.start + timezone.timedelta(days=1), - datetime_to=self.start + timezone.timedelta(days=1, hours=8), + datetime_from=self.datetime_start + timezone.timedelta(days=1), + datetime_to=self.datetime_start + timezone.timedelta(days=1, hours=8), + ) + # 12:00 to 20:00 day 3 + InterviewLocationAvailability.objects.create( + interview_location=self.knaus, + datetime_from=self.datetime_start + timezone.timedelta(days=2), + datetime_to=self.datetime_start + timezone.timedelta(days=2, hours=8), ) # 12:00 to 20:00 day 1 InterviewLocationAvailability.objects.create( interview_location=self.digitalt_rom_1, - datetime_from=self.start, - datetime_to=self.start + timezone.timedelta(hours=8), + datetime_from=self.datetime_start, + datetime_to=self.datetime_start + timezone.timedelta(hours=8), ) # 12:00 to 20:00 day 2 InterviewLocationAvailability.objects.create( interview_location=self.digitalt_rom_1, - datetime_from=self.start + timezone.timedelta(days=1), - datetime_to=self.start + timezone.timedelta(days=1, hours=8), + datetime_from=self.datetime_start + timezone.timedelta(days=1), + datetime_to=self.datetime_start + timezone.timedelta(days=1, hours=8), + ) + # 12:00 to 20:00 day 3 + InterviewLocationAvailability.objects.create( + interview_location=self.digitalt_rom_1, + datetime_from=self.datetime_start + timezone.timedelta(days=2), + datetime_to=self.datetime_start + timezone.timedelta(days=2, hours=8), ) # 12:00 to 20:00 day 1 InterviewLocationAvailability.objects.create( interview_location=self.bodegaen, - datetime_from=self.start, - datetime_to=self.start + timezone.timedelta(hours=8), + datetime_from=self.datetime_start, + datetime_to=self.datetime_start + timezone.timedelta(hours=8), ) # 12:00 to 20:00 day 2 self.bodegaen_day_2 = InterviewLocationAvailability.objects.create( interview_location=self.bodegaen, - datetime_from=self.start + timezone.timedelta(days=1), - datetime_to=self.start + timezone.timedelta(days=1, hours=8), + datetime_from=self.datetime_start + timezone.timedelta(days=1), + datetime_to=self.datetime_start + timezone.timedelta(days=1, hours=8), + ) + # 12:00 to 20:00 day 3 + self.bodegaen_day_3 = InterviewLocationAvailability.objects.create( + interview_location=self.bodegaen, + datetime_from=self.datetime_start + timezone.timedelta(days=2), + datetime_to=self.datetime_start + timezone.timedelta(days=2, hours=8), ) - def test__3_locations_available_12_to_20_two_days_10_interviews_per_location__generates_60_interviews( + def test__3_locations_available_12_to_20_three_days_10_interviews_per_location__generates_60_interviews( self, ): - # 3 locations. 8 hours. 2 interviews per hour. 5 interviews back to back in two sessions - # 3 x 10 x 2 = 60 + # 3 locations. 3 days, 10 interviews per day + # 3 x 3 x 10 = 60 generate_interviews_from_schedule(self.schedule) interviews = Interview.objects.all() - self.assertEqual(interviews.count(), 60) + self.assertEqual(interviews.count(), 90) - def test__3_locations_first_dat_2_locations_next__generates_50_interviews(self): + def test__3_locations_first_day_2_locations_next__generates_50_interviews(self): self.bodegaen_day_2.delete() generate_interviews_from_schedule(self.schedule) interviews = Interview.objects.all() - self.assertEqual(interviews.count(), 50) + self.assertEqual(interviews.count(), 80) def test__before_interview_time__returns_no_available_locations(self): now = timezone.datetime.now() diff --git a/admissions/utils.py b/admissions/utils.py index 9428904c..02a3d3a7 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -35,12 +35,33 @@ def get_available_interview_locations(datetime_from=None, datetime_to=None): def generate_interviews_from_schedule(schedule): interview_duration = schedule.default_interview_duration default_pause_duration = schedule.default_pause_duration - datetime_cursor = schedule.interview_period_start + default_interview_day_start = schedule.default_interview_day_start + default_interview_day_end = schedule.default_interview_day_end + interview_period_start_date = schedule.interview_period_start_date + + datetime_cursor = timezone.datetime( + year=interview_period_start_date.year, + month=interview_period_start_date.month, + day=interview_period_start_date.day, + hour=default_interview_day_start.hour, + minute=default_interview_day_start.minute, + tzinfo=timezone.timezone(timezone.timedelta()), + ) + datetime_interview_period_end = timezone.datetime( + year=schedule.interview_period_end_date.year, + month=schedule.interview_period_end_date.month, + day=schedule.interview_period_end_date.day, + hour=schedule.default_interview_day_end.hour, + minute=schedule.default_interview_day_end.minute, + tzinfo=timezone.timezone(timezone.timedelta()), + ) + print(f"{datetime_cursor=}") + print(f"{datetime_interview_period_end=}") # Lazy load model due to circular import errors Interview = apps.get_model(app_label="admissions", model_name="Interview") - while datetime_cursor < schedule.interview_period_end: + while datetime_cursor < datetime_interview_period_end: # Generate interviews for the first session of the day for i in range(schedule.default_block_size): available_locations = get_available_interview_locations( @@ -82,8 +103,8 @@ def generate_interviews_from_schedule(schedule): year=datetime_cursor.year, month=datetime_cursor.month, day=datetime_cursor.day, - hour=schedule.interview_period_start.hour, - minute=schedule.interview_period_start.minute, + hour=default_interview_day_start.hour, + minute=default_interview_day_start.minute, second=0, tzinfo=timezone.timezone(timezone.timedelta()), ) diff --git a/common/util.py b/common/util.py index 95c86499..fae78d87 100644 --- a/common/util.py +++ b/common/util.py @@ -230,3 +230,15 @@ def send_email( email.attach_file(attachments) return email.send(fail_silently=fail_silently) + + +def date_time_combiner(date: datetime.date, time: datetime.time): + return timezone.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=time.hour, + minute=time.minute, + second=time.second, + tzinfo=timezone.timezone(timezone.timedelta()), + ) From 2c8efae6daefe04a24b547188e799d74b593b356 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Fri, 25 Feb 2022 03:18:50 +0100 Subject: [PATCH 02/32] fix(admissions): timezone offsett bug --- admissions/utils.py | 55 ++++++++++++++++++++------------------------- common/util.py | 17 +++++++------- 2 files changed, 33 insertions(+), 39 deletions(-) diff --git a/admissions/utils.py b/admissions/utils.py index 02a3d3a7..e3ac426c 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -5,6 +5,7 @@ from django.conf import settings from django.apps import apps import csv +from common.util import date_time_combiner def get_available_interview_locations(datetime_from=None, datetime_to=None): @@ -39,43 +40,34 @@ def generate_interviews_from_schedule(schedule): default_interview_day_end = schedule.default_interview_day_end interview_period_start_date = schedule.interview_period_start_date - datetime_cursor = timezone.datetime( - year=interview_period_start_date.year, - month=interview_period_start_date.month, - day=interview_period_start_date.day, - hour=default_interview_day_start.hour, - minute=default_interview_day_start.minute, - tzinfo=timezone.timezone(timezone.timedelta()), + datetime_cursor = date_time_combiner( + interview_period_start_date, default_interview_day_start ) - datetime_interview_period_end = timezone.datetime( - year=schedule.interview_period_end_date.year, - month=schedule.interview_period_end_date.month, - day=schedule.interview_period_end_date.day, - hour=schedule.default_interview_day_end.hour, - minute=schedule.default_interview_day_end.minute, - tzinfo=timezone.timezone(timezone.timedelta()), + datetime_interview_period_end = date_time_combiner( + schedule.interview_period_end_date, default_interview_day_end ) - print(f"{datetime_cursor=}") - print(f"{datetime_interview_period_end=}") # Lazy load model due to circular import errors Interview = apps.get_model(app_label="admissions", model_name="Interview") while datetime_cursor < datetime_interview_period_end: + print(f"{datetime_cursor}") # Generate interviews for the first session of the day for i in range(schedule.default_block_size): available_locations = get_available_interview_locations( datetime_from=datetime_cursor, datetime_to=datetime_cursor + interview_duration, ) + print(f"{available_locations.count()} locations are available") for location in available_locations: - with transaction.atomic(): - Interview.objects.create( - location=location, - interview_start=datetime_cursor, - interview_end=datetime_cursor + interview_duration, - ) - + print( + f"Looking at {location} from {datetime_cursor} to {datetime_cursor + interview_duration}" + ) + Interview.objects.create( + location=location, + interview_start=datetime_cursor, + interview_end=datetime_cursor + interview_duration, + ) datetime_cursor += interview_duration # First session is over. We give the interviewers a break @@ -99,14 +91,15 @@ def generate_interviews_from_schedule(schedule): # Interviews for the day is over. Update cursor. datetime_cursor += timezone.timedelta(days=1) - datetime_cursor = timezone.datetime( - year=datetime_cursor.year, - month=datetime_cursor.month, - day=datetime_cursor.day, - hour=default_interview_day_start.hour, - minute=default_interview_day_start.minute, - second=0, - tzinfo=timezone.timezone(timezone.timedelta()), + datetime_cursor = timezone.make_aware( + timezone.datetime( + year=datetime_cursor.year, + month=datetime_cursor.month, + day=datetime_cursor.day, + hour=default_interview_day_start.hour, + minute=default_interview_day_start.minute, + second=0, + ) ) diff --git a/common/util.py b/common/util.py index fae78d87..20a3337b 100644 --- a/common/util.py +++ b/common/util.py @@ -233,12 +233,13 @@ def send_email( def date_time_combiner(date: datetime.date, time: datetime.time): - return timezone.datetime( - year=date.year, - month=date.month, - day=date.day, - hour=time.hour, - minute=time.minute, - second=time.second, - tzinfo=timezone.timezone(timezone.timedelta()), + return timezone.make_aware( + timezone.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=time.hour, + minute=time.minute, + second=time.second, + ) ) From c79f735f95792ab143afc46ac81f007dd1e2dc5a Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Fri, 25 Feb 2022 03:20:37 +0100 Subject: [PATCH 03/32] feat(admissions): finetune api endpoints * Add some missing fields to models * Add missing mutations and queries to schema --- admissions/consts.py | 3 +- .../migrations/0016_alter_admission_status.py | 32 ++ .../migrations/0017_alter_admission_date.py | 18 + .../migrations/0018_alter_admission_status.py | 29 ++ .../0019_interviewbooleanevaluation_order.py | 19 + ...viewadditionalevaluationstatement_order.py | 19 + .../migrations/0021_auto_20220224_2124.py | 28 ++ ...ableinternalgrouppositiondata_admission.py | 23 + admissions/models.py | 34 +- admissions/schema.py | 399 +++++++++++++++++- admissions/tests/test_utils.py | 38 ++ ksg_nett/schema.py | 11 +- ksg_nett/settings.py | 14 +- ...ernalgroupposition_available_externally.py | 18 + organization/models.py | 2 + users/schema.py | 6 + 16 files changed, 676 insertions(+), 17 deletions(-) create mode 100644 admissions/migrations/0016_alter_admission_status.py create mode 100644 admissions/migrations/0017_alter_admission_date.py create mode 100644 admissions/migrations/0018_alter_admission_status.py create mode 100644 admissions/migrations/0019_interviewbooleanevaluation_order.py create mode 100644 admissions/migrations/0020_interviewadditionalevaluationstatement_order.py create mode 100644 admissions/migrations/0021_auto_20220224_2124.py create mode 100644 admissions/migrations/0022_alter_admissionavailableinternalgrouppositiondata_admission.py create mode 100644 organization/migrations/0024_internalgroupposition_available_externally.py diff --git a/admissions/consts.py b/admissions/consts.py index 5665ab4f..6d6eece0 100644 --- a/admissions/consts.py +++ b/admissions/consts.py @@ -8,7 +8,8 @@ class Priority(models.TextChoices): class AdmissionStatus(models.TextChoices): - INITIALIZATION = ("initialization", "Initialization") + INITIALIZATION = ("configuration", "Configuration") + INTERVIEW_OVERVIEW = ("interview-overview", "Interview overview") OPEN = ("open", "Open") IN_SESSION = ("in-session", "In session") # Fordelingsmøtet FINALIZATION = ( diff --git a/admissions/migrations/0016_alter_admission_status.py b/admissions/migrations/0016_alter_admission_status.py new file mode 100644 index 00000000..19cdef71 --- /dev/null +++ b/admissions/migrations/0016_alter_admission_status.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.12 on 2022-02-20 19:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "admissions", + "0015_alter_interviewscheduletemplate_default_interview_day_end", + ), + ] + + operations = [ + migrations.AlterField( + model_name="admission", + name="status", + field=models.CharField( + choices=[ + ("initialization", "Initialization"), + ("interview-overview", "Interview overview"), + ("open", "Open"), + ("in-session", "In session"), + ("finalization", "Finalization"), + ("closed", "Closed"), + ], + default="open", + max_length=32, + ), + ), + ] diff --git a/admissions/migrations/0017_alter_admission_date.py b/admissions/migrations/0017_alter_admission_date.py new file mode 100644 index 00000000..ad029d4a --- /dev/null +++ b/admissions/migrations/0017_alter_admission_date.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-02-22 16:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0016_alter_admission_status"), + ] + + operations = [ + migrations.AlterField( + model_name="admission", + name="date", + field=models.DateField(auto_now=True, null=True), + ), + ] diff --git a/admissions/migrations/0018_alter_admission_status.py b/admissions/migrations/0018_alter_admission_status.py new file mode 100644 index 00000000..317a0eec --- /dev/null +++ b/admissions/migrations/0018_alter_admission_status.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.12 on 2022-02-22 16:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0017_alter_admission_date"), + ] + + operations = [ + migrations.AlterField( + model_name="admission", + name="status", + field=models.CharField( + choices=[ + ("configuration", "Configuration"), + ("interview-overview", "Interview overview"), + ("open", "Open"), + ("in-session", "In session"), + ("finalization", "Finalization"), + ("closed", "Closed"), + ], + default="open", + max_length=32, + ), + ), + ] diff --git a/admissions/migrations/0019_interviewbooleanevaluation_order.py b/admissions/migrations/0019_interviewbooleanevaluation_order.py new file mode 100644 index 00000000..5fc8c2b0 --- /dev/null +++ b/admissions/migrations/0019_interviewbooleanevaluation_order.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-02-24 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0018_alter_admission_status"), + ] + + operations = [ + migrations.AddField( + model_name="interviewbooleanevaluation", + name="order", + field=models.IntegerField(default=1, unique=True), + preserve_default=False, + ), + ] diff --git a/admissions/migrations/0020_interviewadditionalevaluationstatement_order.py b/admissions/migrations/0020_interviewadditionalevaluationstatement_order.py new file mode 100644 index 00000000..80cd2e43 --- /dev/null +++ b/admissions/migrations/0020_interviewadditionalevaluationstatement_order.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-02-24 14:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0019_interviewbooleanevaluation_order"), + ] + + operations = [ + migrations.AddField( + model_name="interviewadditionalevaluationstatement", + name="order", + field=models.IntegerField(default=1, unique=True), + preserve_default=False, + ), + ] diff --git a/admissions/migrations/0021_auto_20220224_2124.py b/admissions/migrations/0021_auto_20220224_2124.py new file mode 100644 index 00000000..a5c8534a --- /dev/null +++ b/admissions/migrations/0021_auto_20220224_2124.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.12 on 2022-02-24 20:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0020_interviewadditionalevaluationstatement_order"), + ] + + operations = [ + migrations.AlterField( + model_name="admissionavailableinternalgrouppositiondata", + name="admission", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="available_internal_group_posistions_data", + to="admissions.admission", + ), + ), + migrations.AlterField( + model_name="admissionavailableinternalgrouppositiondata", + name="available_positions", + field=models.IntegerField(), + ), + ] diff --git a/admissions/migrations/0022_alter_admissionavailableinternalgrouppositiondata_admission.py b/admissions/migrations/0022_alter_admissionavailableinternalgrouppositiondata_admission.py new file mode 100644 index 00000000..28cd216b --- /dev/null +++ b/admissions/migrations/0022_alter_admissionavailableinternalgrouppositiondata_admission.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2022-02-24 20:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0021_auto_20220224_2124"), + ] + + operations = [ + migrations.AlterField( + model_name="admissionavailableinternalgrouppositiondata", + name="admission", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="available_internal_group_positions_data", + to="admissions.admission", + ), + ), + ] diff --git a/admissions/models.py b/admissions/models.py index c7f141bc..367da8e7 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -2,6 +2,7 @@ from django.db.models import Q from common.util import get_semester_year_shorthand from django.utils import timezone +from django.db.utils import IntegrityError from django.core.validators import MinValueValidator from admissions.consts import ( Priority, @@ -25,15 +26,19 @@ class AdmissionAvailableInternalGroupPositionData(models.Model): class Meta: unique_together = ("admission", "internal_group_position") - admission = models.ForeignKey("admissions.Admission", on_delete=models.CASCADE) + admission = models.ForeignKey( + "admissions.Admission", + on_delete=models.CASCADE, + related_name="available_internal_group_positions_data", + ) internal_group_position = models.ForeignKey( "organization.InternalGroupPosition", on_delete=models.CASCADE ) - available_positions = models.IntegerField(validators=[MinValueValidator(1)]) + available_positions = models.IntegerField() class Admission(models.Model): - date = models.DateField(blank=True, null=True) + date = models.DateField(blank=True, null=True, auto_now=True) status = models.CharField( choices=AdmissionStatus.choices, default=AdmissionStatus.OPEN, max_length=32 ) @@ -77,6 +82,7 @@ class InterviewBooleanEvaluation(models.Model): """ statement = models.CharField(max_length=64, null=False, blank=False, unique=True) + order = models.IntegerField(unique=True) def __str__(self): return self.statement @@ -94,7 +100,13 @@ class Meta: class InterviewAdditionalEvaluationStatement(models.Model): + """ + An interview question with a range of values stating how true this statment is for this person. + An example would be "Is this person energetic?" + """ + statement = models.CharField(max_length=64, unique=True) + order = models.IntegerField(unique=True) def __str__(self): return self.statement @@ -183,7 +195,7 @@ def save(self, *args, **kwargs): An interview cannot overlap in the same location. Whe therefore make the following checks """ try: - inter = Interview.objects.get( + Interview.objects.get( # First we check if we are trying to start an interview during another one ( Q(interview_end__gt=self.interview_start) @@ -327,6 +339,20 @@ class InterviewScheduleTemplate(models.Model): def __str__(self): return f"Interview schedule template. Generates {self.default_block_size * 2} interviews per location per day" + @classmethod + def get_interview_schedule_template(cls): + return cls.objects.all().first() + + def save(self, *args, **kwargs): + """ + An interview cannot overlap in the same location. Whe therefore make the following checks + """ + if InterviewScheduleTemplate.objects.all().count() > 0 and self._state.adding: + raise IntegrityError( + "Only one InterviewScheduleTemplate can exist at a time" + ) + super(InterviewScheduleTemplate, self).save(*args, **kwargs) + class InterviewLocation(models.Model): """Represents a location where an interview is held. The name can be a physical location or just 'Digital room 1""" diff --git a/admissions/schema.py b/admissions/schema.py index 4a4dc1e0..a9de36fe 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -1,3 +1,4 @@ +import datetime import graphene from graphene import Node from graphene_django import DjangoObjectType @@ -6,7 +7,9 @@ DjangoPatchMutation, DjangoDeleteMutation, DjangoCreateMutation, + DjangoBatchPatchMutation, ) +from common.util import date_time_combiner from django.db.models import Q from graphene_django.filter import DjangoFilterConnectionField from admissions.utils import ( @@ -14,15 +17,26 @@ resend_auth_token_email, obfuscate_admission, ) +from django.core.exceptions import SuspiciousOperation +from django.utils import timezone from admissions.models import ( Applicant, Admission, AdmissionAvailableInternalGroupPositionData, InternalGroupPositionPriority, Interview, + InterviewLocation, + InterviewLocationAvailability, InterviewScheduleTemplate, + InterviewBooleanEvaluation, + InterviewAdditionalEvaluationStatement, ) +from organization.models import InternalGroupPosition +from organization.schema import InternalGroupPositionNode from admissions.filters import AdmissionFilter, ApplicantFilter +from admissions.consts import AdmissionStatus +from graphene_django_cud.types import TimeDelta +from graphene import Time, Date class InternalGroupPositionPriorityNode(DjangoObjectType): @@ -31,6 +45,31 @@ class Meta: interfaces = (Node,) +class InterviewLocationAvailabilityNode(DjangoObjectType): + class Meta: + model = InterviewLocationAvailability + interfaces = (Node,) + + @classmethod + def get_node(cls, info, id): + return InterviewLocationAvailability.objects.get(pk=id) + + +class InterviewLocationNode(DjangoObjectType): + class Meta: + model = InterviewLocation + interfaces = (Node,) + + availability = graphene.List(InterviewLocationAvailabilityNode) + + def resolve_availability(self: InterviewLocation, info, *args, **kwargs): + return self.availability.all().order_by("datetime_from") + + @classmethod + def get_node(cls, info, id): + return InterviewLocation.objects.get(pk=id) + + class ApplicantNode(DjangoObjectType): class Meta: model = Applicant @@ -43,6 +82,56 @@ def get_node(cls, info, id): return Applicant.objects.get(pk=id) +class InterviewScheduleTemplateNode(DjangoObjectType): + class Meta: + model = InterviewScheduleTemplate + interfaces = (Node,) + + default_interview_duration = TimeDelta() + default_pause_duration = TimeDelta() + + interview_period_start_date = Date() + interview_period_end_date = Date() + + default_interview_day_start = Time() + default_interview_day_end = Time() + + def resolve_interview_period_start_date( + self: InterviewScheduleTemplate, info, *args, **kwargs + ): + return self.interview_period_start_date + + def resolve_interview_period_end_date( + self: InterviewScheduleTemplate, info, *args, **kwargs + ): + return self.interview_period_end_date + + def resolve_default_interview_day_start( + self: InterviewScheduleTemplate, info, *args, **kwargs + ): + return self.default_interview_day_start + + def resolve_default_interview_day_end( + self: InterviewScheduleTemplate, info, *args, **kwargs + ): + return self.default_interview_day_end + + def resolve_default_interview_duration( + self: InterviewScheduleTemplate, info, *args, **kwargs + ): + # This is a timedelta object but is returned as "mm:ss" instead of "hh:mm:ss" which ruins stuff kinda + return self.default_interview_duration + + def resolve_default_pause_duration( + self: InterviewScheduleTemplate, info, *args, **kwargs + ): + return self.default_pause_duration + + @classmethod + def get_node(cls, info, id): + return InterviewScheduleTemplate.objects.get(pk=id) + + class AdmissionAvailableInternalGroupPositionDataNode(DjangoObjectType): class Meta: model = AdmissionAvailableInternalGroupPositionData @@ -59,7 +148,7 @@ class Meta: interfaces = (Node,) semester = graphene.String(source="semester") - available_internal_group_positions = graphene.NonNull( + available_internal_group_positions_data = graphene.NonNull( graphene.List(AdmissionAvailableInternalGroupPositionDataNode) ) applicants = graphene.List(ApplicantNode) @@ -67,16 +156,38 @@ class Meta: def resolve_applicants(self: Admission, info, *args, **kwargs): return self.applicants.all().order_by("first_name") - def resolve_available_internal_group_positions( + def resolve_available_internal_group_positions_data( self: Admission, info, *args, **kwargs ): - return self.available_internal_group_positions.all() + available_positions = self.available_internal_group_positions_data.all() + if available_positions: + return available_positions + + default_externally_available_positions = InternalGroupPosition.objects.filter( + available_externally=True + ) + for position in default_externally_available_positions: + AdmissionAvailableInternalGroupPositionData.objects.create( + internal_group_position=position, admission=self, available_positions=1 + ) + + return self.available_internal_group_positions_data.all() @classmethod def get_node(cls, info, id): return Admission.objects.get(pk=id) +class InterviewNode(DjangoObjectType): + class Meta: + model = Interview + interfaces = (Node,) + + @classmethod + def get_node(cls, info, id): + return Interview.objects.get(pk=id) + + class ApplicantQuery(graphene.ObjectType): applicant = Node.Field(ApplicantNode) all_applicants = DjangoFilterConnectionField( @@ -139,6 +250,27 @@ class AdmissionQuery(graphene.ObjectType): AdmissionNode, filterset_class=AdmissionFilter ) active_admission = graphene.Field(AdmissionNode) + all_interview_schedule_templates = graphene.List(InterviewScheduleTemplateNode) + interview_schedule_template = graphene.Field(InterviewScheduleTemplateNode) + externally_available_internal_group_positions = graphene.List( + InternalGroupPositionNode + ) + currently_admission_internal_group_position_data = graphene.List( + AdmissionAvailableInternalGroupPositionDataNode + ) + + def resolve_currently_admission_internal_group_position_data( + self, info, *args, **kwargs + ): + return ( + Admission.get_active_admission().available_internal_group_positions_data.all() + ) + + def resolve_interview_schedule_template(self, info, *args, **kwargs): + return InterviewScheduleTemplate.get_interview_schedule_template() + + def resolve_all_interview_schedule_templates(self, info, *args, **kwargs): + return InterviewScheduleTemplate.objects.all() def resolve_active_admission(self, info, *args, **kwargs): admission = Admission.objects.filter(~Q(status="closed")) @@ -153,6 +285,122 @@ def resolve_active_admission(self, info, *args, **kwargs): def resolve_all_admissions(self, info, *args, **kwargs): return Admission.objects.all().order_by("-date") + def resolve_externally_available_internal_group_positions( + self, info, *args, **kwargs + ): + return InternalGroupPosition.objects.filter(available_externally=True).order_by( + "name" + ) + + +class InterviewLocationDateGrouping(graphene.ObjectType): + name = graphene.String() + interviews = graphene.List(InterviewNode) + + +class InterviewDay(graphene.ObjectType): + date = graphene.Date() + locations = graphene.List(InterviewLocationDateGrouping) + + +class InterviewOverviewQuery(graphene.ObjectType): + interview_day_groupings = graphene.List(InterviewDay) + interview_schedule_template = graphene.Field(InterviewScheduleTemplateNode) + interview_count = graphene.Int() + admission_id = graphene.ID() + + +# TODO ADD SOME KIND OF ORERING FIELDS TO THIS +class InterviewBooleanEvaluationStatementNode(DjangoObjectType): + class Meta: + model = InterviewBooleanEvaluation + interfaces = (Node,) + + +class InterviewAdditionalEvaluationStatementNode(DjangoObjectType): + class Meta: + model = InterviewAdditionalEvaluationStatement + interfaces = (Node,) + + +class InterviewTemplate(graphene.ObjectType): + interview_boolean_evaluation_statements = graphene.List( + InterviewBooleanEvaluationStatementNode + ) + interview_additional_evaluation_statements = graphene.List( + InterviewAdditionalEvaluationStatementNode + ) + + +class InterviewQuery(graphene.ObjectType): + interview = Node.Field(InterviewNode) + interview_template = graphene.Field(InterviewTemplate) + + def resolve_interview_template(self, info, *args, **kwargs): + all_boolean_evaluation_statements = ( + InterviewBooleanEvaluation.objects.all().order_by("order") + ) + all_additional_evaluation_statements = ( + InterviewAdditionalEvaluationStatement.objects.all().order_by("order") + ) + return InterviewTemplate( + interview_boolean_evaluation_statements=all_boolean_evaluation_statements, + interview_additional_evaluation_statements=all_additional_evaluation_statements, + ) + + +class InterviewLocationQuery(graphene.ObjectType): + all_interview_locations = graphene.List(InterviewLocationNode) + interview_overview = graphene.Field(InterviewOverviewQuery) + + def resolve_interview_overview(self, info, *args, **kwargs): + # We want to return all interviews in an orderly manner grouped by date and locations. + interview_days = [] + schedule = InterviewScheduleTemplate.get_interview_schedule_template() + date_cursor = schedule.interview_period_start_date + interview_period_end = schedule.interview_period_end_date + next_day = timezone.timedelta(days=1) + start_of_day = datetime.time(hour=0, minute=0, second=0) + while date_cursor <= interview_period_end: + interview_locations = [] + # First we retrieve all interviews in a 24 hour time period + datetime_cursor = date_time_combiner(date_cursor, start_of_day) + interviews = Interview.objects.filter( + interview_start__gt=datetime_cursor, + interview_end__lt=datetime_cursor + next_day, + ) + # Then we want to group them by interview location + for location in InterviewLocation.objects.all().order_by("name"): + location_filtered_interviews = interviews.filter( + location=location + ).order_by("interview_start") + + # We only care about adding an entry if there are interviews in the given location + if location_filtered_interviews: + interview_location_date_grouping = InterviewLocationDateGrouping( + name=location.name, interviews=location_filtered_interviews + ) + interview_locations.append(interview_location_date_grouping) + + # We have found all interviews for this day, now we add it to the main query list + # if it isn't empty + if interview_locations: + interview_days.append( + InterviewDay(date=date_cursor, locations=interview_locations) + ) + date_cursor += next_day + + total_interviews = Interview.objects.all().count() + return InterviewOverviewQuery( + interview_day_groupings=interview_days, + interview_schedule_template=schedule, + interview_count=total_interviews, + admission_id=Admission.get_active_admission().id, + ) + + def resolve_all_interview_locations(self, info, *args, **kwargs): + return InterviewLocation.objects.all().order_by("name") + class CreateApplicantMutation(DjangoCreateMutation): class Meta: @@ -184,7 +432,7 @@ class Meta: model = Admission -class GenerateInterviewScheduleMutation(graphene.Mutation): +class GenerateInterviewsMutation(graphene.Mutation): class Arguments: pass @@ -199,7 +447,7 @@ def mutate(self, info, *args, **kwargs): generate_interviews_from_schedule(schedule) num = Interview.objects.all().count() - return GenerateInterviewScheduleMutation(ok=True, interviews_generated=num) + return GenerateInterviewsMutation(ok=True, interviews_generated=num) class ObfuscateAdmissionMutation(graphene.Mutation): @@ -217,6 +465,108 @@ def mutate(self, info, *args, **kwargs): return ObfuscateAdmissionMutation(ok=True) +class CreateInterviewLocationAvailability(DjangoCreateMutation): + class Meta: + model = InterviewLocationAvailability + + +class CreateInterviewLocationMutation(DjangoCreateMutation): + class Meta: + model = InterviewLocation + + +class DeleteInterviewLocationMutation(DjangoDeleteMutation): + class Meta: + model = InterviewLocation + exclude_fields = ("order",) + + +class DeleteInterviewLocationAvailabilityMutation(DjangoDeleteMutation): + class Meta: + model = InterviewLocationAvailability + + +class PatchInterviewScheduleTemplateMutation(DjangoPatchMutation): + class Meta: + model = InterviewScheduleTemplate + + +class CreateInterviewBooleanEvaluationMutation(DjangoCreateMutation): + class Meta: + model = InterviewBooleanEvaluation + exclude_fields = ("order",) + + @classmethod + def before_mutate(cls, root, info, input): + increment = ( + InterviewBooleanEvaluation.objects.all().order_by(("order")).last().order + + 1 + ) + input["order"] = increment + return input + + +class PatchInterviewBooleanEvaluationMutation(DjangoPatchMutation): + class Meta: + model = InterviewBooleanEvaluation + + +class DeleteInterviewBooleanEvaluationMutation(DjangoDeleteMutation): + class Meta: + model = InterviewBooleanEvaluation + + +class CreateInterviewAdditionalEvaluationStatementMutation(DjangoCreateMutation): + class Meta: + model = InterviewAdditionalEvaluationStatement + exclude_fields = ("order",) + + +class PatchInterviewAdditionalEvaluationStatementMutation(DjangoPatchMutation): + class Meta: + model = InterviewAdditionalEvaluationStatement + + +class DeleteInterviewAdditionalEvaluationStatementMutation(DjangoDeleteMutation): + class Meta: + model = InterviewAdditionalEvaluationStatement + + +class DeleteAllInterviewsMutation(graphene.Mutation): + count = graphene.Int() + + def mutate(self, info, *args, **kwargs): + admission = Admission.get_active_admission() + if admission.status == AdmissionStatus.OPEN.value: + raise SuspiciousOperation("Admission is open, cannot delete") + interviews = Interview.objects.all().all() + count = interviews.count() + interviews.delete() + return DeleteAllInterviewsMutation(count=count) + + +class PatchAdmissionAvailableInternalGroupPositionData(DjangoPatchMutation): + class Meta: + model = AdmissionAvailableInternalGroupPositionData + + +class CreateAdmissionAvailableInternalGroupPositionData(DjangoCreateMutation): + class Meta: + model = AdmissionAvailableInternalGroupPositionData + exclude_fields = ("admission",) + + @classmethod + def before_mutate(cls, root, info, input): + admission_id = Admission.get_active_admission().id + input["admission"] = admission_id + return input + + +class DeleteAdmissionAvailableInternalGroupPositionData(DjangoDeleteMutation): + class Meta: + model = AdmissionAvailableInternalGroupPositionData + + class AdmissionsMutations(graphene.ObjectType): create_applicant = CreateApplicantMutation.Field() patch_applicant = PatchApplicantMutation.Field() @@ -228,6 +578,43 @@ class AdmissionsMutations(graphene.ObjectType): patch_admission = PatchAdmissionMutation.Field() delete_admission = DeleteAdmissionMutation.Field() + patch_interview_schedule_template = PatchInterviewScheduleTemplateMutation.Field() + create_interview_location_availability = CreateInterviewLocationAvailability.Field() + delete_interview_location_availability = ( + DeleteInterviewLocationAvailabilityMutation.Field() + ) + + create_interview_location = CreateInterviewLocationMutation.Field() + delete_interview_location = DeleteInterviewLocationMutation.Field() + + create_interview_boolean_evaluation = ( + CreateInterviewBooleanEvaluationMutation.Field() + ) + delete_interview_boolean_evaluation = ( + DeleteInterviewBooleanEvaluationMutation.Field() + ) + patch_interview_boolean_evaluation = PatchInterviewBooleanEvaluationMutation.Field() + + create_interview_additional_evaluation_statement = ( + CreateInterviewAdditionalEvaluationStatementMutation.Field() + ) + delete_interview_additional_evaluation_statement = ( + DeleteInterviewAdditionalEvaluationStatementMutation.Field() + ) + patch_interview_additional_evaluation_statement = ( + PatchInterviewAdditionalEvaluationStatementMutation.Field() + ) + patch_admission_available_internal_group_position_data = ( + PatchAdmissionAvailableInternalGroupPositionData.Field() + ) + create_admission_available_internal_group_position_data = ( + CreateAdmissionAvailableInternalGroupPositionData.Field() + ) + delete_admission_available_internal_group_position_data = ( + DeleteAdmissionAvailableInternalGroupPositionData.Field() + ) + re_send_application_token = ResendApplicantTokenMutation.Field() - generate_interviews_from_schedule = GenerateInterviewScheduleMutation.Field() + generate_interviews = GenerateInterviewsMutation.Field() obfuscate_admission = ObfuscateAdmissionMutation.Field() + delete_all_interviews = DeleteAllInterviewsMutation.Field() diff --git a/admissions/tests/test_utils.py b/admissions/tests/test_utils.py index ad1b6d6b..5ff5e593 100644 --- a/admissions/tests/test_utils.py +++ b/admissions/tests/test_utils.py @@ -179,3 +179,41 @@ def test__obfuscate_admission__changes__identifying_information(self): self.assertNotEqual(self.sander.last_name, "Haga") self.assertNotEqual(self.sander.phone, "87654321") self.assertNotEqual(self.sander.address, "Klostergata 35") + + +class TestInterviewGenerationEdgeCases(TestCase): + def setUp(self) -> None: + # We set up 3 locations for interviews + self.knaus = InterviewLocation.objects.create(name="Knaus") + self.bodegaen = InterviewLocation.objects.create(name="Bodegaen") + + # Initialize the start of the interview period to 12:00 + self.start = datetime.date.today() + self.datetime_start = date_time_combiner(self.start, datetime.time(hour=12)) + + # End of interview period is two days later giving us a three day interview period + self.interview_period_end_date = self.start + timezone.timedelta(days=2) + + self.schedule = InterviewScheduleTemplate.objects.create( + interview_period_start_date=self.start, + interview_period_end_date=self.interview_period_end_date, + default_interview_day_start=datetime.time(hour=12), + default_interview_day_end=datetime.time(hour=20), + ) + InterviewLocationAvailability.objects.create( + interview_location=self.knaus, + datetime_from=self.datetime_start, + datetime_to=self.datetime_start + timezone.timedelta(hours=8), + ) + InterviewLocationAvailability.objects.create( + interview_location=self.bodegaen, + datetime_from=self.datetime_start + timezone.timedelta(hours=4), + datetime_to=self.datetime_start + timezone.timedelta(hours=8), + ) + + def test__interview_location_not_available_for_first_half__does_not_create_early_interview( + self, + ): + generate_interviews_from_schedule(self.schedule) + print(Interview.objects.all()) + self.assertEqual(Interview.objects.all().count(), 14) diff --git a/ksg_nett/schema.py b/ksg_nett/schema.py index 1d922cf3..d5eeceb3 100644 --- a/ksg_nett/schema.py +++ b/ksg_nett/schema.py @@ -1,5 +1,11 @@ import graphene -from admissions.schema import AdmissionQuery, ApplicantQuery, AdmissionsMutations +from admissions.schema import ( + AdmissionQuery, + ApplicantQuery, + AdmissionsMutations, + InterviewLocationQuery, + InterviewQuery, +) from common.schema import DashboardQuery, SidebarQuery from users.schema import UserQuery, UserMutations from login.schema import LoginMutations, AuthenticationQuery @@ -43,6 +49,7 @@ from summaries.schema import SummaryQuery, SummariesMutations from events.schema import EventQuery, EventMutations + class Query( AdmissionQuery, ApplicantQuery, @@ -61,6 +68,8 @@ class Query( InternalGroupQuery, InternalGroupPositionQuery, InternalGroupPositionMembershipQuery, + InterviewQuery, + InterviewLocationQuery, ShiftQuery, ScheduleQuery, ShiftSlotQuery, diff --git a/ksg_nett/settings.py b/ksg_nett/settings.py index 3be7d664..ce232082 100644 --- a/ksg_nett/settings.py +++ b/ksg_nett/settings.py @@ -12,6 +12,7 @@ import os from datetime import timedelta +import warnings from corsheaders.defaults import default_headers @@ -155,7 +156,7 @@ LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +TIME_ZONE = "Europe/Belgrade" USE_I18N = True @@ -163,6 +164,13 @@ USE_TZ = True +warnings.filterwarnings( + "error", + r"DateTimeField .* received a naive datetime", + RuntimeWarning, + r"django\.db\.models\.fields", +) + # Override in production LOCALE_PATHS = [ "locales/", @@ -188,7 +196,6 @@ # Graphql GRAPHENE = {"SCHEMA": "ksg_nett.schema.schema"} - # Media STATIC_URL = "/static/" STATIC_ROOT = "static/" @@ -225,7 +232,6 @@ # This should be changed before production. SENSOR_API_TOKEN = "3@Zhg$nH^Dlhw23R" - # API DOCS # ------------------------------ SWAGGER_SETTINGS = { @@ -250,7 +256,6 @@ } } - # Redis REDIS = { "host": os.environ.get("REDIS_HOST", "localhost"), @@ -258,7 +263,6 @@ } CHAT_STATE_REDIS_DB = 1 - # Load local and production settings try: from .settings_local import * diff --git a/organization/migrations/0024_internalgroupposition_available_externally.py b/organization/migrations/0024_internalgroupposition_available_externally.py new file mode 100644 index 00000000..9434e613 --- /dev/null +++ b/organization/migrations/0024_internalgroupposition_available_externally.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-02-24 20:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organization", "0023_auto_20220130_2056"), + ] + + operations = [ + migrations.AddField( + model_name="internalgroupposition", + name="available_externally", + field=models.BooleanField(default=False), + ), + ] diff --git a/organization/models.py b/organization/models.py index 4a6a12e4..8420fa01 100644 --- a/organization/models.py +++ b/organization/models.py @@ -104,6 +104,8 @@ class Meta: unique_together = ("name", "internal_group") name = models.CharField(max_length=32) + # We mark if this position is usually available to external applicants + available_externally = models.BooleanField(default=False) internal_group = models.ForeignKey( InternalGroup, null=False, diff --git a/users/schema.py b/users/schema.py index 5854d933..601271aa 100644 --- a/users/schema.py +++ b/users/schema.py @@ -16,6 +16,7 @@ from economy.schema import BankAccountActivity from users.filters import UserFilter from graphql_relay import to_global_id +from schedules.schemas.schema_schedules import ShiftNode class UserNode(DjangoObjectType): @@ -37,6 +38,11 @@ class Meta: all_permissions = graphene.NonNull(graphene.List(graphene.String)) upvoted_quote_ids = graphene.NonNull(graphene.List(graphene.ID)) + future_shifts = graphene.List(ShiftNode) + + def resolver_future_shifts(self: User, info, *args, **kwargs): + return self.future_shifts + def resolve_upvoted_quote_ids(self: User, info, **kwargs): return [ to_global_id("QuoteNode", quote_vote.quote.id) From f4694bdc70d6a383646568fa2fa58131f5d08a12 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Fri, 25 Feb 2022 13:40:52 +0100 Subject: [PATCH 04/32] chore(admissions): schema code tidying --- admissions/schema.py | 82 +++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index a9de36fe..614401a3 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -7,7 +7,6 @@ DjangoPatchMutation, DjangoDeleteMutation, DjangoCreateMutation, - DjangoBatchPatchMutation, ) from common.util import date_time_combiner from django.db.models import Q @@ -262,9 +261,8 @@ class AdmissionQuery(graphene.ObjectType): def resolve_currently_admission_internal_group_position_data( self, info, *args, **kwargs ): - return ( - Admission.get_active_admission().available_internal_group_positions_data.all() - ) + admission = Admission.get_active_admission() + return admission.available_internal_group_positions_data.all() def resolve_interview_schedule_template(self, info, *args, **kwargs): return InterviewScheduleTemplate.get_interview_schedule_template() @@ -304,13 +302,13 @@ class InterviewDay(graphene.ObjectType): class InterviewOverviewQuery(graphene.ObjectType): + # We use this to orderly structure the interview overview for each day in the interview period interview_day_groupings = graphene.List(InterviewDay) interview_schedule_template = graphene.Field(InterviewScheduleTemplateNode) interview_count = graphene.Int() admission_id = graphene.ID() -# TODO ADD SOME KIND OF ORERING FIELDS TO THIS class InterviewBooleanEvaluationStatementNode(DjangoObjectType): class Meta: model = InterviewBooleanEvaluation @@ -402,6 +400,7 @@ def resolve_all_interview_locations(self, info, *args, **kwargs): return InterviewLocation.objects.all().order_by("name") +# === Applicant === class CreateApplicantMutation(DjangoCreateMutation): class Meta: model = Applicant @@ -417,6 +416,7 @@ class Meta: model = Applicant +# === Admission === class CreateAdmissionMutation(DjangoCreateMutation): class Meta: model = Admission @@ -432,11 +432,24 @@ class Meta: model = Admission -class GenerateInterviewsMutation(graphene.Mutation): +class ObfuscateAdmissionMutation(graphene.Mutation): class Arguments: pass ok = graphene.Boolean() + + def mutate(self, info, *args, **kwargs): + admission = Admission.get_active_admission() + if not admission: + return ObfuscateAdmissionMutation(ok=False) + + obfuscate_admission(admission) + return ObfuscateAdmissionMutation(ok=True) + + +# === Interview === +class GenerateInterviewsMutation(graphene.Mutation): + ok = graphene.Boolean() interviews_generated = graphene.Int() def mutate(self, info, *args, **kwargs): @@ -450,26 +463,20 @@ def mutate(self, info, *args, **kwargs): return GenerateInterviewsMutation(ok=True, interviews_generated=num) -class ObfuscateAdmissionMutation(graphene.Mutation): - class Arguments: - pass - - ok = graphene.Boolean() +class DeleteAllInterviewsMutation(graphene.Mutation): + count = graphene.Int() def mutate(self, info, *args, **kwargs): admission = Admission.get_active_admission() - if not admission: - return ObfuscateAdmissionMutation(ok=False) - - obfuscate_admission(admission) - return ObfuscateAdmissionMutation(ok=True) - - -class CreateInterviewLocationAvailability(DjangoCreateMutation): - class Meta: - model = InterviewLocationAvailability + if admission.status == AdmissionStatus.OPEN.value: + raise SuspiciousOperation("Admission is open, cannot delete") + interviews = Interview.objects.all().all() + count = interviews.count() + interviews.delete() + return DeleteAllInterviewsMutation(count=count) +# === InterviewLocation === class CreateInterviewLocationMutation(DjangoCreateMutation): class Meta: model = InterviewLocation @@ -481,16 +488,24 @@ class Meta: exclude_fields = ("order",) +# === InterviewLocationAvailability === +class CreateInterviewLocationAvailability(DjangoCreateMutation): + class Meta: + model = InterviewLocationAvailability + + class DeleteInterviewLocationAvailabilityMutation(DjangoDeleteMutation): class Meta: model = InterviewLocationAvailability +# === InterviewScheduleTemplate === class PatchInterviewScheduleTemplateMutation(DjangoPatchMutation): class Meta: model = InterviewScheduleTemplate +# === InterviewBooleanEvaluation === class CreateInterviewBooleanEvaluationMutation(DjangoCreateMutation): class Meta: model = InterviewBooleanEvaluation @@ -516,6 +531,7 @@ class Meta: model = InterviewBooleanEvaluation +# === InterviewAdditionalEvaluationStatement === class CreateInterviewAdditionalEvaluationStatementMutation(DjangoCreateMutation): class Meta: model = InterviewAdditionalEvaluationStatement @@ -532,24 +548,7 @@ class Meta: model = InterviewAdditionalEvaluationStatement -class DeleteAllInterviewsMutation(graphene.Mutation): - count = graphene.Int() - - def mutate(self, info, *args, **kwargs): - admission = Admission.get_active_admission() - if admission.status == AdmissionStatus.OPEN.value: - raise SuspiciousOperation("Admission is open, cannot delete") - interviews = Interview.objects.all().all() - count = interviews.count() - interviews.delete() - return DeleteAllInterviewsMutation(count=count) - - -class PatchAdmissionAvailableInternalGroupPositionData(DjangoPatchMutation): - class Meta: - model = AdmissionAvailableInternalGroupPositionData - - +# === AdmissionAvailableInternalGroupPositionData === class CreateAdmissionAvailableInternalGroupPositionData(DjangoCreateMutation): class Meta: model = AdmissionAvailableInternalGroupPositionData @@ -562,6 +561,11 @@ def before_mutate(cls, root, info, input): return input +class PatchAdmissionAvailableInternalGroupPositionData(DjangoPatchMutation): + class Meta: + model = AdmissionAvailableInternalGroupPositionData + + class DeleteAdmissionAvailableInternalGroupPositionData(DjangoDeleteMutation): class Meta: model = AdmissionAvailableInternalGroupPositionData From 235c39243e5fa5bba7657c17352079dd01fb45d0 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:08:37 +0100 Subject: [PATCH 05/32] feat(admissions): add missing related names and fields * Interview boolean statement rel name * AdditionalEvaluation statement rel name * digital boolean value on interview * Interview.applicant related name --- .../0023_alter_applicant_interview.py | 28 +++++++++++ .../0024_applicant_wants_digital_interview.py | 18 +++++++ .../migrations/0025_auto_20220228_0253.py | 31 ++++++++++++ .../migrations/0026_auto_20220228_0302.py | 48 +++++++++++++++++++ .../migrations/0027_alter_admission_date.py | 21 ++++++++ admissions/models.py | 25 +++++++--- 6 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 admissions/migrations/0023_alter_applicant_interview.py create mode 100644 admissions/migrations/0024_applicant_wants_digital_interview.py create mode 100644 admissions/migrations/0025_auto_20220228_0253.py create mode 100644 admissions/migrations/0026_auto_20220228_0302.py create mode 100644 admissions/migrations/0027_alter_admission_date.py diff --git a/admissions/migrations/0023_alter_applicant_interview.py b/admissions/migrations/0023_alter_applicant_interview.py new file mode 100644 index 00000000..9d0eb1ed --- /dev/null +++ b/admissions/migrations/0023_alter_applicant_interview.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.12 on 2022-02-25 13:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "admissions", + "0022_alter_admissionavailableinternalgrouppositiondata_admission", + ), + ] + + operations = [ + migrations.AlterField( + model_name="applicant", + name="interview", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="applicant", + to="admissions.interview", + ), + ), + ] diff --git a/admissions/migrations/0024_applicant_wants_digital_interview.py b/admissions/migrations/0024_applicant_wants_digital_interview.py new file mode 100644 index 00000000..61196d6e --- /dev/null +++ b/admissions/migrations/0024_applicant_wants_digital_interview.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-02-27 22:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0023_alter_applicant_interview"), + ] + + operations = [ + migrations.AddField( + model_name="applicant", + name="wants_digital_interview", + field=models.BooleanField(default=False), + ), + ] diff --git a/admissions/migrations/0025_auto_20220228_0253.py b/admissions/migrations/0025_auto_20220228_0253.py new file mode 100644 index 00000000..6d4fae3e --- /dev/null +++ b/admissions/migrations/0025_auto_20220228_0253.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.12 on 2022-02-28 01:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0024_applicant_wants_digital_interview"), + ] + + operations = [ + migrations.AlterField( + model_name="interview", + name="additional_evaluations", + field=models.ManyToManyField( + related_name="additional_evaluation_statement_answers", + through="admissions.InterviewAdditionalEvaluationAnswer", + to="admissions.InterviewAdditionalEvaluationStatement", + ), + ), + migrations.AlterField( + model_name="interview", + name="boolean_evaluations", + field=models.ManyToManyField( + related_name="boolean_evaluation_answers", + through="admissions.InterviewBooleanEvaluationAnswer", + to="admissions.InterviewBooleanEvaluation", + ), + ), + ] diff --git a/admissions/migrations/0026_auto_20220228_0302.py b/admissions/migrations/0026_auto_20220228_0302.py new file mode 100644 index 00000000..21cb0ee2 --- /dev/null +++ b/admissions/migrations/0026_auto_20220228_0302.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.12 on 2022-02-28 02:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0025_auto_20220228_0253"), + ] + + operations = [ + migrations.AlterField( + model_name="interview", + name="additional_evaluations", + field=models.ManyToManyField( + through="admissions.InterviewAdditionalEvaluationAnswer", + to="admissions.InterviewAdditionalEvaluationStatement", + ), + ), + migrations.AlterField( + model_name="interview", + name="boolean_evaluations", + field=models.ManyToManyField( + through="admissions.InterviewBooleanEvaluationAnswer", + to="admissions.InterviewBooleanEvaluation", + ), + ), + migrations.AlterField( + model_name="interviewadditionalevaluationanswer", + name="interview", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="additional_evaluation_statement_answers", + to="admissions.interview", + ), + ), + migrations.AlterField( + model_name="interviewbooleanevaluationanswer", + name="interview", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="boolean_evaluation_answers", + to="admissions.interview", + ), + ), + ] diff --git a/admissions/migrations/0027_alter_admission_date.py b/admissions/migrations/0027_alter_admission_date.py new file mode 100644 index 00000000..905e49da --- /dev/null +++ b/admissions/migrations/0027_alter_admission_date.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.12 on 2022-02-28 12:01 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0026_auto_20220228_0302"), + ] + + operations = [ + migrations.AlterField( + model_name="admission", + name="date", + field=models.DateField( + blank=True, default=django.utils.timezone.now, null=True + ), + ), + ] diff --git a/admissions/models.py b/admissions/models.py index 367da8e7..288c91a0 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -38,7 +38,7 @@ class Meta: class Admission(models.Model): - date = models.DateField(blank=True, null=True, auto_now=True) + date = models.DateField(blank=True, null=True, default=timezone.now) status = models.CharField( choices=AdmissionStatus.choices, default=AdmissionStatus.OPEN, max_length=32 ) @@ -92,7 +92,11 @@ class InterviewBooleanEvaluationAnswer(models.Model): class Meta: unique_together = ("interview", "statement") - interview = models.ForeignKey("admissions.Interview", on_delete=models.CASCADE) + interview = models.ForeignKey( + "admissions.Interview", + on_delete=models.CASCADE, + related_name="boolean_evaluation_answers", + ) statement = models.ForeignKey( "admissions.InterviewBooleanEvaluation", on_delete=models.CASCADE ) @@ -133,7 +137,11 @@ class Options(models.TextChoices): SOMEWHAT = ("somewhat", _("Somewhat")) VERY = ("very", _("Very")) - interview = models.ForeignKey("admissions.Interview", on_delete=models.CASCADE) + interview = models.ForeignKey( + "admissions.Interview", + on_delete=models.CASCADE, + related_name="additional_evaluation_statement_answers", + ) statement = models.ForeignKey( "admissions.InterviewAdditionalEvaluationStatement", on_delete=models.CASCADE ) @@ -229,6 +237,8 @@ class Applicant(models.Model): address = models.CharField(default="", blank=True, max_length=30) hometown = models.CharField(default="", blank=True, max_length=30) + wants_digital_interview = models.BooleanField(default=False) + def image_dir(self, filename): # We want to save all objects in under the admission return osjoin("applicants", str(self.admission.semester), filename) @@ -244,18 +254,21 @@ def image_dir(self, filename): ) interview = models.OneToOneField( - Interview, on_delete=models.CASCADE, null=True, blank=True + Interview, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="applicant", ) @classmethod def create_or_update_application(cls, email): """Can extend this method in the future to handle adding applications to new positions""" + # We can consider changing this to send the email with bcc and then the link kan be requested current_admission = Admission.get_or_create_current_admission() auth_token = token_urlsafe(32) cls.objects.create(email=email, admission=current_admission, token=auth_token) - return send_welcome_to_interview_email(email, auth_token) - @property def get_full_name(self): return f"{self.first_name} {self.last_name}" From cdced1c142f95bc5e9de223c5d0f67567c3acdda Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:10:40 +0100 Subject: [PATCH 06/32] feat(common): add general utility helpers and formatting --- admissions/utils.py | 182 +++++++++++++++++++++++++++++++++++++------- common/util.py | 37 ++++++++- 2 files changed, 191 insertions(+), 28 deletions(-) diff --git a/admissions/utils.py b/admissions/utils.py index e3ac426c..7391822a 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -5,7 +5,12 @@ from django.conf import settings from django.apps import apps import csv -from common.util import date_time_combiner +from common.util import ( + date_time_combiner, + get_date_from_datetime, + parse_datetime_to_midnight, + validate_qs, +) def get_available_interview_locations(datetime_from=None, datetime_to=None): @@ -51,14 +56,12 @@ def generate_interviews_from_schedule(schedule): Interview = apps.get_model(app_label="admissions", model_name="Interview") while datetime_cursor < datetime_interview_period_end: - print(f"{datetime_cursor}") # Generate interviews for the first session of the day for i in range(schedule.default_block_size): available_locations = get_available_interview_locations( datetime_from=datetime_cursor, datetime_to=datetime_cursor + interview_duration, ) - print(f"{available_locations.count()} locations are available") for location in available_locations: print( f"Looking at {location} from {datetime_cursor} to {datetime_cursor + interview_duration}" @@ -103,32 +106,76 @@ def generate_interviews_from_schedule(schedule): ) -def send_welcome_to_interview_email(email: str, auth_token: str): +def mass_send_welcome_to_interview_email(emails): + """ + Accepts a list of emails and sends the same email ass bcc to all the recipients. + Main advantage here is that we do not need to batch together 150 emails which causes + timeouts and slow performance. The applicant can then instead request to be sent their + custom auth token from the portal itself. + """ content = ( _( """ Hei og velkommen til intervju hos KSG! - + Trykk på denne linken for å registrere søknaden videre - + Lenke: %(link)s """ ) - % {"link": f"{settings.APP_URL}/applicant-portal/{auth_token}"} + % {"link": f"{settings.APP_URL}/applicant-portal"} ) html_content = ( _( """ - Hei og velkommen til intervju hos KSG! -
-
- Trykk på denne linken for å registrere søknaden videre -
- Registrer søknad
-
+ Hei og velkommen til intervju hos KSG! +
+
+ Trykk på denne linken for å registrere søknaden videre +
+ Registrer søknad
+
""" ) + % {"link": f"{settings.APP_URL}/applicant-portal"} + ) + + return send_email( + _("Intervju KSG"), + message=content, + html_message=html_content, + recipients=[], + bcc=emails, + ) + + +def send_welcome_to_interview_email(email: str, auth_token: str): + content = ( + _( + """ + Hei og velkommen til intervju hos KSG! + + Trykk på denne linken for å registrere søknaden videre + + Lenke: %(link)s + """ + ) + % {"link": f"{settings.APP_URL}/applicant-portal/{auth_token}"} + ) + + html_content = ( + _( + """ + Hei og velkommen til intervju hos KSG! +
+
+ Trykk på denne linken for å registrere søknaden videre +
+ Registrer søknad
+
+ """ + ) % {"link": f"{settings.APP_URL}/applicant-portal/{auth_token}"} ) @@ -144,12 +191,12 @@ def resend_auth_token_email(applicant): content = ( _( """ - Hei og velkommen til KSG sin søkerportal! - - Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. - - Lenke: %(link)s - """ + Hei og velkommen til KSG sin søkerportal! + + Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. + + Lenke: %(link)s + """ ) % {"link": f"{settings.APP_URL}/applicant-portal/{applicant.token}"} ) @@ -157,13 +204,13 @@ def resend_auth_token_email(applicant): html_content = ( _( """ - Hei og velkommen til KSG sin søkerportal! -
- Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. -
- Registrer søknad
-
- """ + Hei og velkommen til KSG sin søkerportal! +
+ Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. +
+ Registrer søknad
+
+ """ ) % {"link": f"{settings.APP_URL}/applicant-portal/{applicant.token}"} ) @@ -211,3 +258,84 @@ def obfuscate_admission(admission): applicant.address = fake_data.address applicant.phone = fake_data.phone applicant.save() + + +def group_interviews_by_date(interviews): + """ + We accept a queryset and sort it into a list of groupings. Each item in the list is a dict which has a + day defined through a date object and a list of interviews that occur on this date. + + Example: + [ + { + "date": 2022-24-02, + "interviews": [Interview1, Interview2, ...] + }, + { + "date": 2022-25-02, + "interviews": [Interview1, Interview2, ...] + } + ... + ] + """ + validate_qs(interviews) + + interviews = interviews.order_by("interview_start") + cursor = parse_datetime_to_midnight(interviews.first().interview_start) + cursor_end = interviews.last().interview_start + day_offset = timezone.timedelta(days=1) + interview_groupings = [] + + while cursor <= cursor_end: + grouping = interviews.filter( + interview_start__gte=cursor, interview_start__lte=cursor + day_offset + ) + if grouping: + interview_groupings.append( + {"date": get_date_from_datetime(cursor), "interviews": grouping} + ) + cursor += day_offset + return interview_groupings + + +def create_interview_slots(interview_days): + """ + Input is assumed to be a list of dictionary objects with a 'date' and 'interviews' keys. The interviews object + are ordered in ascending order. We return a nested structure which groups together days with interviews + in addition to grouping interviews that have the same timeframe together. + """ + from admissions.models import Interview # Avoid circular import error + + if not interview_days: + return [] + + first_interview = interview_days[0]["interviews"][0] + if not first_interview: + return [] + + # We use the inferred duration as our cursor offset + inferred_interview_duration = ( + first_interview.interview_end - first_interview.interview_start + ) + parsed_interviews = [] + + for day in interview_days: + interviews = day["interviews"] + last_interview = interviews.last().interview_end + + if not interviews: + continue + + cursor = interviews[0].interview_start + day_groupings = [] + while cursor < last_interview: + # Interviews are always created in parallel. Meaning we can use the exact datetime to filter + interview_group = Interview.objects.filter(interview_start=cursor) + slot = {"timestamp": cursor, "interviews": interview_group} + day_groupings.append(slot) + cursor += inferred_interview_duration + + interview_day = {"date": day["date"], "groupings": day_groupings} + parsed_interviews.append(interview_day) + + return parsed_interviews diff --git a/common/util.py b/common/util.py index 20a3337b..ad1faa4b 100644 --- a/common/util.py +++ b/common/util.py @@ -1,3 +1,4 @@ +import random import re from io import BytesIO from sys import getsizeof @@ -6,9 +7,11 @@ from PIL import Image from pydash import strip_tags from django.core.mail import send_mail, EmailMultiAlternatives, get_connection +from graphql_relay import from_global_id from django.core.files.uploadedfile import InMemoryUploadedFile from django.utils import timezone +from django.db.models import QuerySet def get_semester_year_shorthand(timestamp: Union[datetime, date]) -> str: @@ -204,7 +207,7 @@ def send_email( subject="KSG-nett", message="", html_message="", - sender="ksg-no-reply@samfundet.no", + sender="no-reply@ksg-nett.no", recipients=[], attachments=None, cc=[], @@ -243,3 +246,35 @@ def date_time_combiner(date: datetime.date, time: datetime.time): second=time.second, ) ) + + +def get_date_from_datetime(timestamp: timezone.datetime): + return date(year=timestamp.year, month=timestamp.month, day=timestamp.day) + + +def parse_datetime_to_midnight(timestamp: timezone.datetime): + """Accepts a datetime object and returns the same date but at midnight""" + return timezone.make_aware( + timezone.datetime( + year=timestamp.year, + month=timestamp.month, + day=timestamp.day, + hour=0, + minute=0, + second=0, + ) + ) + + +def validate_qs(queryset): + if not issubclass(QuerySet, queryset.__class__): + raise ValueError("Positional argument given is not a QuerySet") + + +def chose_random_element(iterable): + iterable_length = len(iterable) + if iterable_length == 0: + raise ValueError(f"Length of iterable is 0") + + random_number = random.randint(0, iterable_length - 1) + return iterable[random_number] From 4893d18afafc92c183e547554dcb83a6090c9470 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:13:29 +0100 Subject: [PATCH 07/32] feat(admissions): add interview related resolvers and mutations Implement resolvers and mutations related to the applicant interview booking workflow. With these mutations and resolvers a user can now request interviews available for booking in a way which is easy to understand and interact with. The implementation allows an applicant to book their interview from all available interview times and can also request interview times further ahead in time if none of the available options work for the applicant. Lastly rework implementation of email sending. Instead of sending an email for each newly created applicant instance we aggregate a list of emails of new applicants and mass mail them using bcc. The applicant can then request a login token so we do not hit any rate or spam limits on email --- admissions/schema.py | 151 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 4 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index 614401a3..8a5eb11f 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -1,6 +1,7 @@ import datetime import graphene from graphene import Node +from graphql_relay import to_global_id from graphene_django import DjangoObjectType from django.db import IntegrityError from graphene_django_cud.mutations import ( @@ -15,6 +16,9 @@ generate_interviews_from_schedule, resend_auth_token_email, obfuscate_admission, + group_interviews_by_date, + create_interview_slots, + mass_send_welcome_to_interview_email, ) from django.core.exceptions import SuspiciousOperation from django.utils import timezone @@ -33,9 +37,11 @@ from organization.models import InternalGroupPosition from organization.schema import InternalGroupPositionNode from admissions.filters import AdmissionFilter, ApplicantFilter -from admissions.consts import AdmissionStatus +from admissions.consts import AdmissionStatus, Priority, ApplicantStatus from graphene_django_cud.types import TimeDelta from graphene import Time, Date +from graphene_django_cud.util import disambiguate_id +from users.schema import UserNode class InternalGroupPositionPriorityNode(DjangoObjectType): @@ -75,6 +81,19 @@ class Meta: interfaces = (Node,) full_name = graphene.String(source="get_full_name") + priorities = graphene.List(InternalGroupPositionPriorityNode) + + def resolve_priorities(self: Applicant, info, *args, **kwargs): + first_priority = self.priorities.filter( + applicant_priority=Priority.FIRST + ).first() + second_priority = self.priorities.filter( + applicant_priority=Priority.SECOND + ).first() + third_priority = self.priorities.filter( + applicant_priority=Priority.THIRD + ).first() + return [first_priority, second_priority, third_priority] @classmethod def get_node(cls, info, id): @@ -177,11 +196,34 @@ def get_node(cls, info, id): return Admission.objects.get(pk=id) +class BooleanEvaluationAnswer(graphene.ObjectType): + statement = graphene.String() + answer = graphene.Boolean() + + class InterviewNode(DjangoObjectType): class Meta: model = Interview interfaces = (Node,) + interviewers = graphene.List(UserNode) + boolean_evaluation_answers = graphene.List(BooleanEvaluationAnswer) + + def resolve_boolean_evaluation_answers(self: Interview, info, *args, **kwargs): + evaluations = [] + for evaluation in self.boolean_evaluation_answers.all().order_by( + "statement__order" + ): + evaluations.append( + BooleanEvaluationAnswer( + statement=evaluation.statement.statement, answer=evaluation.value + ) + ) + return evaluations + + def resolve_interviewers(self: Interview, info, *args, **kwargs): + return self.interviewers.all() + @classmethod def get_node(cls, info, id): return Interview.objects.get(pk=id) @@ -189,9 +231,7 @@ def get_node(cls, info, id): class ApplicantQuery(graphene.ObjectType): applicant = Node.Field(ApplicantNode) - all_applicants = DjangoFilterConnectionField( - ApplicantNode, filterset_class=ApplicantFilter - ) + all_applicants = graphene.List(ApplicantNode) get_applicant_from_token = graphene.Field(ApplicantNode, token=graphene.String()) def resolve_get_applicant_from_token(self, info, token, *args, **kwargs): @@ -230,12 +270,15 @@ class Arguments: def mutate(self, info, emails): faulty_emails = [] + registered_emails = [] for email in emails: try: Applicant.create_or_update_application(email) + registered_emails.append(email) except IntegrityError: faulty_emails.append(email) + mass_send_welcome_to_interview_email(registered_emails) return CreateApplicationsMutation( ok=True, applications_created=len(emails) - len(faulty_emails), @@ -330,10 +373,24 @@ class InterviewTemplate(graphene.ObjectType): ) +class InterviewSlot(graphene.ObjectType): + interview_start = graphene.DateTime() + interview_ids = graphene.List(graphene.ID) + + +class AvailableInterviewsDayGrouping(graphene.ObjectType): + date = graphene.Date() + interview_slots = graphene.List(InterviewSlot) + + class InterviewQuery(graphene.ObjectType): interview = Node.Field(InterviewNode) interview_template = graphene.Field(InterviewTemplate) + interviews_available_for_booking = graphene.List( + AvailableInterviewsDayGrouping, day_offset=graphene.Int(required=True) + ) + def resolve_interview_template(self, info, *args, **kwargs): all_boolean_evaluation_statements = ( InterviewBooleanEvaluation.objects.all().order_by("order") @@ -346,6 +403,58 @@ def resolve_interview_template(self, info, *args, **kwargs): interview_additional_evaluation_statements=all_additional_evaluation_statements, ) + def resolve_interviews_available_for_booking( + self, info, day_offset, *args, **kwargs + ): + """ + The idea here is that we want to parse interviews in such a way that we only return + a timestamp for when the interview starts, and a list of ids for interviews that + are available for booking. This gives us a bit of security because if the interview + is available and an applicant tries to book the same as another one they can just + try one of the other interviews. + """ + # We get all interviews available for booking + now = timezone.datetime.now() + cursor = timezone.make_aware( + timezone.datetime( # Use midnight helper here + year=now.year, month=now.month, day=now.day, hour=0, minute=0, second=0 + ) + + timezone.timedelta(days=1) + ) + cursor += timezone.timedelta(days=day_offset) + cursor_offset = cursor + timezone.timedelta(days=2) + available_interviews = Interview.objects.filter( + applicant__isnull=True, + interview_start__gte=cursor, + interview_start__lte=cursor_offset, + ) + + available_interviews_timeslot_grouping = [] + parsed_interviews = create_interview_slots( + group_interviews_by_date(available_interviews) + ) + + for day in parsed_interviews: + timeslots = [] + for grouping in day["groupings"]: + interviews = grouping["interviews"] + interview_ids = [ + to_global_id("InterviewNode", interview.id) + for interview in interviews + ] + timeslots.append( + InterviewSlot( + interview_start=grouping["timestamp"], + interview_ids=interview_ids, + ) + ) + available_interviews_timeslot_grouping.append( + AvailableInterviewsDayGrouping( + date=day["date"], interview_slots=timeslots + ) + ) + return available_interviews_timeslot_grouping + class InterviewLocationQuery(graphene.ObjectType): all_interview_locations = graphene.List(InterviewLocationNode) @@ -476,6 +585,39 @@ def mutate(self, info, *args, **kwargs): return DeleteAllInterviewsMutation(count=count) +class BookInterviewMutation(graphene.Mutation): + class Arguments: + interview_ids = graphene.List(graphene.ID) + applicant_token = graphene.String() + + ok = graphene.Boolean() + + def mutate(self, info, interview_ids, applicant_token, *args, **kwargs): + applicant = Applicant.objects.get(token=applicant_token) + if getattr(applicant, "interview", None): + raise SuspiciousOperation("Applicant already has an interview") + + for interview_id in interview_ids: + try: + django_id = disambiguate_id(interview_id) + interview = Interview.objects.get(pk=django_id) + + interview.applicant = applicant + interview.save() + applicant.status = ApplicantStatus.SCHEDULED_INTERVIEW.value + applicant.save() + return BookInterviewMutation(ok=True) + + except IntegrityError: # Someone already booked this interview + pass + except Interview.DoesNotExist: + pass + except Applicant.DoesNotExist: + return BookInterviewMutation(ok=False) + + return BookInterviewMutation(ok=False) + + # === InterviewLocation === class CreateInterviewLocationMutation(DjangoCreateMutation): class Meta: @@ -620,5 +762,6 @@ class AdmissionsMutations(graphene.ObjectType): re_send_application_token = ResendApplicantTokenMutation.Field() generate_interviews = GenerateInterviewsMutation.Field() + book_interview = BookInterviewMutation.Field() obfuscate_admission = ObfuscateAdmissionMutation.Field() delete_all_interviews = DeleteAllInterviewsMutation.Field() From fe1267ffb531055e33ef63d596bbb77618d5cf05 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:22:37 +0100 Subject: [PATCH 08/32] chore: fix misc. model fields Remove som auto_now since they cause problems when mocking data. auto_now makes it very difficult to alter the datetime value --- common/models.py | 3 +- .../0013_alter_deposit_created_at.py | 19 +++++++++ .../0014_alter_productorder_purchased_at.py | 19 +++++++++ economy/models.py | 2 +- economy/tests/factories.py | 42 +++++++++++-------- organization/models.py | 13 ++++++ .../migrations/0014_alter_quote_created_at.py | 19 +++++++++ quotes/models.py | 2 + quotes/tests/factories.py | 6 +-- users/tests/factories.py | 1 - 10 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 economy/migrations/0013_alter_deposit_created_at.py create mode 100644 economy/migrations/0014_alter_productorder_purchased_at.py create mode 100644 quotes/migrations/0014_alter_quote_created_at.py diff --git a/common/models.py b/common/models.py index 1f86cf37..566a53f4 100644 --- a/common/models.py +++ b/common/models.py @@ -1,9 +1,10 @@ from django.db import models +from django.utils import timezone class TimestampedModel(models.Model): class Meta: abstract = True - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(default=timezone.datetime.now) updated_at = models.DateTimeField(auto_now=True) diff --git a/economy/migrations/0013_alter_deposit_created_at.py b/economy/migrations/0013_alter_deposit_created_at.py new file mode 100644 index 00000000..ad7e7dc9 --- /dev/null +++ b/economy/migrations/0013_alter_deposit_created_at.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-02-28 16:22 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0012_alter_deposit_description"), + ] + + operations = [ + migrations.AlterField( + model_name="deposit", + name="created_at", + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/economy/migrations/0014_alter_productorder_purchased_at.py b/economy/migrations/0014_alter_productorder_purchased_at.py new file mode 100644 index 00000000..0fc4122f --- /dev/null +++ b/economy/migrations/0014_alter_productorder_purchased_at.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-02-28 18:48 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0013_alter_deposit_created_at"), + ] + + operations = [ + migrations.AlterField( + model_name="productorder", + name="purchased_at", + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/economy/models.py b/economy/models.py index b939d1aa..13a68461 100644 --- a/economy/models.py +++ b/economy/models.py @@ -199,7 +199,7 @@ class ProductOrder(models.Model): default=SociSession.get_active_session, ) - purchased_at = models.DateTimeField(auto_now=True) + purchased_at = models.DateTimeField(default=timezone.datetime.now) @property def cost(self) -> int: diff --git a/economy/tests/factories.py b/economy/tests/factories.py index 265f0d8a..66643036 100644 --- a/economy/tests/factories.py +++ b/economy/tests/factories.py @@ -4,10 +4,16 @@ from factory.django import DjangoModelFactory from factory.django import ImageField -from economy.models import SociBankAccount, SociProduct, ProductOrder, SociSession, Transfer, Deposit, \ - DepositComment +from economy.models import ( + SociBankAccount, + SociProduct, + ProductOrder, + SociSession, + Transfer, + Deposit, + DepositComment, +) from ksg_nett import settings -from users.tests.factories import UserFactory class SociBankAccountFactory(DjangoModelFactory): @@ -15,9 +21,9 @@ class Meta: model = SociBankAccount django_get_or_create = ("user",) - user = SubFactory(UserFactory) + user = SubFactory("users.tests.factories.UserFactory") balance = 0 - card_uuid = Faker('ean') + card_uuid = Faker("ean") class SociProductFactory(DjangoModelFactory): @@ -25,20 +31,20 @@ class Meta: model = SociProduct sku_number = Sequence(lambda n: f"sku{n}") - name = Faker('word') - price = Faker('random_number', digits=4, fix_len=True) - description = Faker('sentence') + name = Faker("word") + price = Faker("random_number", digits=4, fix_len=True) + description = Faker("sentence") icon = "🤖" - end = Faker('future_datetime', tzinfo=pytz.timezone(settings.TIME_ZONE)) + end = Faker("future_datetime", tzinfo=pytz.timezone(settings.TIME_ZONE)) class SociSessionFactory(DjangoModelFactory): class Meta: model = SociSession - name = Faker('sentence') - start = Faker('past_datetime', tzinfo=pytz.timezone(settings.TIME_ZONE)) - signed_off_by = SubFactory(UserFactory) + name = Faker("sentence") + start = Faker("past_datetime", tzinfo=pytz.timezone(settings.TIME_ZONE)) + signed_off_by = SubFactory("users.tests.factories.UserFactory") class ProductOrderFactory(DjangoModelFactory): @@ -57,7 +63,7 @@ class Meta: source = SubFactory(SociBankAccountFactory) destination = SubFactory(SociBankAccountFactory) - amount = Faker('random_number', digits=4, fix_len=True) + amount = Faker("random_number", digits=4, fix_len=True) class DepositFactory(DjangoModelFactory): @@ -65,11 +71,11 @@ class Meta: model = Deposit account = SubFactory(SociBankAccountFactory) - description = Faker('text') - amount = Faker('random_number', digits=4, fix_len=True) + description = Faker("text") + amount = Faker("random_number", digits=4, fix_len=True) receipt = ImageField() - signed_off_by = SubFactory('users.tests.factories.UserFactory') + signed_off_by = SubFactory("users.tests.factories.UserFactory") signed_off_time = None @post_generation @@ -83,5 +89,5 @@ class Meta: model = DepositComment deposit = SubFactory(DepositFactory) - user = SubFactory('users.tests.factories.UserFactory') - comment = Faker('text') + user = SubFactory("users.tests.factories.UserFactory") + comment = Faker("text") diff --git a/organization/models.py b/organization/models.py index 8420fa01..c8b23fd4 100644 --- a/organization/models.py +++ b/organization/models.py @@ -54,6 +54,14 @@ def group_image_url(self) -> Optional[str]: def active_members_count(self) -> int: return len(self.active_members) + @classmethod + def get_internal_groups(cls): + return cls.objects.filter(type=cls.Type.INTERNAL_GROUP.value).order_by("name") + + @classmethod + def get_interest_groups(cls): + return cls.objects.filter(type=cls.Type.INTEREST_GROUP.value).order_by("name") + def __str__(self): return "Group %s" % self.name @@ -121,6 +129,10 @@ class Meta: through="organization.InternalGroupPositionMembership", ) + @classmethod + def get_externally_available_positions(cls): + return cls.objects.filter(available_externally=True).order_by("name") + @property def active_memberships(self): return self.memberships.filter(date_ended__isnull=True) @@ -130,6 +142,7 @@ def active_memberships_count(self) -> int: return self.active_memberships.count() def __str__(self): + return f"{self.internal_group.name}: {self.name}" diff --git a/quotes/migrations/0014_alter_quote_created_at.py b/quotes/migrations/0014_alter_quote_created_at.py new file mode 100644 index 00000000..365b3942 --- /dev/null +++ b/quotes/migrations/0014_alter_quote_created_at.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2022-02-28 16:22 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("quotes", "0013_alter_quotevote_caster"), + ] + + operations = [ + migrations.AlterField( + model_name="quote", + name="created_at", + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/quotes/models.py b/quotes/models.py index dd3c65c1..fc233b7e 100644 --- a/quotes/models.py +++ b/quotes/models.py @@ -47,6 +47,8 @@ def get_popular_quotes_in_current_semester(cls): semester_start = timezone.datetime(year=now.year, month=1, day=1) else: semester_start = timezone.datetime(year=now.year, month=7, day=1) + + semester_start = timezone.make_aware(semester_start) popular_quotes = ( cls.objects.filter( verified_by__isnull=False, created_at__gte=semester_start diff --git a/quotes/tests/factories.py b/quotes/tests/factories.py index a5473f8a..49311278 100644 --- a/quotes/tests/factories.py +++ b/quotes/tests/factories.py @@ -14,7 +14,7 @@ class QuoteFactory(DjangoModelFactory): class Meta: model = Quote - text = Faker('text') + text = Faker("text") verified_by = SubFactory(UserFactory) reported_by = SubFactory(UserFactory) @@ -30,8 +30,8 @@ def tagged(self, create, extracted, **kwargs): self.tagged.add(user) else: self.tagged.set(UserFactory.create_batch(2)) - - # created_at = Faker('past_datetime', tzinfo=pytz.timezone(settings.TIME_ZONE)) + + created_at = Faker("past_datetime", tzinfo=pytz.timezone(settings.TIME_ZONE)) class QuoteVoteFactory(DjangoModelFactory): diff --git a/users/tests/factories.py b/users/tests/factories.py index 72c7c6ad..65ed7914 100644 --- a/users/tests/factories.py +++ b/users/tests/factories.py @@ -2,7 +2,6 @@ from factory import Faker, SubFactory, sequence, Sequence from factory.django import DjangoModelFactory - from factory.django import FileField from users.models import User, UsersHaveMadeOut From 98b2c4d65450777f23ffe76bdcb0910e7549da97 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:23:47 +0100 Subject: [PATCH 09/32] feat(common): add random_datetime test helper --- common/tests/test_util.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/common/tests/test_util.py b/common/tests/test_util.py index fe8d7de5..6ec14eb5 100644 --- a/common/tests/test_util.py +++ b/common/tests/test_util.py @@ -2,7 +2,9 @@ from common.util import compress_image from PIL import Image from django.core.files.base import File +import random from io import BytesIO +from django.utils import timezone class TestImageCompression(TestCase): @@ -25,3 +27,14 @@ def test__image_compression_function__reduces_image_size(self): self.image, max_width=6000, max_height=2000, quality=70 ) self.assertLess(compressed_image.size, self.initial_image_size) + + +def random_datetime(interval_start, interval_end): + """Returns a random datetime between two datetime objects""" + if not interval_start or not interval_end: + raise ValueError("No arguments can be None") + + delta = interval_end - interval_start + int_delta = (delta.days * 24 * 60 * 60) + delta.seconds + random_second = random.randrange(int_delta) + return interval_start + timezone.timedelta(seconds=random_second) From f4a84d1ac4aeac69adf772153c0ab047760dd289 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:25:42 +0100 Subject: [PATCH 10/32] fix: economy and users schema typos --- economy/schema.py | 2 +- users/schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/economy/schema.py b/economy/schema.py index 70fed530..66773cb9 100644 --- a/economy/schema.py +++ b/economy/schema.py @@ -135,7 +135,7 @@ class ProductOrderQuery(graphene.ObjectType): all_product_orders = DjangoConnectionField(ProductOrderNode) def resolve_all_product_orders(self, info, *args, **kwargs): - return ProductOrder.objects.all().order_by("created_at") + return ProductOrder.objects.all().order_by("-purchased_at") class SociSessionQuery(graphene.ObjectType): diff --git a/users/schema.py b/users/schema.py index 601271aa..6b161ef3 100644 --- a/users/schema.py +++ b/users/schema.py @@ -40,7 +40,7 @@ class Meta: future_shifts = graphene.List(ShiftNode) - def resolver_future_shifts(self: User, info, *args, **kwargs): + def resolve_future_shifts(self: User, info, *args, **kwargs): return self.future_shifts def resolve_upvoted_quote_ids(self: User, info, **kwargs): From a9002f21136da3a90be241a290391aa986b758e5 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:26:26 +0100 Subject: [PATCH 11/32] feat(common): add test data generation Initial draft for the test data generation pipeline. This way a fresh instance of the application can largely populate db tables with sensible data which streamlines developer experience. This implementation is not complete but covers some major modules --- common/management/__init__.py | 0 common/management/commands/__init__.py | 0 common/management/commands/consts.py | 162 ++++++++++ .../management/commands/generate_testdata.py | 305 ++++++++++++++++++ common/management/commands/utils.py | 78 +++++ common/tests/test_management_commands.py | 20 ++ 6 files changed, 565 insertions(+) create mode 100644 common/management/__init__.py create mode 100644 common/management/commands/__init__.py create mode 100644 common/management/commands/consts.py create mode 100644 common/management/commands/generate_testdata.py create mode 100644 common/management/commands/utils.py create mode 100644 common/tests/test_management_commands.py diff --git a/common/management/__init__.py b/common/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/common/management/commands/__init__.py b/common/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/common/management/commands/consts.py b/common/management/commands/consts.py new file mode 100644 index 00000000..62138308 --- /dev/null +++ b/common/management/commands/consts.py @@ -0,0 +1,162 @@ +from organization.models import InternalGroup + +SUMMARY_CONTENT = """ +# Lorem ipsum dolor + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec pulvinar dui vel felis finibus tempus. Suspendisse finibus vehicula velit, eu suscipit augue mollis vehicula. Curabitur blandit aliquam eros ut accumsan. Maecenas sed diam hendrerit, fermentum lacus vitae, blandit felis. Nulla vitae ex auctor libero suscipit rhoncus eget et diam. Cras at aliquet libero. Nullam est velit, suscipit quis consectetur in, accumsan nec metus. Mauris dictum orci vel viverra eleifend. Nam in mattis ligula, sed ultrices magna. Morbi interdum convallis ex, eu semper sem blandit ut. Curabitur ac diam fringilla, iaculis neque quis, molestie magna. Sed id facilisis sem. + +## Lorem ipsum dolor + +Nam id mauris id massa porttitor mattis. Nunc a tortor turpis. Quisque mollis mattis dolor, non posuere mauris. Integer eget volutpat magna. Nunc odio velit, tempor vel mauris et, pulvinar faucibus nisl. In non orci nibh. Integer lacus orci, faucibus eget ornare non, sagittis nec augue. Curabitur eget accumsan ex. In viverra, arcu nec tincidunt egestas, urna justo feugiat risus, vitae posuere ipsum quam non ex. Mauris quis neque velit. In suscipit nulla sit amet nibh placerat vulputate. Nunc vestibulum, sem id ultricies cursus, ligula lectus dapibus nibh, et ullamcorper ipsum lorem eu arcu. Vivamus sagittis laoreet tempor. + +### Lorem ipsum dolor + +Cras lacinia, nulla sed dignissim interdum, velit nulla pretium risus, semper fringilla metus magna sit amet velit. In quis venenatis felis, quis commodo nunc. Cras aliquam velit ipsum, quis tristique purus sodales sit amet. Duis arcu lectus, finibus ut ligula non, fringilla porta elit. Maecenas non orci nibh. Pellentesque egestas, neque a auctor ultricies, mi elit faucibus nibh, vel sollicitudin massa enim eu diam. In consequat metus in pharetra hendrerit. +- Lorem +- Ipsum +- Dolor +- Sit +- Amet + + +### Lorem ipsum dolor + +Cras lacinia, nulla sed dignissim interdum, velit nulla pretium risus, semper fringilla metus magna sit amet velit. In quis venenatis felis, quis commodo nunc. Cras aliquam velit ipsum, quis tristique purus sodales sit amet. Duis arcu lectus, finibus ut ligula non, fringilla porta elit. Maecenas non orci nibh. Pellentesque egestas, neque a auctor ultricies, mi elit faucibus nibh, vel sollicitudin massa enim eu diam. In consequat metus in pharetra hendrerit. + +""" + +QUOTE_CHOICES = [ + { + "text": "Jeg liker det ikke, men det er jævlig hyggelig!", + "context": "Mona om å bli tatt i 2ern", + }, + {"text": "Er søstra di singel?", "context": None}, + { + "text": "Hvis du er overtrøtt og har lyst på en psykotisk homoopplevelse…", + "context": "Jan i ferd med å anbefale YouTube-serie", + }, + { + "text": "Jeg prøvde å løse et problem med regex, nå har jeg to problemer", + "context": None, + }, + {"text": "Det er siling, kan du dra hjem?", "context": None}, + {"text": "Er jeg ubrukelig", "context": None}, + { + "text": "Sprit er sprit", + "context": "Herman svarer på hvorfor de stakkars gjengisene må drikke Sirafan på Soci", + }, + { + "text": "Jeg prøvde å flame på nyttårsaften, men mamma var lame", + "context": None, + }, +] + +INTERNAL_GROUP_DATA = [ + { + "name": "Edgar", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Barista", "available_externally": True}, + {"name": "Kaféansvarlig", "available_externally": False}, + ], + }, + { + "name": "Bargjengen", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Bartender", "available_externally": True}, + {"name": "Barsjef", "available_externally": False}, + ], + }, + { + "name": "Spritgjengen", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Spritbartender", "available_externally": True}, + {"name": "Spritbarsjef", "available_externally": False}, + ], + }, + { + "name": "Arrangement", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Arrangementbartender", "available_externally": True}, + {"name": "Arrangementansvarlig", "available_externally": False}, + ], + }, + { + "name": "Daglighallen bar", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Daglighallenbartender", "available_externally": True}, + {"name": "Daglighallenansvarlig", "available_externally": False}, + ], + }, + { + "name": "Daglighallen bryggeri", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Brygger", "available_externally": True}, + {"name": "Bryggansvarlig", "available_externally": False}, + ], + }, + { + "name": "KSG-IT", + "type": InternalGroup.Type.INTEREST_GROUP.value, + "positions": [ + {"name": "Utvikler", "available_externally": False}, + ], + }, + { + "name": "Pafyll", + "type": InternalGroup.Type.INTEREST_GROUP.value, + "positions": [ + {"name": "Medlem", "available_externally": False}, + ], + }, + { + "name": "Lyche bar", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Barservitør", "available_externally": True}, + {"name": "Hovmester", "available_externally": False}, + ], + }, + { + "name": "Lyche kjøkken", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Kokk", "available_externally": True}, + {"name": "Souschef", "available_externally": False}, + ], + }, + { + "name": "Økonomigjengen", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Økonomiansvarlig", "available_externally": True}, + ], + }, + { + "name": "Styret", + "type": InternalGroup.Type.INTERNAL_GROUP.value, + "positions": [ + {"name": "Styremedlem", "available_externally": False}, + ], + }, +] + +BANK_ACCOUNT_BALANCE_CHOICES = [ + -500, + -87, + 0, + 50, + 100, + 150, + 200, + 300, + 500, + 800, + 1500, + 3000, +] diff --git a/common/management/commands/generate_testdata.py b/common/management/commands/generate_testdata.py new file mode 100644 index 00000000..2e7ce87e --- /dev/null +++ b/common/management/commands/generate_testdata.py @@ -0,0 +1,305 @@ +import datetime + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from secrets import token_urlsafe + +from users.tests.factories import UserFactory +from economy.models import SociBankAccount, SociProduct, SociSession +from organization.models import InternalGroup, InternalGroupPosition +from admissions.tests.factories import ApplicantFactory, AdmissionFactory +from admissions.consts import AdmissionStatus +from common.util import chose_random_element +from admissions.models import AdmissionAvailableInternalGroupPositionData +from summaries.consts import SummaryType +from summaries.models import Summary +from common.management.commands.consts import ( + SUMMARY_CONTENT, + QUOTE_CHOICES, + INTERNAL_GROUP_DATA, + BANK_ACCOUNT_BALANCE_CHOICES, +) +from common.management.commands.utils import ( + create_semester_dates, + create_random_economy_activity, + EconomicActivityType, + get_random_model_objects, +) +from quotes.models import Quote, QuoteVote +from users.models import User + + +class Command(BaseCommand): + """ + This is a fairly large model generation script. One of the main caveats with the main implementation now + is that is in no way modular and can only be run once due to factory.sequence usage which will + cause unique constraint errors when using without nuking several database tables. + + Future improvements: + > Move generation into helpers that reside at at app-level instead of them all being here + > Wrap script in exception blocks so that it can be used a bit more easily + > Allow for modular usage (Lets say you just want users and not quotes or the other way around. Would + be nice if we could target specific modules of the application for data generation) + """ + + help = ( + "Generates a variety of models in order to populate " + "the application with realistic data. Intended to be used with a fresh instance" + ) + + def handle(self, *args, **options): + try: + # We start by setting up the base structure + self.generate_internal_groups_and_positions() + self.generate_users() + + self.generate_old_admission_data() + self.generate_summaries() + self.generate_quotes() + self.generate_economy() + except Exception as e: + self.stdout.write(self.style.ERROR(f"Something went wrong")) + raise CommandError(e) + self.stdout.write(self.style.SUCCESS("Test data generation done")) + + def generate_internal_groups_and_positions(self): + self.stdout.write( + self.style.SUCCESS("Generating Internal groups and positions") + ) + + # INTERNAL_GROUP_DATA is a const with a nested dictionary so we can easily defined our base definitions + for internal_group_data in INTERNAL_GROUP_DATA: + # Can probably use a transaction.atomic context here + name = internal_group_data["name"] + positions = internal_group_data["positions"] + internal_group_type = internal_group_data["type"] + internal_group = InternalGroup.objects.create( + name=name, type=internal_group_type + ) + + self.stdout.write(self.style.SUCCESS(f"Created InternalGroup {name}")) + for position in positions: + InternalGroupPosition.objects.create( + name=position["name"], + internal_group=internal_group, + available_externally=position["available_externally"], + ) + self.stdout.write( + self.style.SUCCESS( + f"Created InternalGroupPosition {position['name']} for InternalGroup {name}" + ) + ) + + self.stdout.write( + self.style.SUCCESS("Finished generating internal groups and positions") + ) + + def generate_users(self, population_size=300): + self.stdout.write(self.style.SUCCESS("Generating users")) + users = UserFactory.create_batch(population_size, profile_image=None) + + self.stdout.write(self.style.SUCCESS("Giving users bank accounts")) + for user in users: + SociBankAccount.objects.create(card_uuid=None, user=user) + return users + + def assign_users_to_internal_group_positions(self): + pass + + def generate_old_admission_data(self): + """ + Future improvements: + > Generating history for available internal group positions + > Generating priorities of each person + """ + self.stdout.write(self.style.SUCCESS("Generating old admission")) + # We create 10 admissions dating 5 years back + now = datetime.date.today() + january_this_year = datetime.date(year=now.year, month=2, day=12) + cursor = january_this_year - timezone.timedelta(days=365 * 5) + semester_offset = timezone.timedelta(days=184) + + generated_admissions = [] + while cursor < january_this_year: + admission = AdmissionFactory.create( + date=cursor, status=AdmissionStatus.CLOSED + ) + applicant_number_choices = [250, 300, 350] + applicant_size = chose_random_element(applicant_number_choices) + self.stdout.write( + self.style.SUCCESS( + f"Generating {applicant_size} applicants for {admission.semester}" + ) + ) + ApplicantFactory.create_batch(applicant_size, admission=admission) + + generated_admissions.append(admission) + cursor += semester_offset + + self.generate_admission_internal_group_position_data(generated_admissions) + return generated_admissions + + def generate_admission_internal_group_position_data(self, admissions): + available_positions_choices = [10, 15, 20, 25] + for admission in admissions: + self.stdout.write( + self.style.SUCCESS(f"Generating available positions for {admission}") + ) + for position in InternalGroupPosition.get_externally_available_positions(): + available_positions = chose_random_element(available_positions_choices) + self.stdout.write( + self.style.SUCCESS( + f"Creating {available_positions} spots for {position.name}" + ) + ) + AdmissionAvailableInternalGroupPositionData.objects.create( + admission=admission, + internal_group_position=position, + available_positions=available_positions, + ) + self.stdout.write(self.style.SUCCESS("Done generating available positions")) + + def generate_summaries(self, summary_count=40): + self.stdout.write(self.style.SUCCESS("Generating summaries")) + cursor = timezone.make_aware(timezone.datetime.now()) - timezone.timedelta( + days=365 + ) + day_fraction = 365 / summary_count + offset = timezone.timedelta(days=day_fraction) + + for _ in range(summary_count): + for summary_type in SummaryType.choices: + reporter = get_random_model_objects(User) + summary = Summary.objects.create( + type=summary_type[1], + date=cursor, + reporter=reporter, + contents=SUMMARY_CONTENT, + ) + participants = get_random_model_objects(User, 13) + summary.participants.set(participants) + summary.save() + cursor += offset + + number_of_summaries = summary_count * len(SummaryType.choices) + self.stdout.write( + self.style.SUCCESS(f"{number_of_summaries} summaries generated") + ) + + def generate_quotes(self): + self.stdout.write(self.style.SUCCESS("Generating Quotes")) + semesters = create_semester_dates() + # we create 50 quotes per semester with some offset + hour_offset = timezone.timedelta(hours=1) + random_vote_values = range(1, 127) + + for semester in semesters: + cursor = semester + for _ in range(50): + reported_by, verified_by = get_random_model_objects(User, 2) + random_quote = chose_random_element(QUOTE_CHOICES) + quote = Quote.objects.create( + reported_by=reported_by, + verified_by=verified_by, + created_at=cursor, + text=random_quote["text"], + context=random_quote["context"], + ) + + tagged_users_count = chose_random_element([1, 2, 3]) + tagged_users = get_random_model_objects(User, tagged_users_count) + quote.tagged.set(tagged_users) + quote.save() + + vote_value = chose_random_element(random_vote_values) + caster = User.objects.all().order_by("?").first() + QuoteVote.objects.create(quote=quote, caster=caster, value=vote_value) + cursor += hour_offset + + number_of_quotes = 50 * len(semesters) + self.stdout.write(self.style.SUCCESS(f"{number_of_quotes} quotes generated")) + + def generate_economy(self): + self.stdout.write(self.style.SUCCESS(f"Stimulating economy")) + # Infuse bank accounts with random balances + self.stdout.write( + self.style.SUCCESS( + f"Injecting all bank accounts with random amount of money" + ) + ) + for user in User.objects.all(): + user.bank_account.balance = chose_random_element( + BANK_ACCOUNT_BALANCE_CHOICES + ) + user.bank_account.save() + + # Create standard soci products + products = [ + "Tuborg", + "Dahls", + "Nordlands", + "Smirnoff Ice", + "Pringles", + "Nudler", + "Shot", + ] + icons = ["🍺", "😡", "🍷", "🛴", "😤", "🙃", "🤢"] + price_choices = [15, 20, 25, 30, 35, 40, 45, 50, 100] + self.stdout.write(self.style.SUCCESS(f"Generating soci products")) + for product in products: + soci_product = SociProduct.objects.create( + name=product, + price=chose_random_element(price_choices), + sku_number=token_urlsafe(32), + icon=chose_random_element(icons), + ) + self.stdout.write( + self.style.SUCCESS(f"Generated {soci_product.name} {soci_product.icon}") + ) + + # Retrieve a handful of users and pretend they undergo various transactions + self.stdout.write( + self.style.SUCCESS(f"Selecting random users to emulate economy") + ) + users = get_random_model_objects(User, 30) + economic_activity_choices = [ + EconomicActivityType.TRANSFER, + EconomicActivityType.DEPOSIT, + EconomicActivityType.PURCHASE, + ] + + self.stdout.write(self.style.SUCCESS(f"Creating 3 Soci sessions")) + soci_session_choices = [SociSession.objects.create() for _ in range(3)] + + for user in users: + self.stdout.write( + self.style.SUCCESS( + f"Simulating bank account activity for {user.get_full_name()}" + ) + ) + # Create 30 activity items for each user + for _ in range(30): + # Get random action. Either product order, transfer or deposit + activity = chose_random_element(economic_activity_choices) + create_random_economy_activity( + user, + activity, + soci_session=chose_random_element(soci_session_choices), + ) + + self.stdout.write(self.style.SUCCESS("Closing opened soci sessions")) + for session in soci_session_choices: + session.closed = True + session.save() + + self.stdout.write(self.style.SUCCESS("Economy generation done")) + + def generate_schedules(self): + """ + Should generate what we need to handle setting up shifts + > Templates + > Generate future shifts + > Generate shift interests + > + """ + pass diff --git a/common/management/commands/utils.py b/common/management/commands/utils.py new file mode 100644 index 00000000..82699cfe --- /dev/null +++ b/common/management/commands/utils.py @@ -0,0 +1,78 @@ +from django.utils import timezone +import datetime +from common.util import chose_random_element +from economy.models import SociProduct, ProductOrder, SociBankAccount, Transfer, Deposit +from common.tests.test_util import random_datetime +from users.models import User + + +def create_semester_dates(years=3): + now = datetime.date.today() + january_this_year = timezone.make_aware( + timezone.datetime(year=now.year, month=2, day=12) + ) + cursor = january_this_year - timezone.timedelta(days=365 * years) + semester_offset = timezone.timedelta(days=184) + dates = [] + while cursor < january_this_year: + dates.append(cursor) + cursor += semester_offset + + return dates + + +class EconomicActivityType: + TRANSFER = "transfer" + DEPOSIT = "deposit" + PURCHASE = "purchase" + + +def create_random_economy_activity(user, economic_activity, soci_session=None): + amount_choices = [30, 120, 180, 250, 340] + now = timezone.make_aware(timezone.datetime.now()) + last_week = now - timezone.timedelta(days=7) + timestamp = random_datetime(last_week, now) + + if economic_activity == EconomicActivityType.TRANSFER: + random_bank_account = get_random_model_objects(SociBankAccount) + transfer_amount = chose_random_element(amount_choices) + Transfer.objects.create( + source=user.bank_account, + destination=random_bank_account, + amount=transfer_amount, + ) + + elif economic_activity == EconomicActivityType.DEPOSIT: + random_user = get_random_model_objects(User) + signed_off_at = random_datetime(timestamp, now) + deposit_amount = chose_random_element(amount_choices) + Deposit.objects.create( + amount=deposit_amount, + signed_off_by=random_user, + account=user.bank_account, + signed_off_time=signed_off_at, + created_at=timestamp, + description="Autogenerated", + ) + + elif economic_activity == EconomicActivityType.PURCHASE: + if not soci_session: + raise ValueError("SociSession must be defined to create product order") + order_size_choices = range(1, 8) + order_size = chose_random_element(order_size_choices) + product = SociProduct.objects.all().order_by("?").first() + ProductOrder.objects.create( + order_size=order_size, + product=product, + session=soci_session, + purchased_at=timestamp, + source=user.bank_account, + ) + + +def get_random_model_objects(model, number_of_objects=None): + randomized_order = model.objects.all().order_by("?") + if not number_of_objects: + return randomized_order.first() + + return randomized_order[0:number_of_objects] diff --git a/common/tests/test_management_commands.py b/common/tests/test_management_commands.py new file mode 100644 index 00000000..7238c471 --- /dev/null +++ b/common/tests/test_management_commands.py @@ -0,0 +1,20 @@ +from io import StringIO + +from django.core.management import call_command +from django.test import TestCase + + +class GenerateTestDataTest(TestCase): + def call_command(self, *args, **kwargs): + out = StringIO() + call_command( + "generate_testdata", + *args, + stdout=out, + stderr=StringIO(), + **kwargs, + ) + return out.getvalue() + + def test__dry_run__generates_models(self): + self.call_command() From badc69a9f37e4ec191b9f64c40eb44048e1d36c5 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:28:15 +0100 Subject: [PATCH 12/32] feat(admissions): bootstrap admission management commands --- admissions/management/__init__.py | 0 admissions/management/commands/__init__.py | 0 .../commands/generate_active_admission.py | 42 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 admissions/management/__init__.py create mode 100644 admissions/management/commands/__init__.py create mode 100644 admissions/management/commands/generate_active_admission.py diff --git a/admissions/management/__init__.py b/admissions/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admissions/management/commands/__init__.py b/admissions/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admissions/management/commands/generate_active_admission.py b/admissions/management/commands/generate_active_admission.py new file mode 100644 index 00000000..a6bbf0ac --- /dev/null +++ b/admissions/management/commands/generate_active_admission.py @@ -0,0 +1,42 @@ +from django.core.management.base import BaseCommand, CommandError +from admissions.models import Admission, Applicant + + +class Command(BaseCommand): + """ + Algorithm: + 1. Generate default interview schedule template if it does not exist + - Should create an interview period which started a week ago and has another week left + 2. Generate interviews + 3. Generate available positions + 4. Randomize applicants + - Their state in the interview process + - Their priorities + 5. Randomize interviews + - Content + - Interviewers + """ + + def handle(self, *args, **options): + try: + pass + + except Exception as e: + self.stdout.write(self.style.ERROR(f"Something went wrong")) + raise CommandError(e) + self.stdout.write(self.style.SUCCESS("Active admission has been generated")) + + def generate_interview_schedule(self): + pass + + def generate_available_positions(self): + pass + + def generate_interviews(self): + pass + + def generate_applicants(self): + pass + + def randomize_interviews(self): + pass From c9e448ebeca93209be55bd6679930b48ed053032 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 28 Feb 2022 22:28:32 +0100 Subject: [PATCH 13/32] chore(Makefile): add new management commands to Makefile --- Makefile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Makefile b/Makefile index 2316113c..b0604af1 100644 --- a/Makefile +++ b/Makefile @@ -30,3 +30,11 @@ user: showmigrations: poetry run python manage.py showmigrations +.PHONY: testdata +testdata: + poetry run python manage.py generate_testdata + + +.PHONY: activeadmission +activeadmission: + poetry run python manage.py generate_active_admission From eb9590e045bf691b942af661b7205200ad4222ba Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Tue, 1 Mar 2022 17:58:53 +0100 Subject: [PATCH 14/32] feat(admission): add active admission generator management command --- Makefile | 5 + admissions/management/commands/_consts.py | 9 + .../commands/generate_active_admission.py | 234 +++++++++++++++++- .../commands/nuke_admission_data.py | 35 +++ admissions/models.py | 9 + admissions/tests/consts.py | 10 + admissions/tests/factories.py | 3 + admissions/tests/test_management_commands.py | 20 ++ admissions/utils.py | 3 - .../commands/{consts.py => _consts.py} | 0 .../commands/{utils.py => _utils.py} | 0 .../management/commands/generate_testdata.py | 4 +- 12 files changed, 316 insertions(+), 16 deletions(-) create mode 100644 admissions/management/commands/_consts.py create mode 100644 admissions/management/commands/nuke_admission_data.py create mode 100644 admissions/tests/consts.py create mode 100644 admissions/tests/test_management_commands.py rename common/management/commands/{consts.py => _consts.py} (100%) rename common/management/commands/{utils.py => _utils.py} (100%) diff --git a/Makefile b/Makefile index b0604af1..4918d51b 100644 --- a/Makefile +++ b/Makefile @@ -38,3 +38,8 @@ testdata: .PHONY: activeadmission activeadmission: poetry run python manage.py generate_active_admission + + +.PHONY: nukeadmission +nukeadmission: + poetry run python manage.py nuke_admission_data diff --git a/admissions/management/commands/_consts.py b/admissions/management/commands/_consts.py new file mode 100644 index 00000000..eac5f7d5 --- /dev/null +++ b/admissions/management/commands/_consts.py @@ -0,0 +1,9 @@ +INTERVIEW_DISCUSSION_TEXT = """ +Dette var en helt middelmådig søker. Ikke noe spesielt dårlig og ingenting spesielt bra. +""" + + +INTERVIEW_NOTES_TEXT = """ +Ny student i byen. Har lyst på venner. Liker å være sosial på fritiden. + +""" diff --git a/admissions/management/commands/generate_active_admission.py b/admissions/management/commands/generate_active_admission.py index a6bbf0ac..4b3a3b4d 100644 --- a/admissions/management/commands/generate_active_admission.py +++ b/admissions/management/commands/generate_active_admission.py @@ -1,5 +1,32 @@ from django.core.management.base import BaseCommand, CommandError -from admissions.models import Admission, Applicant +from admissions.models import ( + Admission, + Applicant, + InterviewScheduleTemplate, + Interview, + InterviewLocation, + InterviewLocationAvailability, + AdmissionAvailableInternalGroupPositionData, + InterviewBooleanEvaluation, + InterviewBooleanEvaluationAnswer, + InterviewAdditionalEvaluationAnswer, + InterviewAdditionalEvaluationStatement, +) +from admissions.tests.factories import ApplicantFactory +from admissions.consts import AdmissionStatus, ApplicantStatus +from organization.models import InternalGroupPosition +from admissions.utils import generate_interviews_from_schedule +import datetime +from common.util import date_time_combiner +from common.management.commands._utils import ( + chose_random_element, + get_random_model_objects, +) +from admissions.management.commands._consts import ( + INTERVIEW_NOTES_TEXT, + INTERVIEW_DISCUSSION_TEXT, +) +from users.models import User class Command(BaseCommand): @@ -19,7 +46,7 @@ class Command(BaseCommand): def handle(self, *args, **options): try: - pass + self.generate_interview_schedule() except Exception as e: self.stdout.write(self.style.ERROR(f"Something went wrong")) @@ -27,16 +54,201 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS("Active admission has been generated")) def generate_interview_schedule(self): - pass + """""" + now = datetime.date.today() + last_week = now - datetime.timedelta(days=7) + next_week = now + datetime.timedelta(days=7) + day_start = datetime.time(hour=12, minute=0, second=0) + day_end = datetime.time(hour=20, minute=0, second=0) + interview_duration = datetime.timedelta(hours=0, minutes=30, seconds=0) + pause_duration = datetime.timedelta(hours=1, minutes=0, seconds=0) + block_size = 5 + self.stdout.write( + self.style.SUCCESS( + f"Creating interview schedule from {last_week} to {next_week} with these settings:" + ) + ) + self.stdout.write( + self.style.SUCCESS( + f""" + > Interview start for each day is {day_start} + > Maximum interview end for each day is {day_end} + > Default interviews in a row before a break is {block_size} + > Default interview duration is {interview_duration} + > Default pause duration is {pause_duration} + """ + ) + ) - def generate_available_positions(self): - pass + # We design around the model only ever having one instance + # An idea could be to have a OneToONe rel with admission and then always try to copy the defaults from 1 year ago + schedule = InterviewScheduleTemplate.get_or_create_interview_schedule_template() + schedule.interview_period_start_date = last_week + schedule.interview_period_end_date = next_week + schedule.default_interview_day_start = day_start + schedule.default_interview_day_end = day_end + schedule.default_interview_duration = interview_duration + schedule.default_pause_duration = pause_duration + schedule.default_block_size = block_size + schedule.save() - def generate_interviews(self): - pass + # Create two interview locations - def generate_applicants(self): - pass + locations = [ + InterviewLocation.objects.get_or_create(name="Bodegaen")[0], + InterviewLocation.objects.get_or_create(name="Knaus")[0], + ] + self.stdout.write(self.style.SUCCESS(f"Created {len(locations)} locations")) + # Make the locations available 12:00 to 20:00 each day in the interview period + self.stdout.write( + self.style.SUCCESS( + f"Making locations available from {day_start} to {day_end} for each day in the interview period" + ) + ) + cursor = last_week + while cursor <= next_week: + for location in locations: + datetime_from = date_time_combiner(cursor, day_start) + datetime_to = date_time_combiner(cursor, day_end) + InterviewLocationAvailability.objects.create( + interview_location=location, + datetime_from=datetime_from, + datetime_to=datetime_to, + ) + cursor += datetime.timedelta(days=1) - def randomize_interviews(self): - pass + self.stdout.write( + self.style.SUCCESS(f"Retrieving or creating admission object") + ) + # Get or an admission + admission = Admission.objects.get_or_create( + status=AdmissionStatus.OPEN, date=last_week - datetime.timedelta(days=3) + )[0] + + # Add some ordinarily available positions + self.stdout.write(self.style.SUCCESS("Creating available positions")) + positions = InternalGroupPosition.objects.all().filter( + available_externally=True + ) + available_position_choices = [5, 10, 15] + for position in positions: + number = chose_random_element(available_position_choices) + AdmissionAvailableInternalGroupPositionData.objects.create( + admission=admission, + internal_group_position=position, + available_positions=number, + ) + self.stdout.write( + self.style.SUCCESS(f"Created {position.name} with {number} spots") + ) + + # Interview generation + generate_interviews_from_schedule(schedule) + interview_period_datetime_start = date_time_combiner(last_week, day_start) + number_of_interviews = Interview.objects.filter( + interview_start__gte=interview_period_datetime_start + ).count() + self.stdout.write( + self.style.SUCCESS(f"Generated {number_of_interviews} interviews") + ) + + # Applicant generation distributed randomly across the admission + self.stdout.write(self.style.SUCCESS("Creating 200 applicants ")) + ApplicantFactory.create_batch(200, admission=admission) + + # Applicants now need to be filtered by status and their data parsed. + # Example being purging data for those who just got an email or assigning them to interviews + self.stdout.write( + self.style.SUCCESS("Grouping applicants together based on status") + ) + email_sent_applicants = Applicant.objects.all().filter( + status=ApplicantStatus.EMAIL_SENT + ) + registered_profile__applicants = Applicant.objects.all().filter( + status=ApplicantStatus.HAS_REGISTERED_PROFILE + ) + interview_scheduled_applicants = Applicant.objects.all().filter( + status=ApplicantStatus.SCHEDULED_INTERVIEW + ) + finished_with_interview_applicants = Applicant.objects.all().filter( + status=ApplicantStatus.INTERVIEW_FINISHED + ) + ghosted_applicants = Applicant.objects.all().filter( + status=ApplicantStatus.DID_NOT_SHOW_UP_FOR_INTERVIEW + ) + retracted_applicants = Applicant.objects.all().filter( + status=ApplicantStatus.RETRACTED_APPLICATION + ) + + # Nuke details + count = email_sent_applicants.update( + first_name="", + last_name="", + date_of_birth=None, + phone="", + hometown="", + address="", + ) + self.stdout.write( + self.style.SUCCESS(f"Reset personal details for {count} applicants") + ) + + # We assign each applicant to a random interview in the future + datetime_today = date_time_combiner(now, day_start) + self.stdout.write( + self.style.SUCCESS( + f"Assigning random future interviews to {interview_scheduled_applicants.count()} applicants" + ) + ) + for applicant in interview_scheduled_applicants: + random_interview = ( + Interview.objects.all() + .filter(applicant__isnull=True, interview_start__lte=datetime_today) + .order_by("?") + .first() + ) + applicant.interview = random_interview + applicant.save() + + number_of_interviewers_choices = [3, 4, 5] + for applicant in finished_with_interview_applicants: + random_interview = ( + Interview.objects.all() + .filter(applicant__isnull=True, interview_start__gte=datetime_today) + .order_by("?") + .first() + ) + applicant.interview = random_interview + applicant.save() + number_of_interviewers = chose_random_element( + number_of_interviewers_choices + ) + random_interviewers = get_random_model_objects(User, number_of_interviewers) + random_interview.interviewers.set(random_interviewers) + random_interview.discussion = INTERVIEW_DISCUSSION_TEXT + random_interview.notes = INTERVIEW_NOTES_TEXT + + boolean_evaluations = InterviewBooleanEvaluation.objects.all() + additional_evaluations = ( + InterviewAdditionalEvaluationStatement.objects.all() + ) + + for statement in boolean_evaluations: + random_answer = chose_random_element([True, False]) + InterviewBooleanEvaluationAnswer.objects.create( + interview=random_interview, statement=statement, value=random_answer + ) + additional_evaluation_answer_choices = ( + InterviewAdditionalEvaluationAnswer.Options.values + ) + for statement in additional_evaluations: + random_answer = chose_random_element( + additional_evaluation_answer_choices + ) + InterviewAdditionalEvaluationAnswer.objects.create( + interview=random_interview, + statement=statement, + answer=random_answer, + ) + applicant.save() + random_interview.save() diff --git a/admissions/management/commands/nuke_admission_data.py b/admissions/management/commands/nuke_admission_data.py new file mode 100644 index 00000000..a3f4d47f --- /dev/null +++ b/admissions/management/commands/nuke_admission_data.py @@ -0,0 +1,35 @@ +from django.core.management.base import BaseCommand, CommandError +from admissions.models import Admission, Interview, Applicant, InterviewLocation + + +class Command(BaseCommand): + """ + This is a fairly large model generation script. One of the main caveats with the main implementation now + is that is in no way modular and can only be run once due to factory.sequence usage which will + cause unique constraint errors when using without nuking several database tables. + + Future improvements: + > Move generation into helpers that reside at at app-level instead of them all being here + > Wrap script in exception blocks so that it can be used a bit more easily + > Allow for modular usage (Lets say you just want users and not quotes or the other way around. Would + be nice if we could target specific modules of the application for data generation) + """ + + help = "Nukes admission data" + + def handle(self, *args, **options): + try: + # We start by setting up the base structure + self.stdout.write(self.style.SUCCESS("🧨 Admission model ")) + Admission.objects.all().delete() + self.stdout.write(self.style.SUCCESS("🧨 InterviewLocation model")) + InterviewLocation.objects.all().delete() + self.stdout.write(self.style.SUCCESS("🧨 Interview model")) + Interview.objects.all().delete() + self.stdout.write(self.style.SUCCESS("🧨 Applicant model")) + Applicant.objects.all().delete() + + except Exception as e: + self.stdout.write(self.style.ERROR(f"Something went wrong")) + raise CommandError(e) + self.stdout.write(self.style.SUCCESS("Nuking complete")) diff --git a/admissions/models.py b/admissions/models.py index 288c91a0..55be7f95 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -356,6 +356,14 @@ def __str__(self): def get_interview_schedule_template(cls): return cls.objects.all().first() + @classmethod + def get_or_create_interview_schedule_template(cls): + schedule = cls.objects.all().first() + if not schedule: + schedule = cls() + + return schedule + def save(self, *args, **kwargs): """ An interview cannot overlap in the same location. Whe therefore make the following checks @@ -383,6 +391,7 @@ def __str__(self): class InterviewLocationAvailability(models.Model): """Defines when a location is available to us. A location can have multiple intervals where its available to us""" + # Should rename to just location. This is redundant interview_location = models.ForeignKey( InterviewLocation, related_name="availability", on_delete=models.CASCADE ) diff --git a/admissions/tests/consts.py b/admissions/tests/consts.py new file mode 100644 index 00000000..a4af03be --- /dev/null +++ b/admissions/tests/consts.py @@ -0,0 +1,10 @@ +from admissions.consts import ApplicantStatus + +APPLICANT_FACTORY_STATUS_CHOICES = [ + ApplicantStatus.EMAIL_SENT, + ApplicantStatus.HAS_REGISTERED_PROFILE, + ApplicantStatus.SCHEDULED_INTERVIEW, + ApplicantStatus.INTERVIEW_FINISHED, + ApplicantStatus.DID_NOT_SHOW_UP_FOR_INTERVIEW, + ApplicantStatus.RETRACTED_APPLICATION, +] diff --git a/admissions/tests/factories.py b/admissions/tests/factories.py index dc6582e5..d86d8597 100644 --- a/admissions/tests/factories.py +++ b/admissions/tests/factories.py @@ -8,6 +8,8 @@ InterviewLocationAvailability, Admission, ) +from admissions.consts import ApplicantStatus +from admissions.tests.consts import APPLICANT_FACTORY_STATUS_CHOICES class AdmissionFactory(factory.django.DjangoModelFactory): @@ -26,6 +28,7 @@ class Meta: hometown = factory.Faker("address") address = factory.Faker("address") date_of_birth = factory.Faker("date") + status = factory.fuzzy.FuzzyChoice(APPLICANT_FACTORY_STATUS_CHOICES) class InterviewLocationFactory(factory.django.DjangoModelFactory): diff --git a/admissions/tests/test_management_commands.py b/admissions/tests/test_management_commands.py new file mode 100644 index 00000000..408fda24 --- /dev/null +++ b/admissions/tests/test_management_commands.py @@ -0,0 +1,20 @@ +from io import StringIO + +from django.core.management import call_command +from django.test import TestCase + + +class GenerateActiveAdmission(TestCase): + def call_command(self, *args, **kwargs): + out = StringIO() + call_command( + "generate_active_admission", + *args, + stdout=out, + stderr=StringIO(), + **kwargs, + ) + return out.getvalue() + + def test__dry_run__generates_admission(self): + self.call_command() diff --git a/admissions/utils.py b/admissions/utils.py index 7391822a..a2176f15 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -63,9 +63,6 @@ def generate_interviews_from_schedule(schedule): datetime_to=datetime_cursor + interview_duration, ) for location in available_locations: - print( - f"Looking at {location} from {datetime_cursor} to {datetime_cursor + interview_duration}" - ) Interview.objects.create( location=location, interview_start=datetime_cursor, diff --git a/common/management/commands/consts.py b/common/management/commands/_consts.py similarity index 100% rename from common/management/commands/consts.py rename to common/management/commands/_consts.py diff --git a/common/management/commands/utils.py b/common/management/commands/_utils.py similarity index 100% rename from common/management/commands/utils.py rename to common/management/commands/_utils.py diff --git a/common/management/commands/generate_testdata.py b/common/management/commands/generate_testdata.py index 2e7ce87e..c527ee9d 100644 --- a/common/management/commands/generate_testdata.py +++ b/common/management/commands/generate_testdata.py @@ -13,13 +13,13 @@ from admissions.models import AdmissionAvailableInternalGroupPositionData from summaries.consts import SummaryType from summaries.models import Summary -from common.management.commands.consts import ( +from common.management.commands._consts import ( SUMMARY_CONTENT, QUOTE_CHOICES, INTERNAL_GROUP_DATA, BANK_ACCOUNT_BALANCE_CHOICES, ) -from common.management.commands.utils import ( +from common.management.commands._utils import ( create_semester_dates, create_random_economy_activity, EconomicActivityType, From 6c83db5437b7471f27e8a2db13ddfd99715fa2df Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 7 Mar 2022 18:33:30 +0100 Subject: [PATCH 15/32] chore(admissions): add applicant to list display --- admissions/admin.py | 1 + admissions/schema.py | 101 ++++++++++++++++++++++++++++++++++++++++++- users/signals.py | 13 ------ 3 files changed, 101 insertions(+), 14 deletions(-) delete mode 100644 users/signals.py diff --git a/admissions/admin.py b/admissions/admin.py index 735d4e08..88ace08c 100644 --- a/admissions/admin.py +++ b/admissions/admin.py @@ -34,6 +34,7 @@ class InterviewBooleanEvaluationAnswerInline(admin.TabularInline): @admin.register(Interview) class InterviewAdmin(admin.ModelAdmin): + list_display = ("applicant",) inlines = ( InterviewAdditionalEvaluationAnswerInline, InterviewBooleanEvaluationAnswerInline, diff --git a/admissions/schema.py b/admissions/schema.py index 8a5eb11f..2369e362 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -20,6 +20,7 @@ create_interview_slots, mass_send_welcome_to_interview_email, ) +from django.db.models.functions import Coalesce from django.core.exceptions import SuspiciousOperation from django.utils import timezone from admissions.models import ( @@ -34,7 +35,7 @@ InterviewBooleanEvaluation, InterviewAdditionalEvaluationStatement, ) -from organization.models import InternalGroupPosition +from organization.models import InternalGroupPosition, InternalGroup from organization.schema import InternalGroupPositionNode from admissions.filters import AdmissionFilter, ApplicantFilter from admissions.consts import AdmissionStatus, Priority, ApplicantStatus @@ -229,10 +230,30 @@ def get_node(cls, info, id): return Interview.objects.get(pk=id) +class InternalGroupApplicantsData(graphene.ObjectType): + """ + A way to encapsulate the applicants for a given internal group + > Resolves all applicants for this group split into their priorities + """ + + first_priorities = graphene.List(ApplicantNode) + second_priorities = graphene.List(ApplicantNode) + third_priorities = graphene.List(ApplicantNode) + + """ + + Can consider making a custom node which resolves whether or not they have someone from the relevant internal group + Can also maybe just do this with looping over interviewers and checking their internal group position states + """ + + class ApplicantQuery(graphene.ObjectType): applicant = Node.Field(ApplicantNode) all_applicants = graphene.List(ApplicantNode) get_applicant_from_token = graphene.Field(ApplicantNode, token=graphene.String()) + internal_group_applicants_data = graphene.Field( + InternalGroupApplicantsData, internal_group=graphene.ID() + ) def resolve_get_applicant_from_token(self, info, token, *args, **kwargs): applicant = Applicant.objects.filter(token=token).first() @@ -241,6 +262,44 @@ def resolve_get_applicant_from_token(self, info, token, *args, **kwargs): def resolve_all_applicants(self, info, *args, **kwargs): return Applicant.objects.all().order_by("first_name") + def resolve_internal_group_applicants_data( + self, info, internal_group, *args, **kwargs + ): + django_id = disambiguate_id(internal_group) + internal_group = InternalGroup.objects.filter(id=django_id).first() + if not internal_group: + return None + + first_priorities = ( + Applicant.objects.all() + .filter( + priorities__applicant_priority=Priority.FIRST, + priorities__internal_group_position__internal_group=internal_group, + ) + .order_by("interview__interview_start") + ) + second_priorities = ( + Applicant.objects.all() + .filter( + priorities__applicant_priority=Priority.SECOND, + priorities__internal_group_position__internal_group=internal_group, + ) + .order_by("interview__interview_start") + ) + third_priorities = ( + Applicant.objects.all() + .filter( + priorities__applicant_priority=Priority.THIRD, + priorities__internal_group_position__internal_group=internal_group, + ) + .order_by("interview__interview_start") + ) + return InternalGroupApplicantsData( + first_priorities=first_priorities, + second_priorities=second_priorities, + third_priorities=third_priorities, + ) + class ResendApplicantTokenMutation(graphene.Mutation): class Arguments: @@ -572,6 +631,46 @@ def mutate(self, info, *args, **kwargs): return GenerateInterviewsMutation(ok=True, interviews_generated=num) +class SetSelfAsInterviewerMutation(graphene.Mutation): + class Arguments: + interview_id = graphene.ID(required=True) + + success = graphene.Boolean() + + def mutate(self, info, interview_id, *args, **kwargs): + interview_django_id = disambiguate_id(interview_id) + interview = Interview.objects.filter(id=interview_django_id).first() + if not interview: + return SetSelfAsInterviewerMutation(success=False) + + # Interview exists. Here we can parse whether or not a person from this internal group is here already + # ToDo ^ + + user = info.context.user + interview.interviewers.add(user) + interview.save() + return SetSelfAsInterviewerMutation(success=True) + + +class RemoveSelfAsInterviewerMutation(graphene.Mutation): + class Arguments: + interview_id = graphene.ID(required=True) + + success = graphene.Boolean() + + def mutate(self, info, interview_id, *args, **kwargs): + interview_django_id = disambiguate_id(interview_id) + interview = Interview.objects.filter(id=interview_django_id).first() + if not interview: + return RemoveSelfAsInterviewerMutation(success=False) + + user = info.context.user + interviewers = interview.interviewers.all() + interview.interviewers.set(interviewers.exclude(user=user)) + interview.save() + return RemoveSelfAsInterviewerMutation(success=True) + + class DeleteAllInterviewsMutation(graphene.Mutation): count = graphene.Int() diff --git a/users/signals.py b/users/signals.py deleted file mode 100644 index 011fab0c..00000000 --- a/users/signals.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.db.models import signals -from django.dispatch import receiver -from .models import User - -from economy.models import SociBankAccount - - -@receiver(signals.post_save, sender=User) -def create_soci_bank_account(sender, instance: User, created, **kwargs): - # We use getattr here as accessing the field directly when the relation does not - # exist will throw an error. - if created and getattr(instance, "bank_account", None) is not None: - SociBankAccount.objects.create(user=instance) \ No newline at end of file From 9550addafe9498d2a50ec144d3af2cdc0ae39a19 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 7 Mar 2022 18:34:10 +0100 Subject: [PATCH 16/32] feat(admissions): add priorities to test data generation --- .../commands/generate_active_admission.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/admissions/management/commands/generate_active_admission.py b/admissions/management/commands/generate_active_admission.py index 4b3a3b4d..742d0609 100644 --- a/admissions/management/commands/generate_active_admission.py +++ b/admissions/management/commands/generate_active_admission.py @@ -49,7 +49,7 @@ def handle(self, *args, **options): self.generate_interview_schedule() except Exception as e: - self.stdout.write(self.style.ERROR(f"Something went wrong")) + self.stdout.write(self.style.ERROR(f"{e}")) raise CommandError(e) self.stdout.write(self.style.SUCCESS("Active admission has been generated")) @@ -210,8 +210,14 @@ def generate_interview_schedule(self): applicant.interview = random_interview applicant.save() + self.stdout.write( + self.style.SUCCESS("Adding random interviewers to interviews") + ) number_of_interviewers_choices = [3, 4, 5] for applicant in finished_with_interview_applicants: + self.stdout.write( + self.style.SUCCESS(f"Generating interview data for {applicant}") + ) random_interview = ( Interview.objects.all() .filter(applicant__isnull=True, interview_start__gte=datetime_today) @@ -252,3 +258,21 @@ def generate_interview_schedule(self): ) applicant.save() random_interview.save() + + self.stdout.write(self.style.SUCCESS("Giving all applicants random priorities")) + # Now we give the applicants random priorities + number_of_priorities_choices = [2, 3] + for applicant in Applicant.objects.all(): + number_of_priorities = chose_random_element(number_of_priorities_choices) + positions = ( + InternalGroupPosition.objects.all() + .filter(available_externally=True) + .order_by("?")[0:number_of_priorities] + ) + self.stdout.write( + self.style.SUCCESS( + f"Adding positions {positions} to {applicant} priorities" + ) + ) + for position in positions: + applicant.add_priority(position) From d8ad2d2d57ec9bd2a6f597805a68cbe4f58b9d53 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 7 Mar 2022 18:35:21 +0100 Subject: [PATCH 17/32] fix(users): misc --- users/apps.py | 6 ++---- users/models.py | 3 ++- users/tests/tests.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/users/apps.py b/users/apps.py index db97f4ef..e7d462a0 100644 --- a/users/apps.py +++ b/users/apps.py @@ -3,8 +3,6 @@ from django.apps import AppConfig -class UsersConfig(AppConfig): - name = 'users' - def ready(self): - from users import signals +class UsersConfig(AppConfig): + name = "users" diff --git a/users/models.py b/users/models.py index 03aef77f..c0c6124e 100644 --- a/users/models.py +++ b/users/models.py @@ -117,10 +117,11 @@ def future_shifts(self): @property def ksg_status(self): + # ToDo Rework this shit. Doesn't make sense return ( self.internal_group_position_history.filter(date_ended__isnull=True) .first() - .position.type + .position if self.internal_group_position_history.filter( date_ended__isnull=True ).first() diff --git a/users/tests/tests.py b/users/tests/tests.py index 21de9d6b..646f7995 100644 --- a/users/tests/tests.py +++ b/users/tests/tests.py @@ -17,7 +17,7 @@ from users.tests.factories import UserFactory, UsersHaveMadeOutFactory from users.views import user_detail, klinekart -from organization.consts import InternalGroupPositionType +from organization.consts import InternalGroupPositionMembershipType class UserProfileTest(TestCase): From cd2ca9e4bda4e57765f71fad9ed4768ba26039e7 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 7 Mar 2022 18:36:18 +0100 Subject: [PATCH 18/32] fix(quotes): broken old code --- quotes/tests/tests.py | 48 ++----------------------------------------- quotes/views.py | 24 ++++++++++------------ 2 files changed, 13 insertions(+), 59 deletions(-) diff --git a/quotes/tests/tests.py b/quotes/tests/tests.py index 4d313332..a8086542 100644 --- a/quotes/tests/tests.py +++ b/quotes/tests/tests.py @@ -70,10 +70,10 @@ def setUp(self): self.unverified_quotes = QuoteFactory.create_batch(4, verified_by=None) def test_quote_pending_objects__returns_correct_count(self): - self.assertEqual(Quote.pending_objects.all().count(), 4) + self.assertEqual(Quote.get_pending_quotes().count(), 4) def test_quote_verified_objects__returns_correct_count(self): - self.assertEqual(Quote.verified_objects.all().count(), 2) + self.assertEqual(Quote.get_approved_quotes().count(), 2) class QuotePresentationViewsTest(TestCase): @@ -323,50 +323,6 @@ def test_approving_unapproved_quote(self): self.assertEqual(self.quote.verified_by, self.user) -class QuoteHighscoreTest(TestCase): - def setUp(self): - self.quotesH17 = QuoteFactory.create_batch(10, text="This is a quote from H17") - for quote in self.quotesH17: - quote.created_at = timezone.now().replace(year=2017, month=10, day=15) - quote.save() - self.quotes_this_semester = QuoteFactory.create_batch( - 10, text="This is a quote from this semester" - ) - - # Generates a random set pf votes for each dataset - for i in range(len(self.quotes_this_semester)): - QuoteVoteFactory.create_batch(5, quote=self.quotesH17[i], value=i / 2) - - for i in range(len(self.quotes_this_semester)): - # factory uses same vote value for everything - QuoteVoteFactory.create_batch( - 5, quote=self.quotes_this_semester[i], value=i - ) - - def test__return_highscore_descending(self): - quotes = Quote.highscore_objects.semester_highest_score(timezone.now()) - flag = True - for i in range((len(quotes) - 1)): - if quotes[i].sum < quotes[i + 1].sum: - flag = False - self.assertTrue(flag) - - def test__return_only_from_given_semester(self): - quotes = Quote.highscore_objects.semester_highest_score( - timezone.now().replace(year=2017, month=10, day=15) - ) - for quote in quotes: - self.assertEqual(quote.text, "This is a quote from H17") - - def test__quotes_all_time__returns_descending(self): - quotes = Quote.highscore_objects.highest_score_all_time() - flag = True - for i in range((len(quotes) - 1)): - if quotes[i].sum < quotes[i + 1].sum: - flag = False - self.assertTrue(flag) - - class QuotePendingViewTest(TestCase): def setUp(self): QuoteFactory.create_batch(10, verified_by=None) diff --git a/quotes/views.py b/quotes/views.py index 1219816c..0bbc00c6 100644 --- a/quotes/views.py +++ b/quotes/views.py @@ -28,16 +28,14 @@ def quotes_approve(request, quote_id): @login_required def quotes_highscore(request): if request.method == "GET": - this_semester = Quote.highscore_objects.semester_highest_score(timezone.now()) - all_time = Quote.highscore_objects.highest_score_all_time() + this_semester = Quote.get_popular_quotes_in_current_semester() + all_time = Quote.get_popular_quotes_all_time() combined_list = list(zip(this_semester, all_time)) ctx = { - "highscore_this_semester": Quote.highscore_objects.semester_highest_score( - timezone.now() - ), - "highscore_all_time": Quote.highscore_objects.highest_score_all_time(), + "highscore_this_semester": Quote.get_popular_quotes_in_current_semester(), + "highscore_all_time": Quote.get_popular_quotes_all_time(), "highscore_combined": combined_list, # Can be used in the future so we can style the rows together - "pending": Quote.pending_objects.order_by("-created_at"), + "pending": Quote.get_pending_quotes().order_by("-created_at"), } return render( request, template_name="quotes/quotes_highscore.html", context=ctx @@ -49,8 +47,8 @@ def quotes_highscore(request): @login_required def quotes_list(request): ctx = { - "pending": Quote.pending_objects.all().order_by("-created_at"), - "quotes": Quote.verified_objects.all().order_by("-created_at"), + "pending": Quote.get_pending_quotes().order_by("-created_at"), + "quotes": Quote.get_approved_quotes().order_by("-created_at"), "current_semester": get_semester_year_shorthand(timezone.now()), } return render(request, template_name="quotes/quotes_list.html", context=ctx) @@ -59,7 +57,7 @@ def quotes_list(request): @login_required def quotes_pending(request): ctx = { - "pending": Quote.pending_objects.all().order_by("-created_at"), + "pending": Quote.get_pending_quotes().order_by("-created_at"), "current_semester": get_semester_year_shorthand(timezone.now()), } return render(request, template_name="quotes/quotes_pending.html", context=ctx) @@ -68,7 +66,7 @@ def quotes_pending(request): @login_required def quotes_add(request): if request.method == "GET": - ctx = {"pending": Quote.pending_objects.all(), "quote_form": QuoteForm()} + ctx = {"pending": Quote.get_pending_quotes(), "quote_form": QuoteForm()} return render(request, template_name="quotes/quotes_add.html", context=ctx) elif request.method == "POST": form = QuoteForm(request.POST) @@ -123,7 +121,7 @@ def quotes_delete(request, quote_id): @login_required def vote_up(request, quote_id): if request.method == "POST": - quote = get_object_or_404(Quote.verified_objects, pk=quote_id) + quote = get_object_or_404(Quote.get_approved_quotes(), pk=quote_id) user = request.user quote_vote = QuoteVote.objects.filter(quote=quote, caster=user).first() @@ -149,7 +147,7 @@ def vote_up(request, quote_id): @login_required def vote_down(request, quote_id): if request.method == "POST": - quote = get_object_or_404(Quote.verified_objects, pk=quote_id) + quote = get_object_or_404(Quote.get_approved_quotes(), pk=quote_id) user = request.user quote_vote = QuoteVote.objects.filter(quote=quote, caster=user).first() From 9c67cc0906941888848641b6643faa515e7ff4da Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Mon, 7 Mar 2022 18:38:10 +0100 Subject: [PATCH 19/32] feat(admissions): tune admission resolvers and mutations Add resolvers and mutations for applicants and interviewers: > Resolve applicants for internal groups and their priorities > Handle applicant priority deletion > Toggle applicant wil be admitted mutation --- admissions/consts.py | 1 + .../migrations/0028_auto_20220302_1926.py | 34 ++ .../0029_applicant_will_be_admitted.py | 18 ++ .../migrations/0030_alter_applicant_status.py | 33 ++ admissions/models.py | 58 +++- admissions/schema.py | 293 ++++++++++++++++-- admissions/tests/test_utils.py | 11 +- admissions/utils.py | 111 ++++--- 8 files changed, 479 insertions(+), 80 deletions(-) create mode 100644 admissions/migrations/0028_auto_20220302_1926.py create mode 100644 admissions/migrations/0029_applicant_will_be_admitted.py create mode 100644 admissions/migrations/0030_alter_applicant_status.py diff --git a/admissions/consts.py b/admissions/consts.py index 6d6eece0..2e3d3c30 100644 --- a/admissions/consts.py +++ b/admissions/consts.py @@ -23,6 +23,7 @@ class ApplicantStatus(models.TextChoices): # This probably has to be revisited EMAIL_SENT = ("email-sent", "Email sent") HAS_REGISTERED_PROFILE = ("has-registered-profile", "Has registered profile") + HAS_SET_PRIORITIES = ("has-set-priorities", "Has set priorities") SCHEDULED_INTERVIEW = ("scheduled-interview", "Scheduled interview") INTERVIEW_FINISHED = ("interview-finished", "Interview finished") DID_NOT_SHOW_UP_FOR_INTERVIEW = ( diff --git a/admissions/migrations/0028_auto_20220302_1926.py b/admissions/migrations/0028_auto_20220302_1926.py new file mode 100644 index 00000000..e97c4f9a --- /dev/null +++ b/admissions/migrations/0028_auto_20220302_1926.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.12 on 2022-03-02 18:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0027_alter_admission_date"), + ] + + operations = [ + migrations.AlterField( + model_name="interviewadditionalevaluationanswer", + name="answer", + field=models.CharField( + blank=True, + choices=[ + ("very-little", "Very little"), + ("little", "Little"), + ("medium", "Medium"), + ("somewhat", "Somewhat"), + ("very", "Very"), + ], + max_length=32, + null=True, + ), + ), + migrations.AlterField( + model_name="interviewbooleanevaluationanswer", + name="value", + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/admissions/migrations/0029_applicant_will_be_admitted.py b/admissions/migrations/0029_applicant_will_be_admitted.py new file mode 100644 index 00000000..4c538ac2 --- /dev/null +++ b/admissions/migrations/0029_applicant_will_be_admitted.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-03-03 20:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0028_auto_20220302_1926"), + ] + + operations = [ + migrations.AddField( + model_name="applicant", + name="will_be_admitted", + field=models.BooleanField(default=False), + ), + ] diff --git a/admissions/migrations/0030_alter_applicant_status.py b/admissions/migrations/0030_alter_applicant_status.py new file mode 100644 index 00000000..3e913611 --- /dev/null +++ b/admissions/migrations/0030_alter_applicant_status.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.12 on 2022-03-06 18:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0029_applicant_will_be_admitted"), + ] + + operations = [ + migrations.AlterField( + model_name="applicant", + name="status", + field=models.CharField( + choices=[ + ("email-sent", "Email sent"), + ("has-registered-profile", "Has registered profile"), + ("has-set-priorities", "Has set priorities"), + ("scheduled-interview", "Scheduled interview"), + ("interview-finished", "Interview finished"), + ("did-not-show-up-for-interview", "Did not show up for interview"), + ("to-be-called", "To be called"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ("retracted-application", "Retracted application"), + ], + default="email-sent", + max_length=64, + ), + ), + ] diff --git a/admissions/models.py b/admissions/models.py index 55be7f95..afdab93a 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -3,7 +3,6 @@ from common.util import get_semester_year_shorthand from django.utils import timezone from django.db.utils import IntegrityError -from django.core.validators import MinValueValidator from admissions.consts import ( Priority, ApplicantStatus, @@ -13,7 +12,7 @@ from django.utils.translation import ugettext_lazy as _ from secrets import token_urlsafe from os.path import join as osjoin -from admissions.utils import send_welcome_to_interview_email +from organization.models import InternalGroup import datetime @@ -56,6 +55,11 @@ def semester(self) -> str: def number_of_applicants(self): return self.applicants.count() + def internal_groups_accepting_applicants(self): + positions = self.available_internal_group_positions.all() + internal_groups = InternalGroup.objects.filter(positions__in=positions) + return internal_groups.order_by("name") + @classmethod def get_or_create_current_admission(cls): active_admission = cls.objects.filter(~Q(status=AdmissionStatus.CLOSED)) @@ -100,7 +104,8 @@ class Meta: statement = models.ForeignKey( "admissions.InterviewBooleanEvaluation", on_delete=models.CASCADE ) - value = models.BooleanField(null=False, blank=False) + # Nullable because we prepare this before the interview is booked + value = models.BooleanField(null=True, blank=True) class InterviewAdditionalEvaluationStatement(models.Model): @@ -145,13 +150,11 @@ class Options(models.TextChoices): statement = models.ForeignKey( "admissions.InterviewAdditionalEvaluationStatement", on_delete=models.CASCADE ) + # Nullable because we prepare this before the interview is booked answer = models.CharField( - max_length=32, choices=Options.choices, null=False, blank=False + max_length=32, choices=Options.choices, null=True, blank=True ) - def __str__(self): - return self.answer - class Interview(models.Model): """ @@ -238,6 +241,7 @@ class Applicant(models.Model): hometown = models.CharField(default="", blank=True, max_length=30) wants_digital_interview = models.BooleanField(default=False) + will_be_admitted = models.BooleanField(default=False) def image_dir(self, filename): # We want to save all objects in under the admission @@ -269,6 +273,46 @@ def create_or_update_application(cls, email): auth_token = token_urlsafe(32) cls.objects.create(email=email, admission=current_admission, token=auth_token) + @classmethod + def valid_applicants(cls): + return Applicant.objects.filter( + ~Q(priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT), + status=ApplicantStatus.INTERVIEW_FINISHED, + ) + + def add_priority(self, position): + # In case a priority has been deleted we need to reorder existing ones first + priorities = [Priority.FIRST, Priority.SECOND, Priority.THIRD] + # Unfiltered priorities can have None values + index = self.priorities.count() + if index >= 3: + raise IntegrityError("Applicant already has three priorities") + + priority = priorities[index] + self.priorities.add( + InternalGroupPositionPriority.objects.create( + applicant=self, + internal_group_position=position, + applicant_priority=priority, + ) + ) + + self.save() + + @property + def get_priorities(self): + # get_ pre-pending to avoid name conflicts + first_priority = self.priorities.filter( + applicant_priority=Priority.FIRST + ).first() + second_priority = self.priorities.filter( + applicant_priority=Priority.SECOND + ).first() + third_priority = self.priorities.filter( + applicant_priority=Priority.THIRD + ).first() + return [first_priority, second_priority, third_priority] + @property def get_full_name(self): return f"{self.first_name} {self.last_name}" diff --git a/admissions/schema.py b/admissions/schema.py index 2369e362..a41d15eb 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -20,7 +20,6 @@ create_interview_slots, mass_send_welcome_to_interview_email, ) -from django.db.models.functions import Coalesce from django.core.exceptions import SuspiciousOperation from django.utils import timezone from admissions.models import ( @@ -35,14 +34,25 @@ InterviewBooleanEvaluation, InterviewAdditionalEvaluationStatement, ) -from organization.models import InternalGroupPosition, InternalGroup -from organization.schema import InternalGroupPositionNode -from admissions.filters import AdmissionFilter, ApplicantFilter -from admissions.consts import AdmissionStatus, Priority, ApplicantStatus +from organization.models import ( + InternalGroupPosition, + InternalGroup, + InternalGroupPositionMembership, +) +from organization.schema import InternalGroupPositionNode, InternalGroupNode +from admissions.filters import AdmissionFilter +from admissions.consts import ( + AdmissionStatus, + Priority, + ApplicantStatus, + InternalGroupStatus, +) from graphene_django_cud.types import TimeDelta from graphene import Time, Date from graphene_django_cud.util import disambiguate_id from users.schema import UserNode +from users.models import User +from economy.models import SociBankAccount class InternalGroupPositionPriorityNode(DjangoObjectType): @@ -50,6 +60,12 @@ class Meta: model = InternalGroupPositionPriority interfaces = (Node,) + def resolve_internal_group_priority(self, info, *args, **kwargs): + # Shady. Should do something else about this + if self.internal_group_priority == "": + return None + return self.internal_group_priority + class InterviewLocationAvailabilityNode(DjangoObjectType): class Meta: @@ -84,6 +100,36 @@ class Meta: full_name = graphene.String(source="get_full_name") priorities = graphene.List(InternalGroupPositionPriorityNode) + interviewer_from_internal_group = graphene.ID(internal_group_id=graphene.ID()) + + def resolve_interviewer_from_internal_group( + self: Applicant, info, internal_group_id, *args, **kwargs + ): + """ + We want to be able to query whether or not there is an interviewer from the respective internal + group set up for the given interview. If it exists we return the id so we can determine what piece + of UI we want to render in react. + > If None the interview isn't covered + > If ID it is covered but we want to render differently based on who is logged in + """ + internal_group_id = disambiguate_id(internal_group_id) + internal_group = InternalGroup.objects.filter(id=internal_group_id).first() + interview = self.interview + + if not internal_group or not interview: + return None + + interviewers = interview.interviewers.all() + interviewer_from_internal_group = interviewers.filter( + internal_group_position_history__date_ended__isnull=True, + internal_group_position_history__position__internal_group=internal_group, + ).first() # Should only be one + + if not interviewer_from_internal_group: + return None + + return to_global_id("UserNode", interviewer_from_internal_group.id) + def resolve_priorities(self: Applicant, info, *args, **kwargs): first_priority = self.priorities.filter( applicant_priority=Priority.FIRST @@ -236,16 +282,11 @@ class InternalGroupApplicantsData(graphene.ObjectType): > Resolves all applicants for this group split into their priorities """ + internal_group_name = graphene.String() first_priorities = graphene.List(ApplicantNode) second_priorities = graphene.List(ApplicantNode) third_priorities = graphene.List(ApplicantNode) - """ - - Can consider making a custom node which resolves whether or not they have someone from the relevant internal group - Can also maybe just do this with looping over interviewers and checking their internal group position states - """ - class ApplicantQuery(graphene.ObjectType): applicant = Node.Field(ApplicantNode) @@ -255,6 +296,15 @@ class ApplicantQuery(graphene.ObjectType): InternalGroupApplicantsData, internal_group=graphene.ID() ) + valid_applicants = graphene.List(ApplicantNode) + + def resolve_valid_applicants(self, info, *args, **kwargs): + applicants = Applicant.objects.filter( + ~Q(priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT), + status=ApplicantStatus.INTERVIEW_FINISHED, + ).order_by("first_name") + return applicants + def resolve_get_applicant_from_token(self, info, token, *args, **kwargs): applicant = Applicant.objects.filter(token=token).first() return applicant @@ -295,6 +345,7 @@ def resolve_internal_group_applicants_data( .order_by("interview__interview_start") ) return InternalGroupApplicantsData( + internal_group_name=internal_group.name, first_priorities=first_priorities, second_priorities=second_priorities, third_priorities=third_priorities, @@ -356,11 +407,15 @@ class AdmissionQuery(graphene.ObjectType): externally_available_internal_group_positions = graphene.List( InternalGroupPositionNode ) - currently_admission_internal_group_position_data = graphene.List( + internal_group_positions_available_for_applicants = graphene.List( + InternalGroupPositionNode + ) + current_admission_internal_group_position_data = graphene.List( AdmissionAvailableInternalGroupPositionDataNode ) + internal_groups_accepting_applicants = graphene.List(InternalGroupNode) - def resolve_currently_admission_internal_group_position_data( + def resolve_current_admission_internal_group_position_data( self, info, *args, **kwargs ): admission = Admission.get_active_admission() @@ -385,6 +440,7 @@ def resolve_active_admission(self, info, *args, **kwargs): def resolve_all_admissions(self, info, *args, **kwargs): return Admission.objects.all().order_by("-date") + # Intended use for admission configuration def resolve_externally_available_internal_group_positions( self, info, *args, **kwargs ): @@ -392,6 +448,20 @@ def resolve_externally_available_internal_group_positions( "name" ) + def resolve_internal_groups_accepting_applicants(self, info, *args, **kwargs): + admission = Admission.get_active_admission() + if not admission: + return [] + return admission.internal_groups_accepting_applicants() + + def resolve_internal_group_positions_available_for_applicants( + self, info, *args, **kwargs + ): + admission = Admission.get_active_admission() + if not admission: + return [] + return admission.available_internal_group_positions.all().order_by("name") + class InterviewLocationDateGrouping(graphene.ObjectType): name = graphene.String() @@ -445,7 +515,6 @@ class AvailableInterviewsDayGrouping(graphene.ObjectType): class InterviewQuery(graphene.ObjectType): interview = Node.Field(InterviewNode) interview_template = graphene.Field(InterviewTemplate) - interviews_available_for_booking = graphene.List( AvailableInterviewsDayGrouping, day_offset=graphene.Int(required=True) ) @@ -584,6 +653,82 @@ class Meta: model = Applicant +class ToggleApplicantWillBeAdmittedMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + success = graphene.Boolean() + + def mutate(self, info, id, *args, **kwargs): + applicant_id = disambiguate_id(id) + applicant = Applicant.objects.filter(id=applicant_id).first() + + if not applicant: + return ToggleApplicantWillBeAdmittedMutation(success=False) + + applicant.will_be_admitted = not applicant.will_be_admitted + applicant.save() + return ToggleApplicantWillBeAdmittedMutation(success=True) + + +# === InternalGroupPositionPriority === +class AddInternalGroupPositionPriorityMutation(graphene.Mutation): + class Arguments: + internal_group_position_id = graphene.ID(required=True) + applicant_id = graphene.ID(required=True) + + success = graphene.Boolean() + + def mutate(self, info, internal_group_position_id, applicant_id, *args, **kwargs): + internal_group_position_id = disambiguate_id(internal_group_position_id) + applicant_id = disambiguate_id(applicant_id) + internal_group_position = InternalGroupPosition.objects.filter( + id=internal_group_position_id + ).first() + applicant = Applicant.objects.filter(id=applicant_id).first() + + if not (internal_group_position and applicant): + return AddInternalGroupPositionPriorityMutation(success=False) + + applicant.add_priority(internal_group_position) + return AddInternalGroupPositionPriorityMutation(success=True) + + +class DeleteInternalGroupPositionPriority(DjangoDeleteMutation): + class Meta: + model = InternalGroupPositionPriority + + @classmethod + def before_save(cls, root, info, id, obj): + # We need to re-order the priorities in case something is deleted out of order + priorities = [Priority.FIRST, Priority.SECOND, Priority.THIRD] + applicant = obj.applicant + unfiltered_priorities = applicant.get_priorities + filtered_priorities = [] + # Unfiltered priorities can have None values. Get rid of them + for priority in unfiltered_priorities: + if not priority: + continue + # We don't want to re-add the position we are trying to delete + if priority.internal_group_position == obj.internal_group_position: + continue + + filtered_priorities.append(priority.internal_group_position) + + # Delete the priorities so we can add them in the right order + applicant.priorities.all().delete() + for index, position in enumerate(filtered_priorities): + priority = priorities[index] + applicant.priorities.add( + InternalGroupPositionPriority.objects.create( + applicant=applicant, + internal_group_position=position, + applicant_priority=priority, + ) + ) + return obj + + # === Admission === class CreateAdmissionMutation(DjangoCreateMutation): class Meta: @@ -600,10 +745,81 @@ class Meta: model = Admission -class ObfuscateAdmissionMutation(graphene.Mutation): - class Arguments: - pass +class CloseAdmissionMutation(graphene.Mutation): + failed_user_generation = graphene.List(ApplicantNode) + + def mutate(self, info, *args, **kwargs): + """ + 1. Get all applicants we have marked for admission + 2. Create a user instance for all of them with their data + 3. Should be able to handle some shitty inputs + 4. Give them the internal group position they were admitted for + 5. obfuscate admission + 6. Close the admission + """ + admission = Admission.get_active_admission() + admitted_applicants = Applicant.objects.filter( + will_be_admitted=True, admission=admission + ) + failed_user_generation = [] + for applicant in admitted_applicants: + try: + applicant_user_profile = User.objects.create( + username=applicant.email, + first_name=applicant.first_name, + last_name=applicant.last_name, + email=applicant.email, + profile_image=applicant.image, + phone=applicant.phone, + start_ksg=datetime.datetime.today(), + study_address=applicant.address, + home_address=applicant.hometown, + study=applicant.study, + date_of_birth=applicant.date_of_birth, + ) + """ + Unique constraints that can be fucked up here + 1. How should we handle emails? Do this at applicant stage? + 2. + """ + # We give the applicant the internal group position they have been accepted into + priorities = applicant.get_priorities + for priority in priorities: + # We find the first priority from the applicant where the internal group has marked + # it as "WANT" + if priority is None: + continue + if priority.internal_group_priority != InternalGroupStatus.WANT: + continue + + internal_group_position = priority.internal_group_position + InternalGroupPositionMembership.objects.create( + position=internal_group_position, + user=applicant_user_profile, + date_joined=datetime.date.today(), + ) + # We found their assigned position, eject. + break + SociBankAccount.objects.create( + user=applicant_user_profile, balance=0, card_uuid=None + ) + + except Exception as e: + failed_user_generation.append(applicant) + + # User generation is done. Now we want to remove all identifying information + obfuscate_admission(admission) + # It's a wrap folks + admission.status = AdmissionStatus.CLOSED + admission.save() + admitted_applicants.update(will_be_admitted=False) + + return CloseAdmissionMutation(failed_user_generation=failed_user_generation) + + +# This can probably be deleted +class ObfuscateAdmissionMutation(graphene.Mutation): ok = graphene.Boolean() def mutate(self, info, *args, **kwargs): @@ -622,12 +838,10 @@ class GenerateInterviewsMutation(graphene.Mutation): def mutate(self, info, *args, **kwargs): # retrieve the schedule template - schedule = ( - InterviewScheduleTemplate.objects.all().first() - ) # should handle this a bit better probably + schedule = InterviewScheduleTemplate.objects.all().first() + # should handle this a bit better probably generate_interviews_from_schedule(schedule) num = Interview.objects.all().count() - return GenerateInterviewsMutation(ok=True, interviews_generated=num) @@ -639,19 +853,29 @@ class Arguments: def mutate(self, info, interview_id, *args, **kwargs): interview_django_id = disambiguate_id(interview_id) - interview = Interview.objects.filter(id=interview_django_id).first() + interview = Interview.objects.filter(pk=interview_django_id).first() if not interview: return SetSelfAsInterviewerMutation(success=False) - # Interview exists. Here we can parse whether or not a person from this internal group is here already - # ToDo ^ + # ToDo: + # Interview exists. Here we can parse whether or not a person from this internal group is here already as well user = info.context.user + existing_interviewers = interview.interviewers.all() + if user in existing_interviewers: + # User is already on this interview + return SetSelfAsInterviewerMutation(success=True) + interview.interviewers.add(user) interview.save() return SetSelfAsInterviewerMutation(success=True) +class PatchInterviewMutation(DjangoPatchMutation): + class Meta: + model = Interview + + class RemoveSelfAsInterviewerMutation(graphene.Mutation): class Arguments: interview_id = graphene.ID(required=True) @@ -666,7 +890,7 @@ def mutate(self, info, interview_id, *args, **kwargs): user = info.context.user interviewers = interview.interviewers.all() - interview.interviewers.set(interviewers.exclude(user=user)) + interview.interviewers.set(interviewers.exclude(id=user.id)) interview.save() return RemoveSelfAsInterviewerMutation(success=True) @@ -754,10 +978,8 @@ class Meta: @classmethod def before_mutate(cls, root, info, input): - increment = ( - InterviewBooleanEvaluation.objects.all().order_by(("order")).last().order - + 1 - ) + count = InterviewBooleanEvaluation.objects.all().count() + increment = count + 1 input["order"] = increment return input @@ -829,6 +1051,8 @@ class AdmissionsMutations(graphene.ObjectType): DeleteInterviewLocationAvailabilityMutation.Field() ) + patch_interview = PatchInterviewMutation.Field() + create_interview_location = CreateInterviewLocationMutation.Field() delete_interview_location = DeleteInterviewLocationMutation.Field() @@ -864,3 +1088,14 @@ class AdmissionsMutations(graphene.ObjectType): book_interview = BookInterviewMutation.Field() obfuscate_admission = ObfuscateAdmissionMutation.Field() delete_all_interviews = DeleteAllInterviewsMutation.Field() + set_self_as_interviewer = SetSelfAsInterviewerMutation.Field() + remove_self_as_interviewer = RemoveSelfAsInterviewerMutation.Field() + toggle_applicant_will_be_admitted = ToggleApplicantWillBeAdmittedMutation.Field() + close_admission = CloseAdmissionMutation.Field() + + add_internal_group_position_priority = ( + AddInternalGroupPositionPriorityMutation.Field() + ) + delete_internal_group_position_priority = ( + DeleteInternalGroupPositionPriority.Field() + ) diff --git a/admissions/tests/test_utils.py b/admissions/tests/test_utils.py index 5ff5e593..a6f1df62 100644 --- a/admissions/tests/test_utils.py +++ b/admissions/tests/test_utils.py @@ -215,5 +215,14 @@ def test__interview_location_not_available_for_first_half__does_not_create_early self, ): generate_interviews_from_schedule(self.schedule) - print(Interview.objects.all()) self.assertEqual(Interview.objects.all().count(), 14) + + +class TestCloseAdmission(TestCase): + def setUp(self) -> None: + pass + + def test__interview_location_not_available_for_first_half__does_not_create_early_interview( + self, + ): + pass diff --git a/admissions/utils.py b/admissions/utils.py index a2176f15..ceddaf8d 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -52,9 +52,26 @@ def generate_interviews_from_schedule(schedule): schedule.interview_period_end_date, default_interview_day_end ) - # Lazy load model due to circular import errors + # Lazy load models due to circular import errors Interview = apps.get_model(app_label="admissions", model_name="Interview") + InterviewBooleanEvaluation = apps.get_model( + app_label="admissions", model_name="InterviewBooleanEvaluation" + ) + InterviewBooleanEvaluationAnswer = apps.get_model( + app_label="admissions", model_name="InterviewBooleanEvaluationAnswer" + ) + InterviewAdditionalEvaluationStatement = apps.get_model( + app_label="admissions", model_name="InterviewAdditionalEvaluationStatement" + ) + InterviewAdditionalEvaluationAnswer = apps.get_model( + app_label="admissions", model_name="InterviewAdditionalEvaluationAnswer" + ) + # We want to prepare the interview questions and add them to all interviews + boolean_evaluation_statements = InterviewBooleanEvaluation.objects.all() + additional_evaluation_statements = ( + InterviewAdditionalEvaluationStatement.objects.all() + ) while datetime_cursor < datetime_interview_period_end: # Generate interviews for the first session of the day for i in range(schedule.default_block_size): @@ -63,11 +80,19 @@ def generate_interviews_from_schedule(schedule): datetime_to=datetime_cursor + interview_duration, ) for location in available_locations: - Interview.objects.create( + interview = Interview.objects.create( location=location, interview_start=datetime_cursor, interview_end=datetime_cursor + interview_duration, ) + for statement in boolean_evaluation_statements: + InterviewBooleanEvaluationAnswer.objects.create( + interview=interview, statement=statement, value=None + ) + for statement in additional_evaluation_statements: + InterviewAdditionalEvaluationAnswer.objects.create( + interview=interview, statement=statement, answer=None + ) datetime_cursor += interview_duration # First session is over. We give the interviewers a break @@ -113,12 +138,12 @@ def mass_send_welcome_to_interview_email(emails): content = ( _( """ - Hei og velkommen til intervju hos KSG! - - Trykk på denne linken for å registrere søknaden videre - - Lenke: %(link)s - """ + Hei og velkommen til intervju hos KSG! + + Trykk på denne linken for å registrere søknaden videre + + Lenke: %(link)s + """ ) % {"link": f"{settings.APP_URL}/applicant-portal"} ) @@ -126,14 +151,14 @@ def mass_send_welcome_to_interview_email(emails): html_content = ( _( """ - Hei og velkommen til intervju hos KSG! -
-
- Trykk på denne linken for å registrere søknaden videre -
- Registrer søknad
-
- """ + Hei og velkommen til intervju hos KSG! +
+
+ Trykk på denne linken for å registrere søknaden videre +
+ Registrer søknad
+
+ """ ) % {"link": f"{settings.APP_URL}/applicant-portal"} ) @@ -151,12 +176,12 @@ def send_welcome_to_interview_email(email: str, auth_token: str): content = ( _( """ - Hei og velkommen til intervju hos KSG! - - Trykk på denne linken for å registrere søknaden videre - - Lenke: %(link)s - """ + Hei og velkommen til intervju hos KSG! + + Trykk på denne linken for å registrere søknaden videre + + Lenke: %(link)s + """ ) % {"link": f"{settings.APP_URL}/applicant-portal/{auth_token}"} ) @@ -164,14 +189,14 @@ def send_welcome_to_interview_email(email: str, auth_token: str): html_content = ( _( """ - Hei og velkommen til intervju hos KSG! -
-
- Trykk på denne linken for å registrere søknaden videre -
- Registrer søknad
-
- """ + Hei og velkommen til intervju hos KSG! +
+
+ Trykk på denne linken for å registrere søknaden videre +
+ Registrer søknad
+
+ """ ) % {"link": f"{settings.APP_URL}/applicant-portal/{auth_token}"} ) @@ -188,12 +213,12 @@ def resend_auth_token_email(applicant): content = ( _( """ - Hei og velkommen til KSG sin søkerportal! - - Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. - - Lenke: %(link)s - """ + Hei og velkommen til KSG sin søkerportal! + + Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. + + Lenke: %(link)s + """ ) % {"link": f"{settings.APP_URL}/applicant-portal/{applicant.token}"} ) @@ -201,13 +226,13 @@ def resend_auth_token_email(applicant): html_content = ( _( """ - Hei og velkommen til KSG sin søkerportal! -
- Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. -
- Registrer søknad
-
- """ + Hei og velkommen til KSG sin søkerportal! +
+ Trykk på denne linken for å registrere søknaden videre, eller se intervjutiden din. +
+ Registrer søknad
+
+ """ ) % {"link": f"{settings.APP_URL}/applicant-portal/{applicant.token}"} ) From 82e8f8992564d18ee557ce073cb5a53c212d4a2b Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sun, 13 Mar 2022 15:29:07 +0100 Subject: [PATCH 20/32] feat(admissions): add inline to admin.py --- admissions/admin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/admissions/admin.py b/admissions/admin.py index 88ace08c..e5277321 100644 --- a/admissions/admin.py +++ b/admissions/admin.py @@ -32,6 +32,11 @@ class InterviewBooleanEvaluationAnswerInline(admin.TabularInline): extra = 1 +class InternalGroupPositionPriorityInline(admin.TabularInline): + model = InternalGroupPositionPriority + extra = 1 + + @admin.register(Interview) class InterviewAdmin(admin.ModelAdmin): list_display = ("applicant",) @@ -78,7 +83,7 @@ class AdmissionAdmin(admin.ModelAdmin): @admin.register(Applicant) class ApplicantAdmin(admin.ModelAdmin): - pass + inlines = [InternalGroupPositionPriorityInline] @admin.register(InternalGroupPositionPriority) From e1bcadaaae1815a1dbe692518ed728ddff9d32bd Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Tue, 15 Mar 2022 20:05:33 +0100 Subject: [PATCH 21/32] feat(admissions): expand admissions funcitonality Tweaks models and adds queries for the admissions module. Commit summary: * add PASS_AROUND option to applicant status * correct related names for bool/additional evaluations * add proper resolver for bool/additional evaluation and merge them together as statements * remove default blank in IntenalGroupPriority * add resolver for applicant image * add objecttype and resolver for InternalGroupDiscussionData * add objecttype and resolver for InternalGroupApplicantsData * add PatchInternalGroupMutation * InternalGroup currrently dicsussing field --- admissions/consts.py | 1 + ...iewadditionalevaluationanswer_interview.py | 23 +++ .../migrations/0032_auto_20220310_1517.py | 40 +++++ ...ositionpriority_internal_group_priority.py | 29 ++++ admissions/models.py | 6 +- admissions/schema.py | 163 ++++++++++++++---- admissions/utils.py | 50 ++++++ ...0025_internalgroup_currently_discussing.py | 26 +++ organization/models.py | 6 + 9 files changed, 308 insertions(+), 36 deletions(-) create mode 100644 admissions/migrations/0031_alter_interviewadditionalevaluationanswer_interview.py create mode 100644 admissions/migrations/0032_auto_20220310_1517.py create mode 100644 admissions/migrations/0033_alter_internalgrouppositionpriority_internal_group_priority.py create mode 100644 organization/migrations/0025_internalgroup_currently_discussing.py diff --git a/admissions/consts.py b/admissions/consts.py index 2e3d3c30..061ebf6b 100644 --- a/admissions/consts.py +++ b/admissions/consts.py @@ -47,3 +47,4 @@ class InternalGroupStatus(models.TextChoices): ) RESERVE = ("reserve", "Reserve") SHOULD_BE_ADMITTED = ("should-be-admitted", "Should be admitted") + PASS_AROUND = ("pass-around", "Pass around") diff --git a/admissions/migrations/0031_alter_interviewadditionalevaluationanswer_interview.py b/admissions/migrations/0031_alter_interviewadditionalevaluationanswer_interview.py new file mode 100644 index 00000000..6e81121d --- /dev/null +++ b/admissions/migrations/0031_alter_interviewadditionalevaluationanswer_interview.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2022-03-08 14:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0030_alter_applicant_status"), + ] + + operations = [ + migrations.AlterField( + model_name="interviewadditionalevaluationanswer", + name="interview", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="additional_evaluation_answers", + to="admissions.interview", + ), + ), + ] diff --git a/admissions/migrations/0032_auto_20220310_1517.py b/admissions/migrations/0032_auto_20220310_1517.py new file mode 100644 index 00000000..cc2edc25 --- /dev/null +++ b/admissions/migrations/0032_auto_20220310_1517.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.12 on 2022-03-10 14:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0031_alter_interviewadditionalevaluationanswer_interview"), + ] + + operations = [ + migrations.AddField( + model_name="applicant", + name="discussion_end", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="applicant", + name="discussion_start", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="internalgrouppositionpriority", + name="internal_group_priority", + field=models.CharField( + blank=True, + choices=[ + ("want", "Want"), + ("do-not-want", "Do not want"), + ("reserve", "Reserve"), + ("should-be-admitted", "Should be admitted"), + ("pass-around", "Pass around"), + ], + default="", + max_length=24, + null=True, + ), + ), + ] diff --git a/admissions/migrations/0033_alter_internalgrouppositionpriority_internal_group_priority.py b/admissions/migrations/0033_alter_internalgrouppositionpriority_internal_group_priority.py new file mode 100644 index 00000000..d640f78c --- /dev/null +++ b/admissions/migrations/0033_alter_internalgrouppositionpriority_internal_group_priority.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.12 on 2022-03-10 14:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0032_auto_20220310_1517"), + ] + + operations = [ + migrations.AlterField( + model_name="internalgrouppositionpriority", + name="internal_group_priority", + field=models.CharField( + blank=True, + choices=[ + ("want", "Want"), + ("do-not-want", "Do not want"), + ("reserve", "Reserve"), + ("should-be-admitted", "Should be admitted"), + ("pass-around", "Pass around"), + ], + max_length=24, + null=True, + ), + ), + ] diff --git a/admissions/models.py b/admissions/models.py index afdab93a..ca5d6ca5 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -145,7 +145,7 @@ class Options(models.TextChoices): interview = models.ForeignKey( "admissions.Interview", on_delete=models.CASCADE, - related_name="additional_evaluation_statement_answers", + related_name="additional_evaluation_answers", ) statement = models.ForeignKey( "admissions.InterviewAdditionalEvaluationStatement", on_delete=models.CASCADE @@ -243,6 +243,9 @@ class Applicant(models.Model): wants_digital_interview = models.BooleanField(default=False) will_be_admitted = models.BooleanField(default=False) + discussion_start = models.DateTimeField(null=True, blank=True) + discussion_end = models.DateTimeField(null=True, blank=True) + def image_dir(self, filename): # We want to save all objects in under the admission return osjoin("applicants", str(self.admission.semester), filename) @@ -346,7 +349,6 @@ class Meta: max_length=24, blank=True, null=True, - default="", ) def __str__(self): diff --git a/admissions/schema.py b/admissions/schema.py index a41d15eb..df169ff9 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -4,11 +4,13 @@ from graphql_relay import to_global_id from graphene_django import DjangoObjectType from django.db import IntegrityError +from enum import Enum from graphene_django_cud.mutations import ( DjangoPatchMutation, DjangoDeleteMutation, DjangoCreateMutation, ) +from django.conf import settings from common.util import date_time_combiner from django.db.models import Q from graphene_django.filter import DjangoFilterConnectionField @@ -19,6 +21,7 @@ group_interviews_by_date, create_interview_slots, mass_send_welcome_to_interview_email, + internal_group_applicant_data, ) from django.core.exceptions import SuspiciousOperation from django.utils import timezone @@ -142,6 +145,12 @@ def resolve_priorities(self: Applicant, info, *args, **kwargs): ).first() return [first_priority, second_priority, third_priority] + def resolve_image(self: Applicant, info, **kwargs): + if self.image: + return f"{settings.HOST_URL}{self.image.url}" + else: + return None + @classmethod def get_node(cls, info, id): return Applicant.objects.get(pk=id) @@ -248,6 +257,11 @@ class BooleanEvaluationAnswer(graphene.ObjectType): answer = graphene.Boolean() +class AdditionalEvaluationAnswer(graphene.ObjectType): + statement = graphene.String() + answer = graphene.String() # Should be an enum + + class InterviewNode(DjangoObjectType): class Meta: model = Interview @@ -255,6 +269,7 @@ class Meta: interviewers = graphene.List(UserNode) boolean_evaluation_answers = graphene.List(BooleanEvaluationAnswer) + additional_evaluation_answers = graphene.List(AdditionalEvaluationAnswer) def resolve_boolean_evaluation_answers(self: Interview, info, *args, **kwargs): evaluations = [] @@ -268,6 +283,18 @@ def resolve_boolean_evaluation_answers(self: Interview, info, *args, **kwargs): ) return evaluations + def resolve_additional_evaluation_answers(self: Interview, info, *args, **kwargs): + evaluations = [] + for evaluation in self.additional_evaluation_answers.all().order_by( + "statement__order" + ): + evaluations.append( + AdditionalEvaluationAnswer( + statement=evaluation.statement, answer=evaluation.answer + ) + ) + return evaluations + def resolve_interviewers(self: Interview, info, *args, **kwargs): return self.interviewers.all() @@ -278,15 +305,32 @@ def get_node(cls, info, id): class InternalGroupApplicantsData(graphene.ObjectType): """ - A way to encapsulate the applicants for a given internal group - > Resolves all applicants for this group split into their priorities + A way to encapsulate the applicants for a given internal group. """ - internal_group_name = graphene.String() + internal_group = graphene.Field(InternalGroupNode) + # This should probably be InternalGroupPositionPriority objects instead + # and then we access the applicant data from there instead first_priorities = graphene.List(ApplicantNode) second_priorities = graphene.List(ApplicantNode) third_priorities = graphene.List(ApplicantNode) + positions_to_fill = graphene.Int() + # How far they have come in their process + current_progress = graphene.Int() + + +class InternalGroupDiscussionData(graphene.ObjectType): + internal_group = graphene.Field(InternalGroupNode) + current_applicant_under_discussion = graphene.Field(ApplicantNode) + + # All applicants having this group as their first pick + first_picks = graphene.List(InternalGroupPositionPriorityNode) + # All applicants which are being sent from other internal groups + available_second_picks = graphene.List(InternalGroupPositionPriorityNode) + available_third_picks = graphene.List(InternalGroupPositionPriorityNode) + processed_applicants = graphene.List(InternalGroupPositionPriorityNode) + class ApplicantQuery(graphene.ObjectType): applicant = Node.Field(ApplicantNode) @@ -295,7 +339,10 @@ class ApplicantQuery(graphene.ObjectType): internal_group_applicants_data = graphene.Field( InternalGroupApplicantsData, internal_group=graphene.ID() ) - + all_internal_group_applicant_data = graphene.List(InternalGroupApplicantsData) + internal_group_discussion_data = graphene.Field( + InternalGroupDiscussionData, internal_group_id=graphene.ID(required=True) + ) valid_applicants = graphene.List(ApplicantNode) def resolve_valid_applicants(self, info, *args, **kwargs): @@ -320,35 +367,81 @@ def resolve_internal_group_applicants_data( if not internal_group: return None - first_priorities = ( - Applicant.objects.all() - .filter( - priorities__applicant_priority=Priority.FIRST, - priorities__internal_group_position__internal_group=internal_group, - ) - .order_by("interview__interview_start") + data = internal_group_applicant_data(internal_group) + return data + + def resolve_all_internal_group_applicant_data(self, info, *args, **kwargs): + admission = Admission.get_active_admission() + positions = admission.available_internal_group_positions.all() + internal_groups = InternalGroup.objects.filter(positions__in=positions) + + internal_group_data = [] + for internal_group in internal_groups: + data = internal_group_applicant_data(internal_group) + internal_group_data.append(data) + + return internal_group_data + + def resolve_internal_group_discussion_data( + self, info, internal_group_id, *args, **kwargs + ): + internal_group_id = disambiguate_id(internal_group_id) + internal_group = InternalGroup.objects.filter(id=internal_group_id).first() + + if not internal_group: + return None + + # We want to return and filter this instead + all_internal_group_priorities = InternalGroupPositionPriority.objects.filter( + internal_group_position__internal_group=internal_group, + applicant__interview__isnull=False, + ).order_by("applicant__first_name") + + # We get everyone who has this internal group as its first pick + first_picks = all_internal_group_priorities.filter( + applicant_priority=Priority.FIRST, internal_group_priority__isnull=True + ).exclude(applicant=internal_group.currently_discussing) + + # Our back burner will be a combination of all second and third picks where their status has been set to + # the other group not wanting them or to pass around + second_picks = all_internal_group_priorities.filter( + applicant_priority=Priority.SECOND, ) - second_priorities = ( - Applicant.objects.all() - .filter( - priorities__applicant_priority=Priority.SECOND, - priorities__internal_group_position__internal_group=internal_group, - ) - .order_by("interview__interview_start") + + # Here we get the queryset of all users that have this internal group as their second choice but has also + # been rejected by their first choice + available_second_picks = second_picks.filter( + applicant__priorities__applicant_priority=Priority.FIRST, + applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT, + internal_group_priority__isnull=True, ) - third_priorities = ( - Applicant.objects.all() - .filter( - priorities__applicant_priority=Priority.THIRD, - priorities__internal_group_position__internal_group=internal_group, - ) - .order_by("interview__interview_start") + + third_picks = all_internal_group_priorities.filter( + applicant_priority=Priority.THIRD + ) + # Here we get the queryset of all users that have this internal group as their second choice but has also + # been rejected by their first and second choice. Hence the double filter chaining + available_third_picks = third_picks.filter( + applicant__priorities__applicant_priority=Priority.FIRST, + applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT, + ).filter( + applicant__priorities__applicant_priority=Priority.SECOND, + applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT, + internal_group_priority__isnull=True, + ) + processed_applicants = all_internal_group_priorities.filter( + internal_group_priority__isnull=False ) - return InternalGroupApplicantsData( - internal_group_name=internal_group.name, - first_priorities=first_priorities, - second_priorities=second_priorities, - third_priorities=third_priorities, + + currently_discussing = internal_group.currently_discussing + + return InternalGroupDiscussionData( + internal_group=internal_group, + first_picks=first_picks, + available_second_picks=available_second_picks, + available_third_picks=available_third_picks, + processed_applicants=processed_applicants, + current_applicant_under_discussion=currently_discussing, ) @@ -366,10 +459,6 @@ def mutate(self, info, email, *args, **kwargs): return ResendApplicantTokenMutation(ok=False) -class ApplicationData(graphene.ObjectType): - email = graphene.String() - - class CreateApplicationsMutation(graphene.Mutation): class Arguments: emails = graphene.List(graphene.String) @@ -694,6 +783,11 @@ def mutate(self, info, internal_group_position_id, applicant_id, *args, **kwargs return AddInternalGroupPositionPriorityMutation(success=True) +class PatchInternalGroupPositionPriority(DjangoPatchMutation): + class Meta: + model = InternalGroupPositionPriority + + class DeleteInternalGroupPositionPriority(DjangoDeleteMutation): class Meta: model = InternalGroupPositionPriority @@ -1096,6 +1190,7 @@ class AdmissionsMutations(graphene.ObjectType): add_internal_group_position_priority = ( AddInternalGroupPositionPriorityMutation.Field() ) + patch_internal_group_position_priority = PatchInternalGroupPositionPriority.Field() delete_internal_group_position_priority = ( DeleteInternalGroupPositionPriority.Field() ) diff --git a/admissions/utils.py b/admissions/utils.py index ceddaf8d..b3fc7e4b 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -11,6 +11,7 @@ parse_datetime_to_midnight, validate_qs, ) +from admissions.consts import InternalGroupStatus, Priority def get_available_interview_locations(datetime_from=None, datetime_to=None): @@ -361,3 +362,52 @@ def create_interview_slots(interview_days): parsed_interviews.append(interview_day) return parsed_interviews + + +def internal_group_applicant_data(internal_group): + """ + Accepts an internal group and retrieves its admission data + """ + from admissions.schema import InternalGroupApplicantsData + + Applicant = apps.get_model(app_label="admissions", model_name="Applicant") + Admission = apps.get_model(app_label="admissions", model_name="Admission") + InternalGroupPositionPriority = apps.get_model( + app_label="admissions", model_name="InternalGroupPositionPriority" + ) + + first_priorities = Applicant.objects.filter( + priorities__applicant_priority=Priority.FIRST, + priorities__internal_group_position__internal_group=internal_group, + ).order_by("interview__interview_start") + second_priorities = Applicant.objects.filter( + priorities__applicant_priority=Priority.SECOND, + priorities__internal_group_position__internal_group=internal_group, + ).order_by("interview__interview_start") + third_priorities = Applicant.objects.filter( + priorities__applicant_priority=Priority.THIRD, + priorities__internal_group_position__internal_group=internal_group, + ).order_by("interview__interview_start") + + all_priorities = InternalGroupPositionPriority.objects.filter( + internal_group_position__internal_group=internal_group + ) + + want_count = all_priorities.filter( + internal_group_priority=InternalGroupStatus.WANT + ).count() + + admission = Admission.get_active_admission() + data = admission.available_internal_group_positions_data.filter( + internal_group_position__internal_group=internal_group + ).first() + positions_to_fill = data.available_positions + + return InternalGroupApplicantsData( + internal_group=internal_group, + first_priorities=first_priorities, + second_priorities=second_priorities, + third_priorities=third_priorities, + current_progress=want_count, + positions_to_fill=positions_to_fill, + ) diff --git a/organization/migrations/0025_internalgroup_currently_discussing.py b/organization/migrations/0025_internalgroup_currently_discussing.py new file mode 100644 index 00000000..6a1b7178 --- /dev/null +++ b/organization/migrations/0025_internalgroup_currently_discussing.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.12 on 2022-03-08 13:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0030_alter_applicant_status"), + ("organization", "0024_internalgroupposition_available_externally"), + ] + + operations = [ + migrations.AddField( + model_name="internalgroup", + name="currently_discussing", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="groups", + to="admissions.applicant", + ), + ), + ] diff --git a/organization/models.py b/organization/models.py index c8b23fd4..8863be9a 100644 --- a/organization/models.py +++ b/organization/models.py @@ -31,6 +31,12 @@ class Type(models.TextChoices): description = models.TextField(max_length=2048, blank=True, null=True) group_image = models.ImageField(upload_to="internalgroups", null=True, blank=True) + # This field is used during admission when internal groups are discussing candidates + # Turns out this doesnt make sense have this after all. Delete this and refactor frontend view + currently_discussing = models.OneToOneField( + "admissions.Applicant", null=True, blank=True, on_delete=models.SET_NULL + ) + @property def active_members(self): """ From 594b2a6aa8a50946e541f382f945716f634ea6ef Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sun, 20 Mar 2022 17:00:48 +0100 Subject: [PATCH 22/32] chore(admissions): rename Admission.status option Rename FINALIZATION to LOCKED --- admissions/consts.py | 6 +- .../migrations/0034_alter_admission_status.py | 32 +++++++ admissions/schema.py | 88 ++++++++++++++++--- 3 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 admissions/migrations/0034_alter_admission_status.py diff --git a/admissions/consts.py b/admissions/consts.py index 061ebf6b..2644f5e6 100644 --- a/admissions/consts.py +++ b/admissions/consts.py @@ -12,9 +12,9 @@ class AdmissionStatus(models.TextChoices): INTERVIEW_OVERVIEW = ("interview-overview", "Interview overview") OPEN = ("open", "Open") IN_SESSION = ("in-session", "In session") # Fordelingsmøtet - FINALIZATION = ( - "finalization", - "Finalization", + LOCKED = ( + "locked", + "Locked", ) # Reviewing last step before admitting everyone CLOSED = ("closed", "Closed") diff --git a/admissions/migrations/0034_alter_admission_status.py b/admissions/migrations/0034_alter_admission_status.py new file mode 100644 index 00000000..514a9b2d --- /dev/null +++ b/admissions/migrations/0034_alter_admission_status.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.12 on 2022-03-20 14:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "admissions", + "0033_alter_internalgrouppositionpriority_internal_group_priority", + ), + ] + + operations = [ + migrations.AlterField( + model_name="admission", + name="status", + field=models.CharField( + choices=[ + ("configuration", "Configuration"), + ("interview-overview", "Interview overview"), + ("open", "Open"), + ("in-session", "In session"), + ("locked", "Locked"), + ("closed", "Closed"), + ], + default="open", + max_length=32, + ), + ), + ] diff --git a/admissions/schema.py b/admissions/schema.py index df169ff9..314ef4d9 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -329,6 +329,7 @@ class InternalGroupDiscussionData(graphene.ObjectType): # All applicants which are being sent from other internal groups available_second_picks = graphene.List(InternalGroupPositionPriorityNode) available_third_picks = graphene.List(InternalGroupPositionPriorityNode) + available_picks = graphene.List(InternalGroupPositionPriorityNode) processed_applicants = graphene.List(InternalGroupPositionPriorityNode) @@ -385,6 +386,11 @@ def resolve_all_internal_group_applicant_data(self, info, *args, **kwargs): def resolve_internal_group_discussion_data( self, info, internal_group_id, *args, **kwargs ): + """ + We want to rework this to work with a table view instead. It probably still makes sense to consider + processed applicants are those which have the status WANT and DO_NOT_WANT. Remaining states + are PASS_AROUND, RESERVE and SHOULD_BE_ADMITTED. + """ internal_group_id = disambiguate_id(internal_group_id) internal_group = InternalGroup.objects.filter(id=internal_group_id).first() @@ -399,7 +405,13 @@ def resolve_internal_group_discussion_data( # We get everyone who has this internal group as its first pick first_picks = all_internal_group_priorities.filter( - applicant_priority=Priority.FIRST, internal_group_priority__isnull=True + applicant_priority=Priority.FIRST, + internal_group_priority__in=[ + InternalGroupStatus.PASS_AROUND, + InternalGroupStatus.RESERVE, + InternalGroupStatus.SHOULD_BE_ADMITTED, + None, + ], ).exclude(applicant=internal_group.currently_discussing) # Our back burner will be a combination of all second and third picks where their status has been set to @@ -412,8 +424,16 @@ def resolve_internal_group_discussion_data( # been rejected by their first choice available_second_picks = second_picks.filter( applicant__priorities__applicant_priority=Priority.FIRST, - applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT, - internal_group_priority__isnull=True, + applicant__priorities__internal_group_priority__in=[ + InternalGroupStatus.DO_NOT_WANT, + InternalGroupStatus.PASS_AROUND, + ], + internal_group_priority__in=[ + InternalGroupStatus.PASS_AROUND, + InternalGroupStatus.RESERVE, + InternalGroupStatus.SHOULD_BE_ADMITTED, + None, + ], ) third_picks = all_internal_group_priorities.filter( @@ -421,26 +441,51 @@ def resolve_internal_group_discussion_data( ) # Here we get the queryset of all users that have this internal group as their second choice but has also # been rejected by their first and second choice. Hence the double filter chaining - available_third_picks = third_picks.filter( - applicant__priorities__applicant_priority=Priority.FIRST, - applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT, - ).filter( - applicant__priorities__applicant_priority=Priority.SECOND, - applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT, - internal_group_priority__isnull=True, + available_third_picks = ( + third_picks.filter( + applicant__priorities__applicant_priority=Priority.FIRST, + applicant__priorities__internal_group_priority__in=[ + InternalGroupStatus.DO_NOT_WANT, + InternalGroupStatus.PASS_AROUND, + ], + ) + .filter( + applicant__priorities__applicant_priority=Priority.SECOND, + applicant__priorities__internal_group_priority__in=[ + InternalGroupStatus.DO_NOT_WANT, + InternalGroupStatus.PASS_AROUND, + ], + ) + .filter( + Q(internal_group_priority=None) + | ~Q( + internal_group_priority__in=[ + InternalGroupStatus.WANT, + InternalGroupStatus.DO_NOT_WANT, + ] + ) + ) ) + processed_applicants = all_internal_group_priorities.filter( - internal_group_priority__isnull=False + internal_group_priority__in=[ + InternalGroupStatus.WANT, + InternalGroupStatus.DO_NOT_WANT, + ] ) + available_picks = first_picks | available_second_picks | available_third_picks + currently_discussing = internal_group.currently_discussing return InternalGroupDiscussionData( internal_group=internal_group, + available_picks=available_picks.distinct(), + processed_applicants=processed_applicants, + # All these below are probably obsolete first_picks=first_picks, available_second_picks=available_second_picks, available_third_picks=available_third_picks, - processed_applicants=processed_applicants, current_applicant_under_discussion=currently_discussing, ) @@ -839,6 +884,24 @@ class Meta: model = Admission +class LockAdmissionMutation(graphene.Mutation): + # Final stage before we decide who is admitted into KSG. + # Requires that all applicants have been evaluated in som manner + admission = graphene.Field(AdmissionNode) + + def mutate(self, info, *args, **kwargs): + admission = Admission.get_active_admission() + unevaluated_applicants = admission.applicants.filter( + priorities__internal_group_priority__isnull=True + ) + if unevaluated_applicants: + raise Exception("All applicants have not been considered") + + admission.status = AdmissionStatus.LOCKED + admission.save() + return LockAdmissionMutation(admission=admission) + + class CloseAdmissionMutation(graphene.Mutation): failed_user_generation = graphene.List(ApplicantNode) @@ -1185,6 +1248,7 @@ class AdmissionsMutations(graphene.ObjectType): set_self_as_interviewer = SetSelfAsInterviewerMutation.Field() remove_self_as_interviewer = RemoveSelfAsInterviewerMutation.Field() toggle_applicant_will_be_admitted = ToggleApplicantWillBeAdmittedMutation.Field() + lock_admission = LockAdmissionMutation.Field() close_admission = CloseAdmissionMutation.Field() add_internal_group_position_priority = ( From 92d9e986072b51a2aeed4e3499e04831e1ae047d Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Thu, 24 Mar 2022 18:14:41 +0100 Subject: [PATCH 23/32] fix(admissions): internal group discussion data Applicants were being incorrectly rendered in the different views --- admissions/schema.py | 65 ++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index 314ef4d9..1f6b774d 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -405,14 +405,10 @@ def resolve_internal_group_discussion_data( # We get everyone who has this internal group as its first pick first_picks = all_internal_group_priorities.filter( + ~Q(internal_group_priority=InternalGroupStatus.WANT), + ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), applicant_priority=Priority.FIRST, - internal_group_priority__in=[ - InternalGroupStatus.PASS_AROUND, - InternalGroupStatus.RESERVE, - InternalGroupStatus.SHOULD_BE_ADMITTED, - None, - ], - ).exclude(applicant=internal_group.currently_discussing) + ) # Our back burner will be a combination of all second and third picks where their status has been set to # the other group not wanting them or to pass around @@ -423,17 +419,12 @@ def resolve_internal_group_discussion_data( # Here we get the queryset of all users that have this internal group as their second choice but has also # been rejected by their first choice available_second_picks = second_picks.filter( + Q( + applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT + ), # Should this be expanded? + ~Q(internal_group_priority=InternalGroupStatus.WANT), + ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), applicant__priorities__applicant_priority=Priority.FIRST, - applicant__priorities__internal_group_priority__in=[ - InternalGroupStatus.DO_NOT_WANT, - InternalGroupStatus.PASS_AROUND, - ], - internal_group_priority__in=[ - InternalGroupStatus.PASS_AROUND, - InternalGroupStatus.RESERVE, - InternalGroupStatus.SHOULD_BE_ADMITTED, - None, - ], ) third_picks = all_internal_group_priorities.filter( @@ -441,30 +432,22 @@ def resolve_internal_group_discussion_data( ) # Here we get the queryset of all users that have this internal group as their second choice but has also # been rejected by their first and second choice. Hence the double filter chaining - available_third_picks = ( - third_picks.filter( - applicant__priorities__applicant_priority=Priority.FIRST, - applicant__priorities__internal_group_priority__in=[ - InternalGroupStatus.DO_NOT_WANT, - InternalGroupStatus.PASS_AROUND, - ], - ) - .filter( - applicant__priorities__applicant_priority=Priority.SECOND, - applicant__priorities__internal_group_priority__in=[ - InternalGroupStatus.DO_NOT_WANT, - InternalGroupStatus.PASS_AROUND, - ], - ) - .filter( - Q(internal_group_priority=None) - | ~Q( - internal_group_priority__in=[ - InternalGroupStatus.WANT, - InternalGroupStatus.DO_NOT_WANT, - ] - ) - ) + available_third_picks = third_picks.filter( + Q( + applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT + ), + # Should this be expanded? + ~Q(internal_group_priority=InternalGroupStatus.WANT), + ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), + applicant__priorities__applicant_priority=Priority.FIRST, + ).filter( + Q( + applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT + ), + # Should this be expanded? + ~Q(internal_group_priority=InternalGroupStatus.WANT), + ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), + applicant__priorities__applicant_priority=Priority.SECOND, ) processed_applicants = all_internal_group_priorities.filter( From dd5976e6f85ec72c82be3a8dfbfe1abdfabdb3a5 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Thu, 24 Mar 2022 18:30:07 +0100 Subject: [PATCH 24/32] chore(admissions): code cleanup and commnet --- admissions/schema.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index 1f6b774d..8983c44b 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -390,6 +390,9 @@ def resolve_internal_group_discussion_data( We want to rework this to work with a table view instead. It probably still makes sense to consider processed applicants are those which have the status WANT and DO_NOT_WANT. Remaining states are PASS_AROUND, RESERVE and SHOULD_BE_ADMITTED. + + Missing data: + > Some way to handle free-for-all applicants """ internal_group_id = disambiguate_id(internal_group_id) internal_group = InternalGroup.objects.filter(id=internal_group_id).first() @@ -410,8 +413,6 @@ def resolve_internal_group_discussion_data( applicant_priority=Priority.FIRST, ) - # Our back burner will be a combination of all second and third picks where their status has been set to - # the other group not wanting them or to pass around second_picks = all_internal_group_priorities.filter( applicant_priority=Priority.SECOND, ) @@ -421,7 +422,7 @@ def resolve_internal_group_discussion_data( available_second_picks = second_picks.filter( Q( applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT - ), # Should this be expanded? + ), ~Q(internal_group_priority=InternalGroupStatus.WANT), ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), applicant__priorities__applicant_priority=Priority.FIRST, @@ -430,13 +431,12 @@ def resolve_internal_group_discussion_data( third_picks = all_internal_group_priorities.filter( applicant_priority=Priority.THIRD ) - # Here we get the queryset of all users that have this internal group as their second choice but has also + # Here we get the queryset of all users that have this internal group as their third choice but has also # been rejected by their first and second choice. Hence the double filter chaining available_third_picks = third_picks.filter( Q( applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT ), - # Should this be expanded? ~Q(internal_group_priority=InternalGroupStatus.WANT), ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), applicant__priorities__applicant_priority=Priority.FIRST, @@ -444,7 +444,6 @@ def resolve_internal_group_discussion_data( Q( applicant__priorities__internal_group_priority=InternalGroupStatus.DO_NOT_WANT ), - # Should this be expanded? ~Q(internal_group_priority=InternalGroupStatus.WANT), ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), applicant__priorities__applicant_priority=Priority.SECOND, @@ -457,19 +456,13 @@ def resolve_internal_group_discussion_data( ] ) + # Merge together all applicants into a single list available_picks = first_picks | available_second_picks | available_third_picks - currently_discussing = internal_group.currently_discussing - return InternalGroupDiscussionData( internal_group=internal_group, available_picks=available_picks.distinct(), processed_applicants=processed_applicants, - # All these below are probably obsolete - first_picks=first_picks, - available_second_picks=available_second_picks, - available_third_picks=available_third_picks, - current_applicant_under_discussion=currently_discussing, ) From a531d3ed6e9c1795a847d040f483faf46b635893 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Thu, 24 Mar 2022 18:31:35 +0100 Subject: [PATCH 25/32] chore(admissions): remove obsolete type --- admissions/consts.py | 1 - .../migrations/0035_alter_admission_status.py | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 admissions/migrations/0035_alter_admission_status.py diff --git a/admissions/consts.py b/admissions/consts.py index 2644f5e6..248047e1 100644 --- a/admissions/consts.py +++ b/admissions/consts.py @@ -9,7 +9,6 @@ class Priority(models.TextChoices): class AdmissionStatus(models.TextChoices): INITIALIZATION = ("configuration", "Configuration") - INTERVIEW_OVERVIEW = ("interview-overview", "Interview overview") OPEN = ("open", "Open") IN_SESSION = ("in-session", "In session") # Fordelingsmøtet LOCKED = ( diff --git a/admissions/migrations/0035_alter_admission_status.py b/admissions/migrations/0035_alter_admission_status.py new file mode 100644 index 00000000..5b0031f0 --- /dev/null +++ b/admissions/migrations/0035_alter_admission_status.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.12 on 2022-03-24 17:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0034_alter_admission_status"), + ] + + operations = [ + migrations.AlterField( + model_name="admission", + name="status", + field=models.CharField( + choices=[ + ("configuration", "Configuration"), + ("open", "Open"), + ("in-session", "In session"), + ("locked", "Locked"), + ("closed", "Closed"), + ], + default="open", + max_length=32, + ), + ), + ] From b684cdac0df472a7742d773e98100e94cc979e0b Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Thu, 24 Mar 2022 18:37:05 +0100 Subject: [PATCH 26/32] chore(admissions): remove obsolete comment --- admissions/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/admissions/models.py b/admissions/models.py index ca5d6ca5..00305aad 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -271,7 +271,6 @@ def image_dir(self, filename): @classmethod def create_or_update_application(cls, email): """Can extend this method in the future to handle adding applications to new positions""" - # We can consider changing this to send the email with bcc and then the link kan be requested current_admission = Admission.get_or_create_current_admission() auth_token = token_urlsafe(32) cls.objects.create(email=email, admission=current_admission, token=auth_token) From ac1cb171922edd02295150104f03b6ce71e34df4 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sat, 26 Mar 2022 20:59:49 +0100 Subject: [PATCH 27/32] chore(admissions): remove deprecated code --- admissions/schema.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index 8983c44b..098b4515 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -4,7 +4,6 @@ from graphql_relay import to_global_id from graphene_django import DjangoObjectType from django.db import IntegrityError -from enum import Enum from graphene_django_cud.mutations import ( DjangoPatchMutation, DjangoDeleteMutation, @@ -63,12 +62,6 @@ class Meta: model = InternalGroupPositionPriority interfaces = (Node,) - def resolve_internal_group_priority(self, info, *args, **kwargs): - # Shady. Should do something else about this - if self.internal_group_priority == "": - return None - return self.internal_group_priority - class InterviewLocationAvailabilityNode(DjangoObjectType): class Meta: @@ -252,6 +245,14 @@ def get_node(cls, info, id): return Admission.objects.get(pk=id) +class AdditionalEvaluationAnswerEnum(graphene.Enum): + VERY_LITTLE = "VERY" + LITTLE = "LITTLE" + MEDIUM = "MEDIUM" + SOMEWHAT = "SOMEWHAT" + VERY = "VERY" + + class BooleanEvaluationAnswer(graphene.ObjectType): statement = graphene.String() answer = graphene.Boolean() @@ -259,7 +260,7 @@ class BooleanEvaluationAnswer(graphene.ObjectType): class AdditionalEvaluationAnswer(graphene.ObjectType): statement = graphene.String() - answer = graphene.String() # Should be an enum + answer = AdditionalEvaluationAnswerEnum() class InterviewNode(DjangoObjectType): From fb1b7147f4068e8b2ab821c5f6691d24a71dc9fe Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sat, 26 Mar 2022 21:12:05 +0100 Subject: [PATCH 28/32] chore(admission): change code comment --- admissions/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admissions/schema.py b/admissions/schema.py index 098b4515..e399d5ec 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -119,7 +119,7 @@ def resolve_interviewer_from_internal_group( interviewer_from_internal_group = interviewers.filter( internal_group_position_history__date_ended__isnull=True, internal_group_position_history__position__internal_group=internal_group, - ).first() # Should only be one + ).first() # We assume that we have constraint that only allows interviewer from one internal group if not interviewer_from_internal_group: return None From 7d85a5066f121e7fa84abe81b437670255a0a963 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sat, 26 Mar 2022 21:56:46 +0100 Subject: [PATCH 29/32] feat(admissions): resolver cleanup * Remove unused fields in internal group discussion data * Check count instead of queryset before locking admission --- admissions/schema.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index e399d5ec..b79c1584 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -323,13 +323,6 @@ class InternalGroupApplicantsData(graphene.ObjectType): class InternalGroupDiscussionData(graphene.ObjectType): internal_group = graphene.Field(InternalGroupNode) - current_applicant_under_discussion = graphene.Field(ApplicantNode) - - # All applicants having this group as their first pick - first_picks = graphene.List(InternalGroupPositionPriorityNode) - # All applicants which are being sent from other internal groups - available_second_picks = graphene.List(InternalGroupPositionPriorityNode) - available_third_picks = graphene.List(InternalGroupPositionPriorityNode) available_picks = graphene.List(InternalGroupPositionPriorityNode) processed_applicants = graphene.List(InternalGroupPositionPriorityNode) @@ -388,12 +381,12 @@ def resolve_internal_group_discussion_data( self, info, internal_group_id, *args, **kwargs ): """ - We want to rework this to work with a table view instead. It probably still makes sense to consider - processed applicants are those which have the status WANT and DO_NOT_WANT. Remaining states - are PASS_AROUND, RESERVE and SHOULD_BE_ADMITTED. + We resolve the data required for an internal group to consider different applicants. This means we try + to filter all applicants which are possible for this internal group to evaluate. The internal group + will not see an applicant before the applicants other priorities has said they do not want them. - Missing data: - > Some way to handle free-for-all applicants + In the future we should probably still resolve these users but instead "disable" them for this group + until its their turn to mark the applicant. """ internal_group_id = disambiguate_id(internal_group_id) internal_group = InternalGroup.objects.filter(id=internal_group_id).first() @@ -870,8 +863,8 @@ def mutate(self, info, *args, **kwargs): admission = Admission.get_active_admission() unevaluated_applicants = admission.applicants.filter( priorities__internal_group_priority__isnull=True - ) - if unevaluated_applicants: + ).count() + if unevaluated_applicants > 0: raise Exception("All applicants have not been considered") admission.status = AdmissionStatus.LOCKED From d4f61f4d6e2ef0b955cc05bd74114f6ed547c2a4 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sat, 26 Mar 2022 21:59:00 +0100 Subject: [PATCH 30/32] chore(admission): delete deprecated mutation --- admissions/schema.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/admissions/schema.py b/admissions/schema.py index b79c1584..cc4b5450 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -945,19 +945,6 @@ def mutate(self, info, *args, **kwargs): return CloseAdmissionMutation(failed_user_generation=failed_user_generation) -# This can probably be deleted -class ObfuscateAdmissionMutation(graphene.Mutation): - ok = graphene.Boolean() - - def mutate(self, info, *args, **kwargs): - admission = Admission.get_active_admission() - if not admission: - return ObfuscateAdmissionMutation(ok=False) - - obfuscate_admission(admission) - return ObfuscateAdmissionMutation(ok=True) - - # === Interview === class GenerateInterviewsMutation(graphene.Mutation): ok = graphene.Boolean() @@ -1213,7 +1200,6 @@ class AdmissionsMutations(graphene.ObjectType): re_send_application_token = ResendApplicantTokenMutation.Field() generate_interviews = GenerateInterviewsMutation.Field() book_interview = BookInterviewMutation.Field() - obfuscate_admission = ObfuscateAdmissionMutation.Field() delete_all_interviews = DeleteAllInterviewsMutation.Field() set_self_as_interviewer = SetSelfAsInterviewerMutation.Field() remove_self_as_interviewer = RemoveSelfAsInterviewerMutation.Field() From 945914edbbf81690e58981d79887b60d6865dc7a Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sat, 26 Mar 2022 22:11:10 +0100 Subject: [PATCH 31/32] fix(admissions): add missing before_mutate hook AdditionalEvaluationStatement createm utation did not add a order to it --- admissions/schema.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/admissions/schema.py b/admissions/schema.py index cc4b5450..5880a9da 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -1114,6 +1114,13 @@ class Meta: model = InterviewAdditionalEvaluationStatement exclude_fields = ("order",) + @classmethod + def before_mutate(cls, root, info, input): + count = InterviewAdditionalEvaluationStatement.objects.all().count() + increment = count + 1 + input["order"] = increment + return input + class PatchInterviewAdditionalEvaluationStatementMutation(DjangoPatchMutation): class Meta: From 7fb22b7082879ed333df7d9d402aaec41b00abd5 Mon Sep 17 00:00:00 2001 From: Alexander Orvik Date: Sat, 26 Mar 2022 22:24:48 +0100 Subject: [PATCH 32/32] feat(organization): remove deprecated model field --- ...remove_internalgroup_currently_discussing.py | 17 +++++++++++++++++ organization/models.py | 6 ------ 2 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 organization/migrations/0026_remove_internalgroup_currently_discussing.py diff --git a/organization/migrations/0026_remove_internalgroup_currently_discussing.py b/organization/migrations/0026_remove_internalgroup_currently_discussing.py new file mode 100644 index 00000000..44bfb350 --- /dev/null +++ b/organization/migrations/0026_remove_internalgroup_currently_discussing.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.12 on 2022-03-26 21:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("organization", "0025_internalgroup_currently_discussing"), + ] + + operations = [ + migrations.RemoveField( + model_name="internalgroup", + name="currently_discussing", + ), + ] diff --git a/organization/models.py b/organization/models.py index 8863be9a..c8b23fd4 100644 --- a/organization/models.py +++ b/organization/models.py @@ -31,12 +31,6 @@ class Type(models.TextChoices): description = models.TextField(max_length=2048, blank=True, null=True) group_image = models.ImageField(upload_to="internalgroups", null=True, blank=True) - # This field is used during admission when internal groups are discussing candidates - # Turns out this doesnt make sense have this after all. Delete this and refactor frontend view - currently_discussing = models.OneToOneField( - "admissions.Applicant", null=True, blank=True, on_delete=models.SET_NULL - ) - @property def active_members(self): """