diff --git a/Makefile b/Makefile index c4cac57b..e4736372 100644 --- a/Makefile +++ b/Makefile @@ -45,3 +45,16 @@ user-cooler: 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 + + +.PHONY: nukeadmission +nukeadmission: + poetry run python manage.py nuke_admission_data diff --git a/admissions/admin.py b/admissions/admin.py index 735d4e08..e5277321 100644 --- a/admissions/admin.py +++ b/admissions/admin.py @@ -32,8 +32,14 @@ class InterviewBooleanEvaluationAnswerInline(admin.TabularInline): extra = 1 +class InternalGroupPositionPriorityInline(admin.TabularInline): + model = InternalGroupPositionPriority + extra = 1 + + @admin.register(Interview) class InterviewAdmin(admin.ModelAdmin): + list_display = ("applicant",) inlines = ( InterviewAdditionalEvaluationAnswerInline, InterviewBooleanEvaluationAnswerInline, @@ -77,7 +83,7 @@ class AdmissionAdmin(admin.ModelAdmin): @admin.register(Applicant) class ApplicantAdmin(admin.ModelAdmin): - pass + inlines = [InternalGroupPositionPriorityInline] @admin.register(InternalGroupPositionPriority) diff --git a/admissions/consts.py b/admissions/consts.py index 5665ab4f..248047e1 100644 --- a/admissions/consts.py +++ b/admissions/consts.py @@ -8,12 +8,12 @@ class Priority(models.TextChoices): class AdmissionStatus(models.TextChoices): - INITIALIZATION = ("initialization", "Initialization") + INITIALIZATION = ("configuration", "Configuration") 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") @@ -22,6 +22,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 = ( @@ -45,3 +46,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/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/_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 new file mode 100644 index 00000000..742d0609 --- /dev/null +++ b/admissions/management/commands/generate_active_admission.py @@ -0,0 +1,278 @@ +from django.core.management.base import BaseCommand, CommandError +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): + """ + 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: + self.generate_interview_schedule() + + except Exception as e: + self.stdout.write(self.style.ERROR(f"{e}")) + raise CommandError(e) + self.stdout.write(self.style.SUCCESS("Active admission has been generated")) + + def generate_interview_schedule(self): + """""" + 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} + """ + ) + ) + + # 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() + + # Create two interview locations + + 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) + + 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() + + 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) + .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() + + 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) 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/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/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/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/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/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/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/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, + ), + ), + ] diff --git a/admissions/models.py b/admissions/models.py index adf0f09f..00305aad 100644 --- a/admissions/models.py +++ b/admissions/models.py @@ -2,7 +2,7 @@ from django.db.models import Q from common.util import get_semester_year_shorthand from django.utils import timezone -from django.core.validators import MinValueValidator +from django.db.utils import IntegrityError from admissions.consts import ( Priority, ApplicantStatus, @@ -12,8 +12,8 @@ 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 django.db.utils import DatabaseError +from organization.models import InternalGroup +import datetime class AdmissionAvailableInternalGroupPositionData(models.Model): @@ -25,15 +25,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, default=timezone.now) status = models.CharField( choices=AdmissionStatus.choices, default=AdmissionStatus.OPEN, max_length=32 ) @@ -51,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)) @@ -77,6 +86,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 @@ -86,15 +96,26 @@ 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 ) - 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): + """ + 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 @@ -121,17 +142,19 @@ 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_answers", + ) 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): """ @@ -183,7 +206,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) @@ -217,6 +240,12 @@ 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) + 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) @@ -232,7 +261,11 @@ 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 @@ -242,7 +275,45 @@ def create_or_update_application(cls, email): 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) + @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): @@ -277,7 +348,6 @@ class Meta: max_length=24, blank=True, null=True, - default="", ) def __str__(self): @@ -302,8 +372,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) ) @@ -318,6 +397,28 @@ 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() + + @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 + """ + 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""" @@ -335,6 +436,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/schema.py b/admissions/schema.py index 4a4dc1e0..5880a9da 100644 --- a/admissions/schema.py +++ b/admissions/schema.py @@ -1,5 +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 ( @@ -7,22 +9,52 @@ 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 from admissions.utils import ( generate_interviews_from_schedule, resend_auth_token_email, obfuscate_admission, + 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 from admissions.models import ( Applicant, Admission, AdmissionAvailableInternalGroupPositionData, InternalGroupPositionPriority, Interview, + InterviewLocation, + InterviewLocationAvailability, InterviewScheduleTemplate, + InterviewBooleanEvaluation, + InterviewAdditionalEvaluationStatement, ) -from admissions.filters import AdmissionFilter, ApplicantFilter +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): @@ -31,18 +63,142 @@ 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 interfaces = (Node,) 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() # We assume that we have constraint that only allows interviewer from one internal group + + 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 + ).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] + + 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) +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 +215,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,22 +223,129 @@ 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 AdditionalEvaluationAnswerEnum(graphene.Enum): + VERY_LITTLE = "VERY" + LITTLE = "LITTLE" + MEDIUM = "MEDIUM" + SOMEWHAT = "SOMEWHAT" + VERY = "VERY" + + +class BooleanEvaluationAnswer(graphene.ObjectType): + statement = graphene.String() + answer = graphene.Boolean() + + +class AdditionalEvaluationAnswer(graphene.ObjectType): + statement = graphene.String() + answer = AdditionalEvaluationAnswerEnum() + + +class InterviewNode(DjangoObjectType): + class Meta: + model = Interview + interfaces = (Node,) + + 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 = [] + 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_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() + + @classmethod + 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. + """ + + 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) + available_picks = graphene.List(InternalGroupPositionPriorityNode) + processed_applicants = graphene.List(InternalGroupPositionPriorityNode) + + 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()) + 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): + 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() @@ -91,6 +354,111 @@ 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 + + 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 + ): + """ + 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. + + 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() + + 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( + ~Q(internal_group_priority=InternalGroupStatus.WANT), + ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), + applicant_priority=Priority.FIRST, + ) + + second_picks = all_internal_group_priorities.filter( + applicant_priority=Priority.SECOND, + ) + + # 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 + ), + ~Q(internal_group_priority=InternalGroupStatus.WANT), + ~Q(internal_group_priority=InternalGroupStatus.DO_NOT_WANT), + applicant__priorities__applicant_priority=Priority.FIRST, + ) + + 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 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 + ), + ~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 + ), + ~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( + internal_group_priority__in=[ + InternalGroupStatus.WANT, + InternalGroupStatus.DO_NOT_WANT, + ] + ) + + # Merge together all applicants into a single list + available_picks = first_picks | available_second_picks | available_third_picks + + return InternalGroupDiscussionData( + internal_group=internal_group, + available_picks=available_picks.distinct(), + processed_applicants=processed_applicants, + ) + class ResendApplicantTokenMutation(graphene.Mutation): class Arguments: @@ -106,10 +474,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) @@ -120,12 +484,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), @@ -139,6 +506,30 @@ 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 + ) + 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_current_admission_internal_group_position_data( + self, info, *args, **kwargs + ): + 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() + + 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,7 +544,204 @@ 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 + ): + return InternalGroupPosition.objects.filter(available_externally=True).order_by( + "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() + interviews = graphene.List(InterviewNode) + + +class InterviewDay(graphene.ObjectType): + date = graphene.Date() + locations = graphene.List(InterviewLocationDateGrouping) + + +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() + + +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 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") + ) + 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, + ) + + 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) + 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") + + +# === Applicant === class CreateApplicantMutation(DjangoCreateMutation): class Meta: model = Applicant @@ -169,6 +757,88 @@ 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 PatchInternalGroupPositionPriority(DjangoPatchMutation): + class Meta: + model = InternalGroupPositionPriority + + +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: model = Admission @@ -184,37 +854,305 @@ class Meta: model = Admission -class GenerateInterviewScheduleMutation(graphene.Mutation): - class Arguments: - pass +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 + ).count() + if unevaluated_applicants > 0: + 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) + + 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) + + +# === Interview === +class GenerateInterviewsMutation(graphene.Mutation): ok = graphene.Boolean() interviews_generated = graphene.Int() 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) + + +class SetSelfAsInterviewerMutation(graphene.Mutation): + class Arguments: + interview_id = graphene.ID(required=True) - return GenerateInterviewScheduleMutation(ok=True, interviews_generated=num) + success = graphene.Boolean() + def mutate(self, info, interview_id, *args, **kwargs): + interview_django_id = disambiguate_id(interview_id) + interview = Interview.objects.filter(pk=interview_django_id).first() + if not interview: + return SetSelfAsInterviewerMutation(success=False) -class ObfuscateAdmissionMutation(graphene.Mutation): + # 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: - pass + interview_id = graphene.ID(required=True) - ok = graphene.Boolean() + 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(id=user.id)) + interview.save() + return RemoveSelfAsInterviewerMutation(success=True) + + +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) + 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) - obfuscate_admission(admission) - return ObfuscateAdmissionMutation(ok=True) + +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: + model = InterviewLocation + + +class DeleteInterviewLocationMutation(DjangoDeleteMutation): + class Meta: + model = InterviewLocation + 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 + exclude_fields = ("order",) + + @classmethod + def before_mutate(cls, root, info, input): + count = InterviewBooleanEvaluation.objects.all().count() + increment = count + 1 + input["order"] = increment + return input + + +class PatchInterviewBooleanEvaluationMutation(DjangoPatchMutation): + class Meta: + model = InterviewBooleanEvaluation + + +class DeleteInterviewBooleanEvaluationMutation(DjangoDeleteMutation): + class Meta: + model = InterviewBooleanEvaluation + + +# === InterviewAdditionalEvaluationStatement === +class CreateInterviewAdditionalEvaluationStatementMutation(DjangoCreateMutation): + 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: + model = InterviewAdditionalEvaluationStatement + + +class DeleteInterviewAdditionalEvaluationStatementMutation(DjangoDeleteMutation): + class Meta: + model = InterviewAdditionalEvaluationStatement + + +# === 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 PatchAdmissionAvailableInternalGroupPositionData(DjangoPatchMutation): + class Meta: + model = AdmissionAvailableInternalGroupPositionData + + +class DeleteAdmissionAvailableInternalGroupPositionData(DjangoDeleteMutation): + class Meta: + model = AdmissionAvailableInternalGroupPositionData class AdmissionsMutations(graphene.ObjectType): @@ -228,6 +1166,58 @@ 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() + ) + + patch_interview = PatchInterviewMutation.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() - obfuscate_admission = ObfuscateAdmissionMutation.Field() + generate_interviews = GenerateInterviewsMutation.Field() + book_interview = BookInterviewMutation.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() + lock_admission = LockAdmissionMutation.Field() + close_admission = CloseAdmissionMutation.Field() + + 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/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/tests/test_utils.py b/admissions/tests/test_utils.py index d6c6696e..a6f1df62 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() @@ -165,3 +179,50 @@ 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) + 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 9428904c..b3fc7e4b 100644 --- a/admissions/utils.py +++ b/admissions/utils.py @@ -5,6 +5,13 @@ from django.conf import settings from django.apps import apps import csv +from common.util import ( + date_time_combiner, + get_date_from_datetime, + parse_datetime_to_midnight, + validate_qs, +) +from admissions.consts import InternalGroupStatus, Priority def get_available_interview_locations(datetime_from=None, datetime_to=None): @@ -35,12 +42,38 @@ 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 - # Lazy load model due to circular import errors + datetime_cursor = date_time_combiner( + interview_period_start_date, default_interview_day_start + ) + datetime_interview_period_end = date_time_combiner( + schedule.interview_period_end_date, default_interview_day_end + ) + + # 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" + ) - while datetime_cursor < schedule.interview_period_end: + # 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): available_locations = get_available_interview_locations( @@ -48,13 +81,19 @@ def generate_interviews_from_schedule(schedule): datetime_to=datetime_cursor + interview_duration, ) for location in available_locations: - with transaction.atomic(): - Interview.objects.create( - location=location, - interview_start=datetime_cursor, - interview_end=datetime_cursor + interview_duration, + 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 @@ -78,29 +117,36 @@ 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=schedule.interview_period_start.hour, - minute=schedule.interview_period_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, + ) ) -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 - """ + 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 = ( @@ -113,7 +159,45 @@ def send_welcome_to_interview_email(email: str, auth_token: str):
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}"} ) @@ -130,12 +214,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}"} ) @@ -143,13 +227,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}"} ) @@ -197,3 +281,133 @@ 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 + + +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/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/_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/management/commands/generate_testdata.py b/common/management/commands/generate_testdata.py new file mode 100644 index 00000000..c527ee9d --- /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/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/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() 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) diff --git a/common/util.py b/common/util.py index 95c86499..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=[], @@ -230,3 +233,48 @@ 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.make_aware( + timezone.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=time.hour, + minute=time.minute, + 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] 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/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/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/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/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/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 4a6a12e4..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 @@ -104,6 +112,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, @@ -119,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) @@ -128,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/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() 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/schema.py b/users/schema.py index 5854d933..6b161ef3 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 resolve_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) 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 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 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):