From fcc650bf8f6b643438b1c75c5055571d3db38283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?snorre=20s=C3=A6ther?= Date: Mon, 22 Jul 2024 12:06:51 +0200 Subject: [PATCH 01/44] adds occupied time slot seed script --- .../commands/seed_scripts/__init__.py | 2 + .../seed_scripts/recruitment_occupied_time.py | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 backend/root/management/commands/seed_scripts/recruitment_occupied_time.py diff --git a/backend/root/management/commands/seed_scripts/__init__.py b/backend/root/management/commands/seed_scripts/__init__.py index c44b09f5d..1f84eb769 100755 --- a/backend/root/management/commands/seed_scripts/__init__.py +++ b/backend/root/management/commands/seed_scripts/__init__.py @@ -20,6 +20,7 @@ information_pages, recruitment_position, recruitment_applications, + recruitment_occupied_time, recruitment_seperate_position, recruitment_interviewavailability, ) @@ -51,6 +52,7 @@ ('recruitment_interviewavailability', recruitment_interviewavailability.seed), ('recruitment_seperate_position', recruitment_seperate_position.seed), ('recruitment_applications', recruitment_applications.seed), + ('recruitment_occupied_time', recruitment_occupied_time.seed), # Example seed (not run unless targeted specifically) ('example', example.seed), ] diff --git a/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py b/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py new file mode 100644 index 000000000..61a51c9c6 --- /dev/null +++ b/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import random +import datetime + +from django.utils import timezone + +from samfundet.models.general import User +from samfundet.models.recruitment import Recruitment, OccupiedTimeslot + + +def random_datetime_within_hours(start, end): + """Generate a random datetime with start time between 12:00 and 18:00 within the given period.""" + start_date = start.date() + end_date = end.date() + + # Choose a random day within the start and end dates + random_day = start_date + datetime.timedelta(days=random.randint(0, (end_date - start_date).days)) + + # Choose a random hour between 12:00 and 18:00 for start of time slot, this way the timeslot cant go later than 23:00 + start_time = datetime.time(random.randint(12, 18), 0) + + # Combine the random day and random time and make it timezone-aware + random_datetime = datetime.datetime.combine(random_day, start_time) + aware_random_datetime = timezone.make_aware(random_datetime, timezone.get_current_timezone()) + + return aware_random_datetime + + +def is_overlapping(user, start_dt, end_dt): + """Check if the new timeslot overlaps with existing timeslots for the user.""" + return OccupiedTimeslot.objects.filter(user=user, start_dt__lt=end_dt, end_dt__gt=start_dt).exists() + + +def seed(): + yield 0, 'occupied_timeslot' + OccupiedTimeslot.objects.all().delete() + yield 0, 'Deleted old occupied timeslots' + + recruitments = Recruitment.objects.all() + users = User.objects.all() + created_count = 0 + + for i, recruitment in enumerate(recruitments): + for j, user in enumerate(users): + # Create multiple occupied timeslots for each user and recruitment + for _ in range(3): + while True: + start_dt = random_datetime_within_hours(recruitment.visible_from, recruitment.shown_application_deadline) + end_dt = start_dt + datetime.timedelta(hours=random.randint(1, 5)) + + if not is_overlapping(user, start_dt, end_dt): + break + + data = { + 'user': user, + 'recruitment': recruitment, + 'start_dt': start_dt, + 'end_dt': end_dt, + } + _, created = OccupiedTimeslot.objects.get_or_create(**data) + + if created: + created_count += 1 + yield (i + 1) / len(recruitments), 'occupied_timeslot' + + yield 100, f'Created {created_count} occupied timeslots' From 3bbaee1d210ea1d5af6b7478fc2b31b9984c5259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?snorre=20s=C3=A6ther?= Date: Mon, 22 Jul 2024 12:16:19 +0200 Subject: [PATCH 02/44] =?UTF-8?q?ruff=20=F0=9F=90=95=E2=80=8D=F0=9F=A6=BA?= =?UTF-8?q?=F0=9F=90=95=E2=80=8D=F0=9F=A6=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seed_scripts/recruitment_occupied_time.py | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py b/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py index 61a51c9c6..156dac545 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py +++ b/backend/root/management/commands/seed_scripts/recruitment_occupied_time.py @@ -17,7 +17,7 @@ def random_datetime_within_hours(start, end): # Choose a random day within the start and end dates random_day = start_date + datetime.timedelta(days=random.randint(0, (end_date - start_date).days)) - # Choose a random hour between 12:00 and 18:00 for start of time slot, this way the timeslot cant go later than 23:00 + # Choose a random hour between 12:00 and 18:00 for start of time slot, this way the timeslot can't go later than 23:00 start_time = datetime.time(random.randint(12, 18), 0) # Combine the random day and random time and make it timezone-aware @@ -32,6 +32,25 @@ def is_overlapping(user, start_dt, end_dt): return OccupiedTimeslot.objects.filter(user=user, start_dt__lt=end_dt, end_dt__gt=start_dt).exists() +def create_occupied_timeslot(user, recruitment): + """Create a non-overlapping occupied timeslot for the given user and recruitment.""" + while True: + start_dt = random_datetime_within_hours(recruitment.visible_from, recruitment.shown_application_deadline) + end_dt = start_dt + datetime.timedelta(hours=random.randint(1, 5)) + + if not is_overlapping(user, start_dt, end_dt): + break + + data = { + 'user': user, + 'recruitment': recruitment, + 'start_dt': start_dt, + 'end_dt': end_dt, + } + _, created = OccupiedTimeslot.objects.get_or_create(**data) + return created + + def seed(): yield 0, 'occupied_timeslot' OccupiedTimeslot.objects.all().delete() @@ -41,27 +60,13 @@ def seed(): users = User.objects.all() created_count = 0 + total_recruitments = recruitments.count() for i, recruitment in enumerate(recruitments): - for j, user in enumerate(users): + for user in users: # Create multiple occupied timeslots for each user and recruitment for _ in range(3): - while True: - start_dt = random_datetime_within_hours(recruitment.visible_from, recruitment.shown_application_deadline) - end_dt = start_dt + datetime.timedelta(hours=random.randint(1, 5)) - - if not is_overlapping(user, start_dt, end_dt): - break - - data = { - 'user': user, - 'recruitment': recruitment, - 'start_dt': start_dt, - 'end_dt': end_dt, - } - _, created = OccupiedTimeslot.objects.get_or_create(**data) - - if created: + if create_occupied_timeslot(user, recruitment): created_count += 1 - yield (i + 1) / len(recruitments), 'occupied_timeslot' + yield (i + 1) / total_recruitments * 100, 'occupied_timeslot' yield 100, f'Created {created_count} occupied timeslots' From 47bcf4d11943ab5305a29eca7cd3254f07782098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?snorre=20s=C3=A6ther?= Date: Mon, 22 Jul 2024 13:08:34 +0200 Subject: [PATCH 03/44] adds seed script for interviewers. Seeds at least three interviewers at each position. Interviewer cannot be applicant on the same position. --- .../commands/seed_scripts/__init__.py | 2 + .../recruitment_position_interviewers.py | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 backend/root/management/commands/seed_scripts/recruitment_position_interviewers.py diff --git a/backend/root/management/commands/seed_scripts/__init__.py b/backend/root/management/commands/seed_scripts/__init__.py index 1f84eb769..9b3d7e130 100755 --- a/backend/root/management/commands/seed_scripts/__init__.py +++ b/backend/root/management/commands/seed_scripts/__init__.py @@ -23,6 +23,7 @@ recruitment_occupied_time, recruitment_seperate_position, recruitment_interviewavailability, + recruitment_position_interviewers, ) # Insert seed scripts here (in order of priority) @@ -53,6 +54,7 @@ ('recruitment_seperate_position', recruitment_seperate_position.seed), ('recruitment_applications', recruitment_applications.seed), ('recruitment_occupied_time', recruitment_occupied_time.seed), + ('recruitment_position_interviewers', recruitment_position_interviewers.seed), # Example seed (not run unless targeted specifically) ('example', example.seed), ] diff --git a/backend/root/management/commands/seed_scripts/recruitment_position_interviewers.py b/backend/root/management/commands/seed_scripts/recruitment_position_interviewers.py new file mode 100644 index 000000000..f9c90d1b5 --- /dev/null +++ b/backend/root/management/commands/seed_scripts/recruitment_position_interviewers.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from random import sample, randint + +from samfundet.models.general import User +from samfundet.models.recruitment import RecruitmentPosition, RecruitmentApplication + + +def seed(): + yield 0, 'recruitment_interviewers' + # Clear any existing interviewers + for position in RecruitmentPosition.objects.all(): + position.interviewers.clear() + yield 0, 'Cleared old interviewers' + + positions = RecruitmentPosition.objects.all() + applicants_by_position = RecruitmentApplication.objects.values_list('recruitment_position_id', 'user_id') + + created_count = 0 + + for position_index, position in enumerate(positions): + # Get the users who have applied for this position + applicants_for_position = set(user_id for pos_id, user_id in applicants_by_position if pos_id == position.id) + # Get the list of interviewers excluding those who applied for this position + interviewers = User.objects.exclude(id__in=applicants_for_position) + + available_interviewers = list(interviewers) + if len(available_interviewers) < 3: + print(f'Skipping position {position.id} due to insufficient interviewers') + continue # Skip this position if there are not enough interviewers + + # Select 3-6 random interviewers for each position + num_interviewers = randint(3, min(6, len(available_interviewers))) + selected_interviewers = sample(available_interviewers, num_interviewers) + + # Assign interviewers to the position + position.interviewers.set(selected_interviewers) + created_count += num_interviewers + + yield (position_index + 1) / len(positions), 'recruitment_interviewers' + + yield 100, f'Assigned {created_count} interviewers to recruitment positions' From 4a6973d8fb2e6cf02b85def530ed6b3ac52c7e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?snorre=20s=C3=A6ther?= Date: Mon, 22 Jul 2024 14:53:47 +0200 Subject: [PATCH 04/44] adds InterviewTimeblock model --- backend/samfundet/models/recruitment.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 0172af548..36c453f6c 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -372,6 +372,17 @@ class Meta: constraints = [models.UniqueConstraint(fields=['user', 'recruitment', 'start_dt', 'end_dt'], name='occupied_UNIQ')] +class InterviewTimeblock(CustomBaseModel): + recruitment_position = models.ForeignKey( + RecruitmentPosition, on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='applications' + ) + date = models.DateField(help_text='Block date', null=False, blank=False) + start_dt = models.DateTimeField(help_text='Block start time', null=False, blank=False) + end_dt = models.DateTimeField(help_text='Block end time', null=False, blank=False) + rating = models.FloatField(help_text='Rating used for optimizing interview time') + available_interviewers = models.ManyToManyField(to='samfundet.User', help_text='Interviewers in this time block', blank=True, related_name='interviews') + + class RecruitmentStatistics(FullCleanSaveMixin): recruitment = models.OneToOneField(Recruitment, on_delete=models.CASCADE, blank=True, null=True, related_name='statistics') From b1cdae13cabed0f470defa5fce3ab7fbbd189fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?snorre=20s=C3=A6ther?= Date: Mon, 22 Jul 2024 16:13:16 +0200 Subject: [PATCH 05/44] working BAD solution. Generate blocks using API call --- .../migrations/0002_interviewtimeblock.py | 27 +++++++ backend/samfundet/models/recruitment.py | 6 +- backend/samfundet/serializers.py | 7 ++ backend/samfundet/urls.py | 2 + .../process_unavailability.py | 17 +++++ backend/samfundet/views.py | 76 +++++++++++++++++++ 6 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 backend/samfundet/migrations/0002_interviewtimeblock.py create mode 100644 backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py diff --git a/backend/samfundet/migrations/0002_interviewtimeblock.py b/backend/samfundet/migrations/0002_interviewtimeblock.py new file mode 100644 index 000000000..e85d2ebc3 --- /dev/null +++ b/backend/samfundet/migrations/0002_interviewtimeblock.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.7 on 2024-07-22 13:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='InterviewTimeblock', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(help_text='Block date')), + ('start_dt', models.DateTimeField(help_text='Block start time')), + ('end_dt', models.DateTimeField(help_text='Block end time')), + ('rating', models.FloatField(help_text='Rating used for optimizing interview time')), + ('available_interviewers', models.ManyToManyField(blank=True, help_text='Interviewers in this time block', related_name='interview_timeblocks', to=settings.AUTH_USER_MODEL)), + ('recruitment_position', models.ForeignKey(help_text='The position which is recruiting', on_delete=django.db.models.deletion.CASCADE, related_name='interview_timeblocks', to='samfundet.recruitmentposition')), + ], + ), + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 36c453f6c..d31bd65a5 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -372,15 +372,15 @@ class Meta: constraints = [models.UniqueConstraint(fields=['user', 'recruitment', 'start_dt', 'end_dt'], name='occupied_UNIQ')] -class InterviewTimeblock(CustomBaseModel): +class InterviewTimeblock(models.Model): recruitment_position = models.ForeignKey( - RecruitmentPosition, on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='applications' + 'RecruitmentPosition', on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='interview_timeblocks' ) date = models.DateField(help_text='Block date', null=False, blank=False) start_dt = models.DateTimeField(help_text='Block start time', null=False, blank=False) end_dt = models.DateTimeField(help_text='Block end time', null=False, blank=False) rating = models.FloatField(help_text='Rating used for optimizing interview time') - available_interviewers = models.ManyToManyField(to='samfundet.User', help_text='Interviewers in this time block', blank=True, related_name='interviews') + available_interviewers = models.ManyToManyField('User', help_text='Interviewers in this time block', blank=True, related_name='interview_timeblocks') class RecruitmentStatistics(FullCleanSaveMixin): diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index afd08bb19..a4b817aac 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -54,6 +54,7 @@ Recruitment, InterviewRoom, OccupiedTimeslot, + InterviewTimeblock, RecruitmentDateStat, RecruitmentPosition, RecruitmentTimeStat, @@ -833,6 +834,12 @@ class Meta: fields = '__all__' +class GenerateInterviewBlocksSerializer(serializers.ModelSerializer): + class Meta: + model = InterviewTimeblock + fields = '__all__' + + class ApplicantInfoSerializer(CustomBaseSerializer): occupied_timeslots = OccupiedTimeslotSerializer(many=True) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 9df37de97..36047a3ff 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -6,6 +6,7 @@ from django.urls import path, include from . import views +from .views import GenerateInterviewBlocksView # End: imports ----------------------------------------------------------------- @@ -109,4 +110,5 @@ path('recruitment-interview-availability/', views.RecruitmentInterviewAvailabilityView.as_view(), name='recruitment_interview_availability'), path('recruitment//availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'), path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), + path('generate-interview-blocks/', GenerateInterviewBlocksView.as_view(), name='generate_interview_blocks'), ] diff --git a/backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py b/backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py new file mode 100644 index 000000000..ffeccc444 --- /dev/null +++ b/backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +""" +occupied_timeslot: +{ + "id": 1, + "user": 123, + "recruitment": 456, + "start_dt": "2024-07-22T09:00:00Z", + "end_dt": "2024-07-22T11:00:00Z" +} + +""" + + +def process_unavailability(interviewer_unavailability): + unavailability = {} diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index b26e22693..4b006bbb3 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -5,6 +5,7 @@ import hmac import hashlib from typing import Any +from datetime import datetime, timedelta from guardian.shortcuts import get_objects_for_user @@ -117,6 +118,7 @@ Recruitment, InterviewRoom, OccupiedTimeslot, + InterviewTimeblock, RecruitmentPosition, RecruitmentStatistics, RecruitmentApplication, @@ -1122,6 +1124,80 @@ def create(self, request: Request) -> Response: return Response({'message': 'Successfully updated occupied timeslots'}) +""" + + +asdasd adl akløda køadl ksøløaldk + +""" + + +class GenerateInterviewBlocksView(APIView): + """Generate interview time blocks based on availability.""" + + permission_classes = [AllowAny] + + def post(self, request): + recruitment_id = request.data.get('recruitment_id') + position_id = request.data.get('position_id') + + if not recruitment_id or not position_id: + return Response({'error': 'recruitment_id and position_id fields are required.'}, status=status.HTTP_400_BAD_REQUEST) + + try: + availability = RecruitmentInterviewAvailability.objects.get(recruitment_id=recruitment_id, position_id=position_id) + except RecruitmentInterviewAvailability.DoesNotExist: + return Response({'error': 'Availability not found.'}, status=status.HTTP_404_NOT_FOUND) + + self.generate_time_blocks(availability) + + return Response({'status': 'success'}, status=status.HTTP_200_OK) + + def generate_time_blocks(self, availability): + start_date = availability.start_date + end_date = availability.end_date + start_time = availability.start_time + end_time = availability.end_time + interval = availability.timeslot_interval + + date_range = self.generate_date_range(start_date, end_date) + + # Generate time blocks for each date + for date in date_range: + print('doing something') + time_blocks = self.generate_time_intervals(date, start_time, end_time, interval) + for start_dt, end_dt in time_blocks: + rating = self.calculate_rating(availability, start_dt, end_dt) + InterviewTimeblock.objects.create(recruitment_position=availability.position, date=date, start_dt=start_dt, end_dt=end_dt, rating=rating) + + def generate_date_range(self, start_date, end_date): + current_date = start_date + date_range = [] + while current_date <= end_date: + date_range.append(current_date) + current_date += timedelta(days=1) + return date_range + + def generate_time_intervals(self, date, start_time, end_time, interval): + start_dt = datetime.combine(date, start_time) + end_dt = datetime.combine(date, end_time) + current_dt = start_dt + time_blocks = [] + while current_dt < end_dt: + next_dt = current_dt + timedelta(minutes=interval) + if next_dt <= end_dt: + time_blocks.append((current_dt, next_dt)) + current_dt = next_dt + return time_blocks + + def calculate_rating(self, availability, start_dt, end_dt): + occupied_slots = OccupiedTimeslot.objects.filter(recruitment=availability.recruitment, start_dt__lt=end_dt, end_dt__gt=start_dt) + unavailable_count = occupied_slots.count() + block_length = (end_dt - start_dt).total_seconds() / 3600 # Convert to hours + rating = max(0, 1 - unavailable_count) + block_length * 0.25 + return rating + + class UserFeedbackView(CreateAPIView): permission_classes = [AllowAny] model = UserFeedbackModel From 61caacb2469f3c6fa94f372f347c982c35a15782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?snorre=20s=C3=A6ther?= Date: Tue, 23 Jul 2024 11:11:52 +0200 Subject: [PATCH 06/44] adds "generate and rate blocks" script --- .../generate_optimal_interview_timeblocks.py | 180 ++++++++++++++++++ .../process_unavailability.py | 17 -- backend/samfundet/views.py | 10 +- 3 files changed, 181 insertions(+), 26 deletions(-) create mode 100644 backend/samfundet/utils/generate_optimal_interview_timeblocks.py delete mode 100644 backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py diff --git a/backend/samfundet/utils/generate_optimal_interview_timeblocks.py b/backend/samfundet/utils/generate_optimal_interview_timeblocks.py new file mode 100644 index 000000000..12619d6cf --- /dev/null +++ b/backend/samfundet/utils/generate_optimal_interview_timeblocks.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from datetime import datetime + +# TODO: REMOVE BEFORE MERGE : +earliest_time = '13:00' +latest_time = '23:30' +date_format = '%d.%m' +time_format = '%H:%M' +date_range = ['15.08', '16.08', '17.08', '18.08', '19.08'] + +meeting_unavailability = { + 'captain_dan': [ + {'date': '15.08', 'start': '12:00', 'end': '16:00'}, + {'date': '16.08', 'start': '09:00', 'end': '11:00'}, + ], + 'forrest': [ + {'date': '15.08', 'start': '14:00', 'end': '15:00'}, + {'date': '17.08', 'start': '14:00', 'end': '22:00'}, + ], + 'jenny': [ + {'date': '15.08', 'start': '13:00', 'end': '14:00'}, + {'date': '15.08', 'start': '16:00', 'end': '18:00'}, + {'date': '16.08', 'start': '14:00', 'end': '16:00'}, + {'date': '17.08', 'start': '14:00', 'end': '22:00'}, + ], +} + +# TODO, before merge: look at solution for creating/updating blocks as unavailability is registered +# this would spread out this resource intensive process over time + +""" +occupied timeslot +occupied_timeslot: +{ + "id": 1, + "user": 123, + "recruitment": 456, + "start_dt": "2024-07-22T09:00:00Z", + "end_dt": "2024-07-22T11:00:00Z" +} + + + +""" + + +def to_datetime(date_str, time_str): + return datetime.strptime(f'{date_str} {time_str}', '%d.%m %H:%M') + + +def process_unavailability(interviewers_unavailability): + unavailability = {} + for interviewer, times in interviewers_unavailability.items(): + for period in times: + date = period['date'] + start = to_datetime(date, period['start']) + end = to_datetime(date, period['end']) + unavailability.setdefault(interviewer, {}).setdefault(date, []).append((start, end)) + return unavailability + + +""" +unifies timepoints to create unique time blocks +""" + + +def create_unique_blocks(unavailability, date_range, earliest_time, latest_time): + merged_intervals = {} + for date in date_range: + time_points = set() + time_points.add(to_datetime(date, earliest_time)) + time_points.add(to_datetime(date, latest_time)) + for interviewer, dates in unavailability.items(): + if date in dates: + for start, end in dates[date]: + time_points.add(start) + time_points.add(end) + time_points = sorted(time_points) # Sort after all points have been added + merged_intervals[date] = time_points + return merged_intervals + + +def availability_count(unavailability, merged_intervals): + available_persons_count = {} + for date, time_points in merged_intervals.items(): + count_blocks = [] + for i in range(len(time_points) - 1): + start = time_points[i] + end = time_points[i + 1] + count = 0 + for interviewer, dates in unavailability.items(): + if date in dates: + available = True + for unav_start, unav_end in dates[date]: + if unav_start < end and unav_end > start: + available = False + break + if available: + count += 1 + else: + count += 1 # Available all day if no unavailability for that day + count_blocks.append((start, end, count)) + available_persons_count[date] = count_blocks + return available_persons_count + + +def group_block_by_interviewer_count(available_persons_count): + grouped_blocks = {} + for date, blocks in available_persons_count.items(): + current_count = blocks[0][2] + current_start = blocks[0][0] + grouped_blocks[date] = [] + for i in range(1, len(blocks)): + start, end, count = blocks[i] + if count != current_count: + grouped_blocks[date].append((current_start, blocks[-1][1], current_count)) + current_start = start + current_count = count + grouped_blocks[date].append((current_start, blocks[-1][1], current_count)) + return grouped_blocks + + +def calculate_block_rating(grouped_blocks): + block_ratings = {} + for date, blocks in grouped_blocks.items(): + this_date_ratings = [] + for i in range(len(blocks)): + start, end, count = blocks[i] + preceding_block_count = blocks[i - 1][2] if i > 0 else 0 + succeeding_block_count = blocks[i + 1][2] if i < len(blocks) - 1 else 0 + this_block_length = (end - start).total_seconds() / 3600 # Convert to hours + rating = count + (preceding_block_count - succeeding_block_count) * 0.1 + this_block_length * 0.25 + this_date_ratings.append((start, end, count, rating)) + block_ratings[date] = this_date_ratings + return block_ratings + + +# TODO, remove before merge: +interviewers_unavailability = process_unavailability(meeting_unavailability) +blocks = create_unique_blocks(interviewers_unavailability, date_range, earliest_time, latest_time) +interviewer_in_block_count = availability_count(interviewers_unavailability, blocks) +grouped_blocks = group_block_by_interviewer_count(interviewer_in_block_count) +block_ratings = calculate_block_rating(grouped_blocks) + +""" +for date, blocks in block_ratings.items(): + print(f'\nDate: {date}') + for start, end, count, rating in blocks: + print(f"From {start.strftime('%H:%M')} to {end.strftime('%H:%M')}: {count} employees available, rating: {rating:.2f}") +""" + + +""" +console output after printing: + +Date: 15.08 +From 12:00 to 23:30: 2 employees available, rating: 4.78 +From 13:00 to 23:30: 1 employees available, rating: 3.62 +From 15:00 to 23:30: 2 employees available, rating: 3.92 +From 18:00 to 23:30: 3 employees available, rating: 4.58 + +Date: 16.08 +From 09:00 to 23:30: 2 employees available, rating: 5.33 +From 11:00 to 23:30: 3 employees available, rating: 6.12 BUG! Should not create blocks outside of the +From 14:00 to 23:30: 2 employees available, rating: 4.38 +From 16:00 to 23:30: 3 employees available, rating: 5.08 + +Date: 17.08 +From 13:00 to 23:30: 3 employees available, rating: 5.53 +From 14:00 to 23:30: 1 employees available, rating: 3.38 +From 22:00 to 23:30: 3 employees available, rating: 3.48 + +Date: 18.08 +From 13:00 to 23:30: 3 employees available, rating: 5.62 + +Date: 19.08 +From 13:00 to 23:30: 3 employees available, rating: 5.62 + +""" diff --git a/backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py b/backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py deleted file mode 100644 index ffeccc444..000000000 --- a/backend/samfundet/utils/optimize_interview_schedule/process_unavailability.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -""" -occupied_timeslot: -{ - "id": 1, - "user": 123, - "recruitment": 456, - "start_dt": "2024-07-22T09:00:00Z", - "end_dt": "2024-07-22T11:00:00Z" -} - -""" - - -def process_unavailability(interviewer_unavailability): - unavailability = {} diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 4b006bbb3..e4f8c5e7c 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1124,14 +1124,6 @@ def create(self, request: Request) -> Response: return Response({'message': 'Successfully updated occupied timeslots'}) -""" - - -asdasd adl akløda køadl ksøløaldk - -""" - - class GenerateInterviewBlocksView(APIView): """Generate interview time blocks based on availability.""" @@ -1193,7 +1185,7 @@ def generate_time_intervals(self, date, start_time, end_time, interval): def calculate_rating(self, availability, start_dt, end_dt): occupied_slots = OccupiedTimeslot.objects.filter(recruitment=availability.recruitment, start_dt__lt=end_dt, end_dt__gt=start_dt) unavailable_count = occupied_slots.count() - block_length = (end_dt - start_dt).total_seconds() / 3600 # Convert to hours + block_length = (end_dt - start_dt).total_seconds() / 3600 rating = max(0, 1 - unavailable_count) + block_length * 0.25 return rating From a7b43985edef78645cea31263cea29ab1788d40e Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 24 Sep 2024 01:43:40 +0200 Subject: [PATCH 07/44] something generated --- .../migrations/0004_merge_20240924_0058.py | 14 ++ backend/samfundet/serializers.py | 2 +- backend/samfundet/urls.py | 4 +- backend/samfundet/utils.py | 56 +++++- .../generate_optimal_interview_timeblocks.py | 180 ------------------ backend/samfundet/views.py | 79 ++------ 6 files changed, 89 insertions(+), 246 deletions(-) create mode 100644 backend/samfundet/migrations/0004_merge_20240924_0058.py delete mode 100644 backend/samfundet/utils/generate_optimal_interview_timeblocks.py diff --git a/backend/samfundet/migrations/0004_merge_20240924_0058.py b/backend/samfundet/migrations/0004_merge_20240924_0058.py new file mode 100644 index 000000000..c2f93f9e8 --- /dev/null +++ b/backend/samfundet/migrations/0004_merge_20240924_0058.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.1 on 2024-09-23 22:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0002_interviewtimeblock'), + ('samfundet', '0003_remove_gang_event_admin_group_and_more'), + ] + + operations = [ + ] diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 67f9bb5d5..e1f9d3b12 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -889,7 +889,7 @@ class Meta: fields = '__all__' -class GenerateInterviewBlocksSerializer(serializers.ModelSerializer): +class InterviewTimeblockSerializer(serializers.ModelSerializer): class Meta: model = InterviewTimeblock fields = '__all__' diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 79df27c61..bfc983d95 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -8,7 +8,6 @@ from django.urls import path, include from . import views -from .views import GenerateInterviewBlocksView # End: imports ----------------------------------------------------------------- router = routers.DefaultRouter() @@ -49,6 +48,7 @@ router.register('recruitment-applications-for-gang', views.RecruitmentApplicationForGangView, 'recruitment_applications_for_gang') router.register('recruitment-applications-for-position', views.RecruitmentApplicationForRecruitmentPositionView, 'recruitment_applications_for_position') router.register('interview', views.InterviewView, 'interview') +router.register('interview-timeblocks', views.InterviewTimeblockView, 'interview_timeblock') app_name = 'samfundet' @@ -136,6 +136,6 @@ path('recruitment-interview-availability/', views.RecruitmentInterviewAvailabilityView.as_view(), name='recruitment_interview_availability'), path('recruitment//availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'), path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), - path('generate-interview-blocks/', GenerateInterviewBlocksView.as_view(), name='generate_interview_blocks'), + # path('generate-interview-blocks/', views.GenerateInterviewTimeblocksView.as_view(), name='generate_interview_blocks'), path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'), ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 6755ad78d..9de31b66b 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -1,8 +1,9 @@ from __future__ import annotations -import datetime +from datetime import datetime, time, timedelta from django.http import QueryDict +from django.utils import timezone from django.db.models import Q, Model from django.utils.timezone import make_aware from django.core.exceptions import ValidationError @@ -12,7 +13,7 @@ from .models import User from .models.event import Event -from .models.recruitment import Recruitment, OccupiedTimeslot, RecruitmentInterviewAvailability +from .models.recruitment import Recruitment, OccupiedTimeslot, InterviewTimeblock, RecruitmentPosition, RecruitmentInterviewAvailability ### @@ -99,3 +100,54 @@ def get_perm(*, perm: str, model: type[Model]) -> Permission: content_type = ContentType.objects.get_for_model(model=model) permission = Permission.objects.get(codename=codename, content_type=content_type) return permission + + +def generate_interview_timeblocks(recruitment_id): + recruitment = Recruitment.objects.get(id=recruitment_id) + + # Delete existing time blocks for this recruitment + InterviewTimeblock.objects.filter(recruitment_position__recruitment=recruitment).delete() + + positions = RecruitmentPosition.objects.filter(recruitment=recruitment) + block_count = 0 + + for position in positions: + start_date = recruitment.visible_from.date() + end_date = recruitment.actual_application_deadline.date() + start_time = time(8, 0) # 8:00 AM + end_time = time(20, 0) # 8:00 PM + interval_minutes = 30 + + current_date = start_date + while current_date <= end_date: + current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) + end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) + + while current_datetime < end_datetime: + next_datetime = current_datetime + timedelta(minutes=interval_minutes) + + available_interviewers = position.interviewers.exclude( + occupied_timeslots__recruitment=recruitment, occupied_timeslots__start_dt__lt=next_datetime, occupied_timeslots__end_dt__gt=current_datetime + ) + + rating = calculate_rating(recruitment, position, current_datetime, next_datetime, available_interviewers.count()) + + InterviewTimeblock.objects.create( + recruitment_position=position, date=current_date, start_dt=current_datetime, end_dt=next_datetime, rating=rating + ) + block_count += 1 + + current_datetime = next_datetime + + current_date += timedelta(days=1) + + return block_count + + +def calculate_rating(recruitment, position, start_dt, end_dt, available_interviewers_count): + block_length = (end_dt - start_dt).total_seconds() / 3600 # in hours + occupied_slots = OccupiedTimeslot.objects.filter(recruitment=recruitment, start_dt__lt=end_dt, end_dt__gt=start_dt).count() + + # You can adjust these weights based on your preferences + rating = (available_interviewers_count * 2) + (block_length * 0.5) - (occupied_slots * 1) + return max(0, rating) # Ensure the rating is not negative diff --git a/backend/samfundet/utils/generate_optimal_interview_timeblocks.py b/backend/samfundet/utils/generate_optimal_interview_timeblocks.py deleted file mode 100644 index 12619d6cf..000000000 --- a/backend/samfundet/utils/generate_optimal_interview_timeblocks.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -# TODO: REMOVE BEFORE MERGE : -earliest_time = '13:00' -latest_time = '23:30' -date_format = '%d.%m' -time_format = '%H:%M' -date_range = ['15.08', '16.08', '17.08', '18.08', '19.08'] - -meeting_unavailability = { - 'captain_dan': [ - {'date': '15.08', 'start': '12:00', 'end': '16:00'}, - {'date': '16.08', 'start': '09:00', 'end': '11:00'}, - ], - 'forrest': [ - {'date': '15.08', 'start': '14:00', 'end': '15:00'}, - {'date': '17.08', 'start': '14:00', 'end': '22:00'}, - ], - 'jenny': [ - {'date': '15.08', 'start': '13:00', 'end': '14:00'}, - {'date': '15.08', 'start': '16:00', 'end': '18:00'}, - {'date': '16.08', 'start': '14:00', 'end': '16:00'}, - {'date': '17.08', 'start': '14:00', 'end': '22:00'}, - ], -} - -# TODO, before merge: look at solution for creating/updating blocks as unavailability is registered -# this would spread out this resource intensive process over time - -""" -occupied timeslot -occupied_timeslot: -{ - "id": 1, - "user": 123, - "recruitment": 456, - "start_dt": "2024-07-22T09:00:00Z", - "end_dt": "2024-07-22T11:00:00Z" -} - - - -""" - - -def to_datetime(date_str, time_str): - return datetime.strptime(f'{date_str} {time_str}', '%d.%m %H:%M') - - -def process_unavailability(interviewers_unavailability): - unavailability = {} - for interviewer, times in interviewers_unavailability.items(): - for period in times: - date = period['date'] - start = to_datetime(date, period['start']) - end = to_datetime(date, period['end']) - unavailability.setdefault(interviewer, {}).setdefault(date, []).append((start, end)) - return unavailability - - -""" -unifies timepoints to create unique time blocks -""" - - -def create_unique_blocks(unavailability, date_range, earliest_time, latest_time): - merged_intervals = {} - for date in date_range: - time_points = set() - time_points.add(to_datetime(date, earliest_time)) - time_points.add(to_datetime(date, latest_time)) - for interviewer, dates in unavailability.items(): - if date in dates: - for start, end in dates[date]: - time_points.add(start) - time_points.add(end) - time_points = sorted(time_points) # Sort after all points have been added - merged_intervals[date] = time_points - return merged_intervals - - -def availability_count(unavailability, merged_intervals): - available_persons_count = {} - for date, time_points in merged_intervals.items(): - count_blocks = [] - for i in range(len(time_points) - 1): - start = time_points[i] - end = time_points[i + 1] - count = 0 - for interviewer, dates in unavailability.items(): - if date in dates: - available = True - for unav_start, unav_end in dates[date]: - if unav_start < end and unav_end > start: - available = False - break - if available: - count += 1 - else: - count += 1 # Available all day if no unavailability for that day - count_blocks.append((start, end, count)) - available_persons_count[date] = count_blocks - return available_persons_count - - -def group_block_by_interviewer_count(available_persons_count): - grouped_blocks = {} - for date, blocks in available_persons_count.items(): - current_count = blocks[0][2] - current_start = blocks[0][0] - grouped_blocks[date] = [] - for i in range(1, len(blocks)): - start, end, count = blocks[i] - if count != current_count: - grouped_blocks[date].append((current_start, blocks[-1][1], current_count)) - current_start = start - current_count = count - grouped_blocks[date].append((current_start, blocks[-1][1], current_count)) - return grouped_blocks - - -def calculate_block_rating(grouped_blocks): - block_ratings = {} - for date, blocks in grouped_blocks.items(): - this_date_ratings = [] - for i in range(len(blocks)): - start, end, count = blocks[i] - preceding_block_count = blocks[i - 1][2] if i > 0 else 0 - succeeding_block_count = blocks[i + 1][2] if i < len(blocks) - 1 else 0 - this_block_length = (end - start).total_seconds() / 3600 # Convert to hours - rating = count + (preceding_block_count - succeeding_block_count) * 0.1 + this_block_length * 0.25 - this_date_ratings.append((start, end, count, rating)) - block_ratings[date] = this_date_ratings - return block_ratings - - -# TODO, remove before merge: -interviewers_unavailability = process_unavailability(meeting_unavailability) -blocks = create_unique_blocks(interviewers_unavailability, date_range, earliest_time, latest_time) -interviewer_in_block_count = availability_count(interviewers_unavailability, blocks) -grouped_blocks = group_block_by_interviewer_count(interviewer_in_block_count) -block_ratings = calculate_block_rating(grouped_blocks) - -""" -for date, blocks in block_ratings.items(): - print(f'\nDate: {date}') - for start, end, count, rating in blocks: - print(f"From {start.strftime('%H:%M')} to {end.strftime('%H:%M')}: {count} employees available, rating: {rating:.2f}") -""" - - -""" -console output after printing: - -Date: 15.08 -From 12:00 to 23:30: 2 employees available, rating: 4.78 -From 13:00 to 23:30: 1 employees available, rating: 3.62 -From 15:00 to 23:30: 2 employees available, rating: 3.92 -From 18:00 to 23:30: 3 employees available, rating: 4.58 - -Date: 16.08 -From 09:00 to 23:30: 2 employees available, rating: 5.33 -From 11:00 to 23:30: 3 employees available, rating: 6.12 BUG! Should not create blocks outside of the -From 14:00 to 23:30: 2 employees available, rating: 4.38 -From 16:00 to 23:30: 3 employees available, rating: 5.08 - -Date: 17.08 -From 13:00 to 23:30: 3 employees available, rating: 5.53 -From 14:00 to 23:30: 1 employees available, rating: 3.38 -From 22:00 to 23:30: 3 employees available, rating: 3.48 - -Date: 18.08 -From 13:00 to 23:30: 3 employees available, rating: 5.62 - -Date: 19.08 -From 13:00 to 23:30: 3 employees available, rating: 5.62 - -""" diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 0541e1cd7..f074d3f69 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -37,9 +37,10 @@ REQUESTED_IMPERSONATE_USER, ) -from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request +from .utils import event_query, generate_timeslots, generate_interview_timeblocks, get_occupied_timeslots_from_request from .homepage import homepage from .serializers import ( + InterviewTimeblockSerializer, TagSerializer, GangSerializer, MenuSerializer, @@ -1209,70 +1210,26 @@ def create(self, request: Request) -> Response: return Response({'message': 'Successfully updated occupied timeslots'}) -class GenerateInterviewBlocksView(APIView): - """Generate interview time blocks based on availability.""" - - permission_classes = [AllowAny] - - def post(self, request): - recruitment_id = request.data.get('recruitment_id') - position_id = request.data.get('position_id') - - if not recruitment_id or not position_id: - return Response({'error': 'recruitment_id and position_id fields are required.'}, status=status.HTTP_400_BAD_REQUEST) +class GenerateInterviewTimeblocksView(APIView): + permission_classes = [IsAuthenticated] + def get(self, request, pk): try: - availability = RecruitmentInterviewAvailability.objects.get(recruitment_id=recruitment_id, position_id=position_id) - except RecruitmentInterviewAvailability.DoesNotExist: - return Response({'error': 'Availability not found.'}, status=status.HTTP_404_NOT_FOUND) - - self.generate_time_blocks(availability) - - return Response({'status': 'success'}, status=status.HTTP_200_OK) + recruitment = get_object_or_404(Recruitment, id=pk) + block_count = generate_interview_timeblocks(recruitment.id) + return Response( + {'message': f'Interview blocks generated successfully for recruitment {pk}.', 'blocks_created': block_count}, status=status.HTTP_200_OK + ) + except Recruitment.DoesNotExist: + return Response({'error': f'Recruitment with id {pk} does not exist.'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response({'error': f'Failed to generate interview blocks: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - def generate_time_blocks(self, availability): - start_date = availability.start_date - end_date = availability.end_date - start_time = availability.start_time - end_time = availability.end_time - interval = availability.timeslot_interval - date_range = self.generate_date_range(start_date, end_date) - - # Generate time blocks for each date - for date in date_range: - print('doing something') - time_blocks = self.generate_time_intervals(date, start_time, end_time, interval) - for start_dt, end_dt in time_blocks: - rating = self.calculate_rating(availability, start_dt, end_dt) - InterviewTimeblock.objects.create(recruitment_position=availability.position, date=date, start_dt=start_dt, end_dt=end_dt, rating=rating) - - def generate_date_range(self, start_date, end_date): - current_date = start_date - date_range = [] - while current_date <= end_date: - date_range.append(current_date) - current_date += timedelta(days=1) - return date_range - - def generate_time_intervals(self, date, start_time, end_time, interval): - start_dt = datetime.combine(date, start_time) - end_dt = datetime.combine(date, end_time) - current_dt = start_dt - time_blocks = [] - while current_dt < end_dt: - next_dt = current_dt + timedelta(minutes=interval) - if next_dt <= end_dt: - time_blocks.append((current_dt, next_dt)) - current_dt = next_dt - return time_blocks - - def calculate_rating(self, availability, start_dt, end_dt): - occupied_slots = OccupiedTimeslot.objects.filter(recruitment=availability.recruitment, start_dt__lt=end_dt, end_dt__gt=start_dt) - unavailable_count = occupied_slots.count() - block_length = (end_dt - start_dt).total_seconds() / 3600 - rating = max(0, 1 - unavailable_count) + block_length * 0.25 - return rating +class InterviewTimeblockView(ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = InterviewTimeblockSerializer + queryset = InterviewTimeblock.objects.all() class UserFeedbackView(CreateAPIView): From c0f0bb649ea1b6c11b7be72143ddbca30b51e182 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 24 Sep 2024 01:50:47 +0200 Subject: [PATCH 08/44] working, but without interviewers --- backend/samfundet/urls.py | 2 +- backend/samfundet/utils.py | 42 ++++++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index bfc983d95..5072013cd 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -136,6 +136,6 @@ path('recruitment-interview-availability/', views.RecruitmentInterviewAvailabilityView.as_view(), name='recruitment_interview_availability'), path('recruitment//availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'), path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), - # path('generate-interview-blocks/', views.GenerateInterviewTimeblocksView.as_view(), name='generate_interview_blocks'), + path('generate-interview-blocks/', views.GenerateInterviewTimeblocksView.as_view(), name='generate_interview_blocks'), path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'), ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 9de31b66b..e9035cc74 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -104,10 +104,7 @@ def get_perm(*, perm: str, model: type[Model]) -> Permission: def generate_interview_timeblocks(recruitment_id): recruitment = Recruitment.objects.get(id=recruitment_id) - - # Delete existing time blocks for this recruitment InterviewTimeblock.objects.filter(recruitment_position__recruitment=recruitment).delete() - positions = RecruitmentPosition.objects.filter(recruitment=recruitment) block_count = 0 @@ -116,7 +113,6 @@ def generate_interview_timeblocks(recruitment_id): end_date = recruitment.actual_application_deadline.date() start_time = time(8, 0) # 8:00 AM end_time = time(20, 0) # 8:00 PM - interval_minutes = 30 current_date = start_date while current_date <= end_date: @@ -124,19 +120,18 @@ def generate_interview_timeblocks(recruitment_id): end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) while current_datetime < end_datetime: - next_datetime = current_datetime + timedelta(minutes=interval_minutes) + next_datetime, available_interviewers = find_next_change(position, recruitment, current_datetime, end_datetime) - available_interviewers = position.interviewers.exclude( - occupied_timeslots__recruitment=recruitment, occupied_timeslots__start_dt__lt=next_datetime, occupied_timeslots__end_dt__gt=current_datetime - ) + if next_datetime == current_datetime: + # No change in availability, move to next 30-minute slot + next_datetime = current_datetime + timedelta(minutes=30) - rating = calculate_rating(recruitment, position, current_datetime, next_datetime, available_interviewers.count()) + rating = calculate_rating(recruitment, position, current_datetime, next_datetime, len(available_interviewers)) InterviewTimeblock.objects.create( recruitment_position=position, date=current_date, start_dt=current_datetime, end_dt=next_datetime, rating=rating ) block_count += 1 - current_datetime = next_datetime current_date += timedelta(days=1) @@ -144,10 +139,35 @@ def generate_interview_timeblocks(recruitment_id): return block_count +def find_next_change(position, recruitment, start_dt, end_dt): + current_dt = start_dt + step = timedelta(minutes=30) + current_interviewers = set( + position.interviewers.exclude( + occupied_timeslots__recruitment=recruitment, occupied_timeslots__start_dt__lte=current_dt, occupied_timeslots__end_dt__gt=current_dt + ) + ) + + while current_dt < end_dt: + next_dt = current_dt + step + next_interviewers = set( + position.interviewers.exclude( + occupied_timeslots__recruitment=recruitment, occupied_timeslots__start_dt__lt=next_dt, occupied_timeslots__end_dt__gt=current_dt + ) + ) + + if next_interviewers != current_interviewers: + return current_dt, current_interviewers + + current_dt = next_dt + current_interviewers = next_interviewers + + return end_dt, current_interviewers + + def calculate_rating(recruitment, position, start_dt, end_dt, available_interviewers_count): block_length = (end_dt - start_dt).total_seconds() / 3600 # in hours occupied_slots = OccupiedTimeslot.objects.filter(recruitment=recruitment, start_dt__lt=end_dt, end_dt__gt=start_dt).count() - # You can adjust these weights based on your preferences rating = (available_interviewers_count * 2) + (block_length * 0.5) - (occupied_slots * 1) return max(0, rating) # Ensure the rating is not negative From 80b3aebd8edb6a6a64f0c5e48c2c42e5d257fc67 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 24 Sep 2024 03:40:58 +0200 Subject: [PATCH 09/44] code creates correct blocks with rating --- backend/samfundet/utils.py | 93 ++++++++++++++++++++++++-------------- backend/samfundet/views.py | 4 +- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index e9035cc74..6253538aa 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -105,69 +105,92 @@ def get_perm(*, perm: str, model: type[Model]) -> Permission: def generate_interview_timeblocks(recruitment_id): recruitment = Recruitment.objects.get(id=recruitment_id) InterviewTimeblock.objects.filter(recruitment_position__recruitment=recruitment).delete() + positions = RecruitmentPosition.objects.filter(recruitment=recruitment) block_count = 0 for position in positions: + # Add this check here + # if not position.interviewers.exists(): + # print(f'No interviewers assigned to position {position.id}') + # continue + start_date = recruitment.visible_from.date() end_date = recruitment.actual_application_deadline.date() start_time = time(8, 0) # 8:00 AM end_time = time(20, 0) # 8:00 PM + interval = timedelta(minutes=30) # 30-minute intervals current_date = start_date while current_date <= end_date: current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) - while current_datetime < end_datetime: - next_datetime, available_interviewers = find_next_change(position, recruitment, current_datetime, end_datetime) - - if next_datetime == current_datetime: - # No change in availability, move to next 30-minute slot - next_datetime = current_datetime + timedelta(minutes=30) - - rating = calculate_rating(recruitment, position, current_datetime, next_datetime, len(available_interviewers)) - - InterviewTimeblock.objects.create( - recruitment_position=position, date=current_date, start_dt=current_datetime, end_dt=next_datetime, rating=rating + unavailability = get_unavailability( + recruitment + # , current_datetime, end_datetime, position + ) + blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) + + for block in blocks: + interview_block = InterviewTimeblock.objects.create( + recruitment_position=position, + date=current_date, + start_dt=block['start'], + end_dt=block['end'], + rating=calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), ) + interview_block.available_interviewers.set(block['available_interviewers']) block_count += 1 - current_datetime = next_datetime current_date += timedelta(days=1) return block_count -def find_next_change(position, recruitment, start_dt, end_dt): +def get_unavailability(recruitment): + # Fetch all OccupiedTimeslot objects for the given recruitment + occupied_timeslots = OccupiedTimeslot.objects.filter( + recruitment=recruitment + # , user__in=position.interviewers.all(), start_dt__lt=end_dt, end_dt__gt=start_dt + ).order_by('start_dt') + + # Loop through the queryset and print field values for each object + # for slot in occupied_timeslots: + # print(f'OccupiedTimeslot ID: {slot.id}, User: {slot.user}, Start: {slot.start_dt}, End: {slot.end_dt}, Recruitment: {slot.recruitment}') + + return occupied_timeslots + + +def generate_blocks(position, start_dt, end_dt, unavailability, interval): + all_interviewers = set(position.interviewers.all()) # All interviewers for this position + blocks = [] current_dt = start_dt - step = timedelta(minutes=30) - current_interviewers = set( - position.interviewers.exclude( - occupied_timeslots__recruitment=recruitment, occupied_timeslots__start_dt__lte=current_dt, occupied_timeslots__end_dt__gt=current_dt - ) - ) while current_dt < end_dt: - next_dt = current_dt + step - next_interviewers = set( - position.interviewers.exclude( - occupied_timeslots__recruitment=recruitment, occupied_timeslots__start_dt__lt=next_dt, occupied_timeslots__end_dt__gt=current_dt - ) - ) + block_end = min(current_dt + interval, end_dt) + available_interviewers = all_interviewers.copy() - if next_interviewers != current_interviewers: - return current_dt, current_interviewers + # Iterate through unavailability slots to check if any interviewers are unavailable + for slot in unavailability: + if slot.start_dt < block_end and slot.end_dt > current_dt: + # Remove the unavailable interviewer for this block + available_interviewers.discard(slot.user) - current_dt = next_dt - current_interviewers = next_interviewers + # Always create a new block if interviewers change + if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): + # Create a new block when interviewer availability changes + blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) + else: + # Extend the last block if interviewer availability hasn't changed + blocks[-1]['end'] = block_end - return end_dt, current_interviewers + current_dt = block_end + return blocks -def calculate_rating(recruitment, position, start_dt, end_dt, available_interviewers_count): - block_length = (end_dt - start_dt).total_seconds() / 3600 # in hours - occupied_slots = OccupiedTimeslot.objects.filter(recruitment=recruitment, start_dt__lt=end_dt, end_dt__gt=start_dt).count() - rating = (available_interviewers_count * 2) + (block_length * 0.5) - (occupied_slots * 1) - return max(0, rating) # Ensure the rating is not negative +def calculate_rating(start_dt, end_dt, available_interviewers_count): + block_length = (end_dt - start_dt).total_seconds() / 3600 + rating = (available_interviewers_count * 2) + (block_length * 0.5) + return max(0, rating) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index f074d3f69..7f7bf8f3f 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1213,7 +1213,7 @@ def create(self, request: Request) -> Response: class GenerateInterviewTimeblocksView(APIView): permission_classes = [IsAuthenticated] - def get(self, request, pk): + def get(self, request: Request, pk): try: recruitment = get_object_or_404(Recruitment, id=pk) block_count = generate_interview_timeblocks(recruitment.id) @@ -1227,7 +1227,7 @@ def get(self, request, pk): class InterviewTimeblockView(ModelViewSet): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] # Corrected spelling serializer_class = InterviewTimeblockSerializer queryset = InterviewTimeblock.objects.all() From e433ddb08b15034fd688cfec887ef58628a11ad7 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 24 Sep 2024 04:34:24 +0200 Subject: [PATCH 10/44] adds assign interview logic --- backend/samfundet/urls.py | 1 + backend/samfundet/utils.py | 122 +++++++++++++++++++++++++++++++++++-- backend/samfundet/views.py | 23 ++++++- 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 5072013cd..f05df692a 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -138,4 +138,5 @@ path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), path('generate-interview-blocks/', views.GenerateInterviewTimeblocksView.as_view(), name='generate_interview_blocks'), path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'), + path('allocate-interviews/', views.AllocateInterviewsForPositionView.as_view(), name='allocated_interviews'), ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 6253538aa..a287535c2 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import defaultdict from datetime import datetime, time, timedelta from django.http import QueryDict @@ -13,7 +14,15 @@ from .models import User from .models.event import Event -from .models.recruitment import Recruitment, OccupiedTimeslot, InterviewTimeblock, RecruitmentPosition, RecruitmentInterviewAvailability +from .models.recruitment import ( + Interview, + Recruitment, + OccupiedTimeslot, + InterviewTimeblock, + RecruitmentApplication, + RecruitmentPosition, + RecruitmentInterviewAvailability, +) ### @@ -190,7 +199,110 @@ def generate_blocks(position, start_dt, end_dt, unavailability, interval): return blocks -def calculate_rating(start_dt, end_dt, available_interviewers_count): - block_length = (end_dt - start_dt).total_seconds() / 3600 - rating = (available_interviewers_count * 2) + (block_length * 0.5) - return max(0, rating) +def allocate_interviews_for_position(position): + # Get the time blocks for the specific position, sorted by rating + timeblocks = InterviewTimeblock.objects.filter(recruitment_position=position).order_by('-rating') + + # Fetch all active applications for the position + applications = RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False) + + # Prepare unavailability data for applicants and interviewers + applicant_unavailability = defaultdict(list) + interviewer_unavailability = defaultdict(list) + + # Collect unavailability for each applicant + for slot in OccupiedTimeslot.objects.filter(recruitment=position.recruitment): + applicant_unavailability[slot.user.id].append((slot.start_dt, slot.end_dt)) + + # Collect unavailability for each interviewer + for interview in Interview.objects.filter(interviewers__in=position.interviewers.all()): + for interviewer in interview.interviewers.all(): + interviewer_unavailability[interviewer.id].append( + (interview.interview_time, interview.interview_time + timedelta(minutes=30)) + ) # Assuming 30-minute interviews + + assigned_interviews = set() # Track assigned interviews + + # Count interviews allocated + interview_count = 0 + + # Iterate over each time block + for block in timeblocks: + # Loop through the applicants for this position + for application in applications: + applicant = application.user + + # Skip applicants that already have an interview + if application.id in assigned_interviews: + continue + + # Check if the applicant is available for this block and interviewers are free + available_interviewers = get_available_interviewers(block.available_interviewers.all(), block.start_dt, block.end_dt, interviewer_unavailability) + + if available_interviewers and is_applicant_available(applicant, block.start_dt, block.end_dt, applicant_unavailability): + # Create and assign an interview + interview = Interview.objects.create( + interview_time=block.start_dt, + interview_location=f'Location for {position.name_en}', + room=None, # Set the room if required + ) + interview.interviewers.set(available_interviewers) # Assign only available interviewers + interview.save() + + # Link the interview to the application + application.interview = interview + application.save() + + # Mark this time as occupied for the applicant + assigned_interviews.add(application.id) + mark_applicant_unavailable(applicant, block.start_dt, block.end_dt) + + # Mark interviewers as occupied + mark_interviewers_unavailable(available_interviewers, block.start_dt, block.end_dt, interviewer_unavailability) + + interview_count += 1 + + return interview_count + + +def is_applicant_available(applicant, start_dt, end_dt, unavailability): + """Check if the applicant is available during the given time block.""" + for unavail_start, unavail_end in unavailability.get(applicant.id, []): + if unavail_start < end_dt and unavail_end > start_dt: + return False # Applicant is unavailable during this time + return True + + +def mark_applicant_unavailable(applicant, start_dt, end_dt): + """Mark the applicant's timeslot as occupied.""" + OccupiedTimeslot.objects.create( + user=applicant, + recruitment=applicant.applications.first().recruitment, # Assuming a valid recruitment exists + start_dt=start_dt, + end_dt=end_dt, + ) + + +def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unavailability): + """Return a list of available interviewers who are free during the time block.""" + available_interviewers = [] + + for interviewer in interviewers: + if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability): + available_interviewers.append(interviewer) + + return available_interviewers + + +def is_interviewer_available(interviewer, start_dt, end_dt, unavailability): + """Check if the interviewer is available during the given time block.""" + for unavail_start, unavail_end in unavailability.get(interviewer.id, []): + if unavail_start < end_dt and unavail_end > start_dt: + return False # Interviewer is unavailable during this time + return True + + +def mark_interviewers_unavailable(interviewers, start_dt, end_dt, unavailability): + """Mark the interviewers as unavailable for the given time block.""" + for interviewer in interviewers: + unavailability[interviewer.id].append((start_dt, end_dt)) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 7f7bf8f3f..c3e58ef11 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -37,7 +37,7 @@ REQUESTED_IMPERSONATE_USER, ) -from .utils import event_query, generate_timeslots, generate_interview_timeblocks, get_occupied_timeslots_from_request +from .utils import allocate_interviews_for_position, event_query, generate_timeslots, generate_interview_timeblocks, get_occupied_timeslots_from_request from .homepage import homepage from .serializers import ( InterviewTimeblockSerializer, @@ -1281,3 +1281,24 @@ def post(self, request: Request) -> Response: form=purchase_model, ) return Response(status=status.HTTP_201_CREATED, data={'message': 'Feedback submitted successfully!'}) + + +class AllocateInterviewsForPositionView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request: Request, pk): + try: + # Get the specific recruitment position + position = get_object_or_404(RecruitmentPosition, id=pk) + + # Allocate interviews for the position (method explained below) + interview_count = allocate_interviews_for_position(position) + + return Response( + {'message': f'Interviews allocated successfully for position {pk}.', 'interviews_allocated': interview_count}, + status=status.HTTP_200_OK, + ) + except RecruitmentPosition.DoesNotExist: + return Response({'error': f'Recruitment position with id {pk} does not exist.'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response({'error': f'Failed to allocate interviews: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) From 8c3062f908e2b49f386d1d432586cb2f60511a3f Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Wed, 25 Sep 2024 20:42:57 +0200 Subject: [PATCH 11/44] good working interview allocation for Samfundet --- backend/samfundet/exceptions.py | 37 +++++ .../migrations/0006_merge_20240925_1842.py | 14 ++ backend/samfundet/utils.py | 140 ++++++++++++------ .../RecruitmentPositionOverviewPage.tsx | 1 + 4 files changed, 145 insertions(+), 47 deletions(-) create mode 100644 backend/samfundet/exceptions.py create mode 100644 backend/samfundet/migrations/0006_merge_20240925_1842.py diff --git a/backend/samfundet/exceptions.py b/backend/samfundet/exceptions.py new file mode 100644 index 000000000..daedcb62e --- /dev/null +++ b/backend/samfundet/exceptions.py @@ -0,0 +1,37 @@ +from __future__ import annotations + + +class InterviewAllocationError(Exception): + """Base exception for interview allocation errors.""" + + pass + + +class NoTimeBlocksAvailableError(InterviewAllocationError): + """Raised when there are no available time blocks for interviews.""" + + pass + + +class NoApplicationsWithoutInterviewsError(InterviewAllocationError): + """Raised when there are no applications without interviews.""" + + pass + + +class NoAvailableInterviewersError(InterviewAllocationError): + """Raised when there are no available interviewers for a given time slot.""" + + pass + + +class AllApplicantsUnavailableError(InterviewAllocationError): + """Raised when all applicants are unavailable for the remaining time slots.""" + + pass + + +class InsufficientTimeBlocksError(InterviewAllocationError): + """Raised when there are not enough time blocks to accommodate all applications.""" + + pass diff --git a/backend/samfundet/migrations/0006_merge_20240925_1842.py b/backend/samfundet/migrations/0006_merge_20240925_1842.py new file mode 100644 index 000000000..5b127e527 --- /dev/null +++ b/backend/samfundet/migrations/0006_merge_20240925_1842.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.1 on 2024-09-25 16:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0004_merge_20240924_0058'), + ('samfundet', '0005_role_content_type_role_created_at_role_created_by_and_more'), + ] + + operations = [ + ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index a287535c2..93cc5c57e 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -1,7 +1,7 @@ from __future__ import annotations +from datetime import time, datetime, timedelta from collections import defaultdict -from datetime import datetime, time, timedelta from django.http import QueryDict from django.utils import timezone @@ -13,14 +13,15 @@ from django.contrib.contenttypes.models import ContentType from .models import User +from .exceptions import * from .models.event import Event from .models.recruitment import ( Interview, Recruitment, OccupiedTimeslot, InterviewTimeblock, - RecruitmentApplication, RecruitmentPosition, + RecruitmentApplication, RecruitmentInterviewAvailability, ) @@ -199,14 +200,21 @@ def generate_blocks(position, start_dt, end_dt, unavailability, interval): return blocks -def allocate_interviews_for_position(position): - # Get the time blocks for the specific position, sorted by rating - timeblocks = InterviewTimeblock.objects.filter(recruitment_position=position).order_by('-rating') +from datetime import timedelta + + +def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: + print(f'Starting allocation for position: {position.name_en}') + + # Get the time blocks for the specific position, sorted by rating (descending) and then start time (ascending) + timeblocks = sorted(InterviewTimeblock.objects.filter(recruitment_position=position), key=lambda block: (-block.rating, block.start_dt)) + print(f'Number of available time blocks: {len(timeblocks)}') - # Fetch all active applications for the position - applications = RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False) + # Fetch only applications without interviews + applications = list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) + print(f'Number of applications without interviews: {len(applications)}') - # Prepare unavailability data for applicants and interviewers + # Prepare unavailability data applicant_unavailability = defaultdict(list) interviewer_unavailability = defaultdict(list) @@ -214,58 +222,96 @@ def allocate_interviews_for_position(position): for slot in OccupiedTimeslot.objects.filter(recruitment=position.recruitment): applicant_unavailability[slot.user.id].append((slot.start_dt, slot.end_dt)) - # Collect unavailability for each interviewer - for interview in Interview.objects.filter(interviewers__in=position.interviewers.all()): + # Collect unavailability for each interviewer and existing interviews + existing_interviews = Interview.objects.filter(applications__recruitment_position=position) + for interview in existing_interviews: for interviewer in interview.interviewers.all(): - interviewer_unavailability[interviewer.id].append( - (interview.interview_time, interview.interview_time + timedelta(minutes=30)) - ) # Assuming 30-minute interviews + interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) + # Mark the interview time as unavailable for all interviewers + for interviewer in position.interviewers.all(): + interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) - assigned_interviews = set() # Track assigned interviews - - # Count interviews allocated interview_count = 0 + interview_duration = timedelta(minutes=30) + current_time = timezone.now() + timedelta(hours=24) # Ensure interviews are at least 24 hours in the future - # Iterate over each time block for block in timeblocks: - # Loop through the applicants for this position - for application in applications: - applicant = application.user + if block.end_dt <= current_time: + continue # Skip blocks that are in the past or within the next 24 hours + + print(f'Processing block: {block.start_dt} - {block.end_dt}, Rating: {block.rating}') + + block_start = max(block.start_dt, current_time) + current_time = block_start - # Skip applicants that already have an interview - if application.id in assigned_interviews: + while current_time + interview_duration <= block.end_dt and applications: + interview_end_time = current_time + interview_duration + + # Check if this time slot is already occupied by an existing interview + if any(interview.interview_time == current_time for interview in existing_interviews): + print(f' Slot {current_time} - {interview_end_time} already has an interview') + current_time += interview_duration continue - # Check if the applicant is available for this block and interviewers are free - available_interviewers = get_available_interviewers(block.available_interviewers.all(), block.start_dt, block.end_dt, interviewer_unavailability) + available_interviewers = get_available_interviewers( + block.available_interviewers.all(), current_time, interview_end_time, interviewer_unavailability + ) - if available_interviewers and is_applicant_available(applicant, block.start_dt, block.end_dt, applicant_unavailability): - # Create and assign an interview - interview = Interview.objects.create( - interview_time=block.start_dt, - interview_location=f'Location for {position.name_en}', - room=None, # Set the room if required - ) - interview.interviewers.set(available_interviewers) # Assign only available interviewers - interview.save() + if not available_interviewers: + print(f' No available interviewers for slot: {current_time} - {interview_end_time}') + current_time += interview_duration + continue + + for application in applications[:]: + applicant = application.user + print(f' Checking applicant: {applicant.username}') + + if is_applicant_available(applicant, current_time, interview_end_time, applicant_unavailability): + # Create and assign a new interview + interview = Interview.objects.create( + interview_time=current_time, + interview_location=f'Location for {position.name_en}', + room=None, # Set the room if required + ) + interview.interviewers.set(available_interviewers) + interview.save() + + # Link the interview to the application + application.interview = interview + application.save() - # Link the interview to the application - application.interview = interview - application.save() + # Mark this time as occupied for the applicant and interviewers + mark_applicant_unavailable(applicant, current_time, interview_end_time) + mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) - # Mark this time as occupied for the applicant - assigned_interviews.add(application.id) - mark_applicant_unavailable(applicant, block.start_dt, block.end_dt) + interview_count += 1 + applications.remove(application) + print(f' Allocated interview for {applicant.username} at {current_time}') - # Mark interviewers as occupied - mark_interviewers_unavailable(available_interviewers, block.start_dt, block.end_dt, interviewer_unavailability) + if limit_to_first_applicant: + return interview_count # Exit after allocating for the first applicant - interview_count += 1 + break + else: + print(f' Applicant {applicant.username} not available for this slot') + current_time += interview_duration + + if not applications: + print('All applications have been processed') + break + + print(f'Finished allocation. Total new interviews allocated: {interview_count}') return interview_count -def is_applicant_available(applicant, start_dt, end_dt, unavailability): +def calculate_rating(start_dt, end_dt, available_interviewers_count) -> int: + block_length = (end_dt - start_dt).total_seconds() / 3600 + rating = (available_interviewers_count * 2) + (block_length * 0.5) + return max(0, rating) + + +def is_applicant_available(applicant, start_dt, end_dt, unavailability) -> bool: """Check if the applicant is available during the given time block.""" for unavail_start, unavail_end in unavailability.get(applicant.id, []): if unavail_start < end_dt and unavail_end > start_dt: @@ -273,7 +319,7 @@ def is_applicant_available(applicant, start_dt, end_dt, unavailability): return True -def mark_applicant_unavailable(applicant, start_dt, end_dt): +def mark_applicant_unavailable(applicant, start_dt, end_dt) -> None: """Mark the applicant's timeslot as occupied.""" OccupiedTimeslot.objects.create( user=applicant, @@ -283,7 +329,7 @@ def mark_applicant_unavailable(applicant, start_dt, end_dt): ) -def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unavailability): +def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unavailability) -> None: """Return a list of available interviewers who are free during the time block.""" available_interviewers = [] @@ -294,7 +340,7 @@ def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unava return available_interviewers -def is_interviewer_available(interviewer, start_dt, end_dt, unavailability): +def is_interviewer_available(interviewer, start_dt, end_dt, unavailability) -> bool: """Check if the interviewer is available during the given time block.""" for unavail_start, unavail_end in unavailability.get(interviewer.id, []): if unavail_start < end_dt and unavail_end > start_dt: @@ -302,7 +348,7 @@ def is_interviewer_available(interviewer, start_dt, end_dt, unavailability): return True -def mark_interviewers_unavailable(interviewers, start_dt, end_dt, unavailability): +def mark_interviewers_unavailable(interviewers, start_dt, end_dt, unavailability) -> None: """Mark the interviewers as unavailable for the given time block.""" for interviewer in interviewers: unavailability[interviewer.id].append((start_dt, end_dt)) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index a1d57ad34..76a3380dd 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -35,6 +35,7 @@ export function RecruitmentPositionOverviewPage() { positionId && getRecruitmentApplicationsForGang(gangId, recruitmentId) .then((data) => { + console.log(data.data) setRecruitmentApplicants( data.data.filter( (recruitmentApplicant) => From 94eb43cc97bef9f7a7efe33707378b3c5704dd51 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Wed, 25 Sep 2024 21:02:19 +0200 Subject: [PATCH 12/44] adds ecceptions --- backend/samfundet/exceptions.py | 6 +++++ backend/samfundet/utils.py | 45 ++++++++++++++++++++++----------- backend/samfundet/views.py | 21 ++++++++++----- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/backend/samfundet/exceptions.py b/backend/samfundet/exceptions.py index daedcb62e..5c5b215bf 100644 --- a/backend/samfundet/exceptions.py +++ b/backend/samfundet/exceptions.py @@ -35,3 +35,9 @@ class InsufficientTimeBlocksError(InterviewAllocationError): """Raised when there are not enough time blocks to accommodate all applications.""" pass + + +class NoFutureTimeSlotsError(InterviewAllocationError): + """Raised when there are no time slots available at least 24 hours in the future.""" + + pass diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 93cc5c57e..81b981745 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -206,13 +206,16 @@ def generate_blocks(position, start_dt, end_dt, unavailability, interval): def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: print(f'Starting allocation for position: {position.name_en}') - # Get the time blocks for the specific position, sorted by rating (descending) and then start time (ascending) + # Define interview duration + interview_duration = timedelta(minutes=30) + timeblocks = sorted(InterviewTimeblock.objects.filter(recruitment_position=position), key=lambda block: (-block.rating, block.start_dt)) - print(f'Number of available time blocks: {len(timeblocks)}') + if not timeblocks: + raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') - # Fetch only applications without interviews applications = list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) - print(f'Number of applications without interviews: {len(applications)}') + if not applications: + raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') # Prepare unavailability data applicant_unavailability = defaultdict(list) @@ -226,21 +229,20 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - existing_interviews = Interview.objects.filter(applications__recruitment_position=position) for interview in existing_interviews: for interviewer in interview.interviewers.all(): - interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) + interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) # Mark the interview time as unavailable for all interviewers for interviewer in position.interviewers.all(): - interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) + interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) interview_count = 0 - interview_duration = timedelta(minutes=30) + all_applicants_unavailable = True current_time = timezone.now() + timedelta(hours=24) # Ensure interviews are at least 24 hours in the future - for block in timeblocks: - if block.end_dt <= current_time: - continue # Skip blocks that are in the past or within the next 24 hours - - print(f'Processing block: {block.start_dt} - {block.end_dt}, Rating: {block.rating}') + future_blocks = [block for block in timeblocks if block.end_dt > current_time] + if not future_blocks: + raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') + for block in future_blocks: block_start = max(block.start_dt, current_time) current_time = block_start @@ -267,6 +269,7 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - print(f' Checking applicant: {applicant.username}') if is_applicant_available(applicant, current_time, interview_end_time, applicant_unavailability): + all_applicants_unavailable = False # Create and assign a new interview interview = Interview.objects.create( interview_time=current_time, @@ -284,12 +287,15 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - mark_applicant_unavailable(applicant, current_time, interview_end_time) mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) + # Add the new interview to existing_interviews to prevent double-booking + existing_interviews = list(existing_interviews) + [interview] + interview_count += 1 applications.remove(application) print(f' Allocated interview for {applicant.username} at {current_time}') if limit_to_first_applicant: - return interview_count # Exit after allocating for the first applicant + return interview_count break else: @@ -298,10 +304,19 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - current_time += interview_duration if not applications: - print('All applications have been processed') break - print(f'Finished allocation. Total new interviews allocated: {interview_count}') + if interview_count == 0: + if all_applicants_unavailable: + raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') + else: + raise NoAvailableInterviewersError(f'No available interviewers for any time slot for position: {position.name_en}') + + if applications: + raise InsufficientTimeBlocksError( + f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.' + ) + return interview_count diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index b028af9f5..dbe644345 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -6,6 +6,7 @@ import hashlib from typing import Any from datetime import datetime, timedelta +from .exceptions import * from guardian.shortcuts import get_objects_for_user @@ -1303,19 +1304,27 @@ def post(self, request: Request) -> Response: class AllocateInterviewsForPositionView(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request, pk): + def get(self, request, pk): try: - # Get the specific recruitment position position = get_object_or_404(RecruitmentPosition, id=pk) - - # Allocate interviews for the position (method explained below) interview_count = allocate_interviews_for_position(position) - return Response( {'message': f'Interviews allocated successfully for position {pk}.', 'interviews_allocated': interview_count}, status=status.HTTP_200_OK, ) + except NoTimeBlocksAvailableError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except NoApplicationsWithoutInterviewsError as e: + return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND) + except NoAvailableInterviewersError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except AllApplicantsUnavailableError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except InsufficientTimeBlocksError as e: + return Response({'error': str(e), 'partial_allocation': True}, status=status.HTTP_206_PARTIAL_CONTENT) + except NoFutureTimeSlotsError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) except RecruitmentPosition.DoesNotExist: return Response({'error': f'Recruitment position with id {pk} does not exist.'}, status=status.HTTP_404_NOT_FOUND) except Exception as e: - return Response({'error': f'Failed to allocate interviews: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response({'error': f'An unexpected error occurred: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) From fe934ab73fee79c4c39606f17b29abccca85cdb2 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Wed, 25 Sep 2024 22:04:40 +0200 Subject: [PATCH 13/44] adds comments for pre-preview --- backend/samfundet/utils.py | 82 ++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 81b981745..c61010cb1 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -119,30 +119,26 @@ def generate_interview_timeblocks(recruitment_id): positions = RecruitmentPosition.objects.filter(recruitment=recruitment) block_count = 0 - for position in positions: - # Add this check here - # if not position.interviewers.exists(): - # print(f'No interviewers assigned to position {position.id}') - # continue + current_date = timezone.now().date() # Get the current date - start_date = recruitment.visible_from.date() + for position in positions: + start_date = max(recruitment.visible_from.date(), current_date) # Use the later of visible_from or current date end_date = recruitment.actual_application_deadline.date() - start_time = time(8, 0) # 8:00 AM - end_time = time(20, 0) # 8:00 PM - interval = timedelta(minutes=30) # 30-minute intervals + # create a timeframe for any day, for when interviews can be held. + start_time = time(14, 0) # TODO: decide if these should be modefialbe. Might want want to set this timeframe in some other way. + end_time = time(23, 0) # TODO: -- "" -- + interval = timedelta(minutes=30) # 30-minute intervals. TODO: decide if this is needed. current_date = start_date while current_date <= end_date: current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) - unavailability = get_unavailability( - recruitment - # , current_datetime, end_datetime, position - ) + unavailability = get_unavailability(recruitment) blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) for block in blocks: + # adds the blocks to the database. TODO: check if this can be done in a more efficient way, maybe bulk add? interview_block = InterviewTimeblock.objects.create( recruitment_position=position, date=current_date, @@ -160,20 +156,16 @@ def generate_interview_timeblocks(recruitment_id): def get_unavailability(recruitment): # Fetch all OccupiedTimeslot objects for the given recruitment - occupied_timeslots = OccupiedTimeslot.objects.filter( - recruitment=recruitment - # , user__in=position.interviewers.all(), start_dt__lt=end_dt, end_dt__gt=start_dt - ).order_by('start_dt') - - # Loop through the queryset and print field values for each object - # for slot in occupied_timeslots: - # print(f'OccupiedTimeslot ID: {slot.id}, User: {slot.user}, Start: {slot.start_dt}, End: {slot.end_dt}, Recruitment: {slot.recruitment}') + occupied_timeslots = OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') return occupied_timeslots +# this function generates timeblocks. If you want to understand interview time blocks read the documentation. # TODO: add documentation. def generate_blocks(position, start_dt, end_dt, unavailability, interval): - all_interviewers = set(position.interviewers.all()) # All interviewers for this position + all_interviewers = set( + position.interviewers.all() + ) # All interviewers for this position. TODO: decide if we want to have a predefined set of POSSIBLE interviewers for some position blocks = [] current_dt = start_dt @@ -187,7 +179,7 @@ def generate_blocks(position, start_dt, end_dt, unavailability, interval): # Remove the unavailable interviewer for this block available_interviewers.discard(slot.user) - # Always create a new block if interviewers change + # Always create a new block if available interviewers change. #TODO add print statements to check if this acctually works as expected. if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): # Create a new block when interviewer availability changes blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) @@ -200,15 +192,11 @@ def generate_blocks(position, start_dt, end_dt, unavailability, interval): return blocks -from datetime import timedelta - - def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: - print(f'Starting allocation for position: {position.name_en}') - # Define interview duration interview_duration = timedelta(minutes=30) + # want the higest rated earliest blocks first. If all blocks are equaly rated the earliest block will be first. timeblocks = sorted(InterviewTimeblock.objects.filter(recruitment_position=position), key=lambda block: (-block.rating, block.start_dt)) if not timeblocks: raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') @@ -225,7 +213,7 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - for slot in OccupiedTimeslot.objects.filter(recruitment=position.recruitment): applicant_unavailability[slot.user.id].append((slot.start_dt, slot.end_dt)) - # Collect unavailability for each interviewer and existing interviews + # Collect unavailability for each interviewer and existing interviews. #TODO check if this is needed, we allready have interview time blocks, which is an abstraction of interviewers availability. existing_interviews = Interview.objects.filter(applications__recruitment_position=position) for interview in existing_interviews: for interviewer in interview.interviewers.all(): @@ -234,16 +222,20 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - for interviewer in position.interviewers.all(): interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) - interview_count = 0 + interview_count = 0 # TODO remove, it is only used to check if something happens. Add better exceptions insted all_applicants_unavailable = True - current_time = timezone.now() + timedelta(hours=24) # Ensure interviews are at least 24 hours in the future + current_time = timezone.now() + timedelta( + hours=24 + ) # TODO: find out if 24h is needed. Ensure interviews are at least 24 hours in the future. Interviews should be planned well in advance. - future_blocks = [block for block in timeblocks if block.end_dt > current_time] + future_blocks = [block for block in timeblocks if block.end_dt > current_time] # for checking if there are any timeslots available. if not future_blocks: raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') for block in future_blocks: - block_start = max(block.start_dt, current_time) + block_start = max( + block.start_dt, current_time + ) # TODO: check if this is to specific. Do we care about seting interviews from "this monent" when interviews onlt can be allocated 24h in advance? current_time = block_start while current_time + interview_duration <= block.end_dt and applications: @@ -251,7 +243,7 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - # Check if this time slot is already occupied by an existing interview if any(interview.interview_time == current_time for interview in existing_interviews): - print(f' Slot {current_time} - {interview_end_time} already has an interview') + # TODO: replace print with a good exception. print(f' Slot {current_time} - {interview_end_time} already has an interview') current_time += interview_duration continue @@ -260,20 +252,19 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - ) if not available_interviewers: - print(f' No available interviewers for slot: {current_time} - {interview_end_time}') + # TODO: replace print with a good exception. print(f' No available interviewers for slot: {current_time} - {interview_end_time}') current_time += interview_duration continue for application in applications[:]: applicant = application.user - print(f' Checking applicant: {applicant.username}') if is_applicant_available(applicant, current_time, interview_end_time, applicant_unavailability): all_applicants_unavailable = False # Create and assign a new interview interview = Interview.objects.create( interview_time=current_time, - interview_location=f'Location for {position.name_en}', + interview_location=f'Location for {position.name_en}', # TODO: set inteview room dependent on what rooms are available in the DB at the time of the intrerview room=None, # Set the room if required ) interview.interviewers.set(available_interviewers) @@ -283,7 +274,8 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - application.interview = interview application.save() - # Mark this time as occupied for the applicant and interviewers + # Mark this time of interview as occupied for the applicant and interviewers. + # Applicants are given a 2 hour buffer after interviews to avoid time conflict with other positions. mark_applicant_unavailable(applicant, current_time, interview_end_time) mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) @@ -292,9 +284,10 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - interview_count += 1 applications.remove(application) - print(f' Allocated interview for {applicant.username} at {current_time}') + # TODO: Create a Exception for informing of what interviews was created in a HTTP response ??? print(f' Allocated interview for {applicant.username} at {current_time}') if limit_to_first_applicant: + # TODO remove this conditional with "limit to first applicant". It was used for testing. return interview_count break @@ -317,7 +310,7 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.' ) - return interview_count + return interview_count # TODO, decide if this is needed def calculate_rating(start_dt, end_dt, available_interviewers_count) -> int: @@ -334,13 +327,14 @@ def is_applicant_available(applicant, start_dt, end_dt, unavailability) -> bool: return True -def mark_applicant_unavailable(applicant, start_dt, end_dt) -> None: - """Mark the applicant's timeslot as occupied.""" +def mark_applicant_unavailable(applicant, start_dt, end_dt, buffer_hours=2): + """Mark the applicant's timeslot as occupied, including a buffer period after the interview.""" + buffer_end = end_dt + timedelta(hours=buffer_hours) OccupiedTimeslot.objects.create( user=applicant, - recruitment=applicant.applications.first().recruitment, # Assuming a valid recruitment exists + recruitment=applicant.applications.first().recruitment, start_dt=start_dt, - end_dt=end_dt, + end_dt=buffer_end, ) From c44ca6b3c51b00c5b067a920f2231f178113a5e2 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 02:50:46 +0200 Subject: [PATCH 14/44] starts removing InterviewTimeBlocks table --- .../automatic_interview_allocation.py | 204 ++++++++++++++ .../migrations/0008_merge_20241018_0235.py | 14 + backend/samfundet/models/recruitment.py | 18 +- backend/samfundet/serializers.py | 7 - backend/samfundet/urls.py | 4 +- backend/samfundet/utils.py | 259 +----------------- backend/samfundet/views.py | 53 ++-- 7 files changed, 251 insertions(+), 308 deletions(-) create mode 100644 backend/samfundet/automatic_interview_allocation.py create mode 100644 backend/samfundet/migrations/0008_merge_20241018_0235.py diff --git a/backend/samfundet/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation.py new file mode 100644 index 000000000..8a2342e27 --- /dev/null +++ b/backend/samfundet/automatic_interview_allocation.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from datetime import time, datetime, timedelta +from collections import defaultdict + +from django.utils import timezone + +from .exceptions import * +from .models.recruitment import ( + Interview, + Recruitment, + OccupiedTimeslot, + RecruitmentPosition, + RecruitmentApplication, +) + + +def generate_interview_timeblocks(recruitment_id) -> list[dict]: + recruitment = Recruitment.objects.get(id=recruitment_id) + positions = RecruitmentPosition.objects.filter(recruitment=recruitment) + all_blocks = [] + + current_date = timezone.now().date() + + for position in positions: + start_date = max(recruitment.visible_from.date(), current_date) + end_date = recruitment.actual_application_deadline.date() + start_time = time(8, 0) + end_time = time(23, 0) + interval = timedelta(minutes=30) + + current_date = start_date + while current_date <= end_date: + current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) + end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) + + unavailability = get_unavailability(recruitment) + blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) + + all_blocks.extend( + [ + { + 'recruitment_position': position, + 'date': current_date, + 'start_dt': block['start'], + 'end_dt': block['end'], + 'rating': calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), + 'available_interviewers': list(block['available_interviewers']), + } + for block in blocks + ] + ) + + current_date += timedelta(days=1) + + return all_blocks + + +def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: + interview_duration = timedelta(minutes=30) + + timeblocks = generate_interview_timeblocks(position.recruitment.id) + timeblocks = [block for block in timeblocks if block['recruitment_position'] == position] + timeblocks.sort(key=lambda block: (-block['rating'], block['start_dt'])) + + if not timeblocks: + raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') + + applications = list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) + if not applications: + raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') + + interviewer_unavailability = defaultdict(list) + + existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) + for interview in existing_interviews: + for interviewer in interview.interviewers.all(): + interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) + + interview_count = 0 + all_applicants_unavailable = True + current_time = timezone.now() + timedelta(hours=24) + + future_blocks = [block for block in timeblocks if block['end_dt'] > current_time] + if not future_blocks: + raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') + + for block in future_blocks: + block_start = max(block['start_dt'], current_time) + current_time = block_start + + while current_time + interview_duration <= block['end_dt'] and applications: + interview_end_time = current_time + interview_duration + + if any(interview.interview_time == current_time for interview in existing_interviews): + current_time += interview_duration + continue + + available_interviewers = get_available_interviewers(block['available_interviewers'], current_time, interview_end_time, interviewer_unavailability) + + if not available_interviewers: + current_time += interview_duration + continue + + for application in applications[:]: + applicant = application.user + + if is_applicant_available(applicant, current_time, interview_end_time, position.recruitment): + all_applicants_unavailable = False + interview = Interview.objects.create( + interview_time=current_time, + interview_location=f'Location for {position.name_en}', + room=None, + ) + interview.interviewers.set(available_interviewers) + interview.save() + + application.interview = interview + application.save() + + mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) + + existing_interviews = list(existing_interviews) + [interview] + + interview_count += 1 + applications.remove(application) + + if limit_to_first_applicant: + return interview_count + + break + + current_time += interview_duration + + if not applications: + break + + if interview_count == 0: + if all_applicants_unavailable: + raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') + raise NoAvailableInterviewersError(f'No available interviewers for any time slot for position: {position.name_en}') + + if applications: + raise InsufficientTimeBlocksError( + f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.' + ) + + return interview_count + + +def get_unavailability(recruitment): + return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') + + +def generate_blocks(position, start_dt, end_dt, unavailability, interval): + all_interviewers = set(position.interviewers.all()) + blocks = [] + current_dt = start_dt + + while current_dt < end_dt: + block_end = min(current_dt + interval, end_dt) + available_interviewers = all_interviewers.copy() + + for slot in unavailability: + if slot.start_dt < block_end and slot.end_dt > current_dt: + available_interviewers.discard(slot.user) + + if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): + blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) + else: + blocks[-1]['end'] = block_end + + current_dt = block_end + + return blocks + + +def calculate_rating(start_dt, end_dt, available_interviewers_count) -> int: + block_length = (end_dt - start_dt).total_seconds() / 3600 + rating = (available_interviewers_count * 2) + (block_length * 0.5) + return max(0, int(rating)) + + +def is_applicant_available(applicant, start_dt, end_dt, recruitment) -> bool: + existing_interviews = Interview.objects.filter( + applications__user=applicant, applications__recruitment=recruitment, interview_time__lt=end_dt, interview_time__gte=start_dt + ) + return not existing_interviews.exists() + + +def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unavailability): + return [interviewer for interviewer in interviewers if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability)] + + +def is_interviewer_available(interviewer, start_dt, end_dt, unavailability) -> bool: + for unavail_start, unavail_end in unavailability.get(interviewer.id, []): + if unavail_start < end_dt and unavail_end > start_dt: + return False + return True + + +def mark_interviewers_unavailable(interviewers, start_dt, end_dt, unavailability) -> None: + for interviewer in interviewers: + unavailability[interviewer.id].append((start_dt, end_dt)) diff --git a/backend/samfundet/migrations/0008_merge_20241018_0235.py b/backend/samfundet/migrations/0008_merge_20241018_0235.py new file mode 100644 index 000000000..117bbe038 --- /dev/null +++ b/backend/samfundet/migrations/0008_merge_20241018_0235.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.1 on 2024-10-18 00:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0006_merge_20240925_1842'), + ('samfundet', '0007_alter_infobox_color_alter_infobox_image_and_more'), + ] + + operations = [ + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 257c308d2..8a229b85e 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -465,15 +465,15 @@ def resolve_org(self, *, return_id: bool = False) -> Organization | int: return self.recruitment.resolve_org(return_id=return_id) -class InterviewTimeblock(models.Model): - recruitment_position = models.ForeignKey( - 'RecruitmentPosition', on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='interview_timeblocks' - ) - date = models.DateField(help_text='Block date', null=False, blank=False) - start_dt = models.DateTimeField(help_text='Block start time', null=False, blank=False) - end_dt = models.DateTimeField(help_text='Block end time', null=False, blank=False) - rating = models.FloatField(help_text='Rating used for optimizing interview time') - available_interviewers = models.ManyToManyField('User', help_text='Interviewers in this time block', blank=True, related_name='interview_timeblocks') +# class InterviewTimeblock(models.Model): +# recruitment_position = models.ForeignKey( +# 'RecruitmentPosition', on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='interview_timeblocks' +# ) +# date = models.DateField(help_text='Block date', null=False, blank=False) +# start_dt = models.DateTimeField(help_text='Block start time', null=False, blank=False) +# end_dt = models.DateTimeField(help_text='Block end time', null=False, blank=False) +# rating = models.FloatField(help_text='Rating used for optimizing interview time') +# available_interviewers = models.ManyToManyField('User', help_text='Interviewers in this time block', blank=True, related_name='interview_timeblocks') class RecruitmentStatistics(FullCleanSaveMixin): diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 3da885e56..0c3502112 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -55,7 +55,6 @@ Recruitment, InterviewRoom, OccupiedTimeslot, - InterviewTimeblock, RecruitmentDateStat, RecruitmentGangStat, RecruitmentPosition, @@ -908,12 +907,6 @@ class Meta: fields = '__all__' -class InterviewTimeblockSerializer(serializers.ModelSerializer): - class Meta: - model = InterviewTimeblock - fields = '__all__' - - class ApplicantInfoSerializer(CustomBaseSerializer): occupied_timeslots = OccupiedTimeslotSerializer(many=True) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index bf67d3dff..6c7170d52 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -51,7 +51,6 @@ router.register('recruitment-applications-for-gang', views.RecruitmentApplicationForGangView, 'recruitment_applications_for_gang') router.register('recruitment-applications-for-position', views.RecruitmentApplicationForRecruitmentPositionView, 'recruitment_applications_for_position') router.register('interview', views.InterviewView, 'interview') -router.register('interview-timeblocks', views.InterviewTimeblockView, 'interview_timeblock') app_name = 'samfundet' @@ -140,7 +139,6 @@ path('recruitment-interview-availability/', views.RecruitmentInterviewAvailabilityView.as_view(), name='recruitment_interview_availability'), path('recruitment//availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'), path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), - path('generate-interview-blocks/', views.GenerateInterviewTimeblocksView.as_view(), name='generate_interview_blocks'), path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'), - path('allocate-interviews/', views.AllocateInterviewsForPositionView.as_view(), name='allocated_interviews'), + path('allocate-interviews//', views.AutomaticInterviewAllocationView.as_view(), name='allocate_interviews'), ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index c61010cb1..3ec73ba55 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -1,10 +1,8 @@ from __future__ import annotations -from datetime import time, datetime, timedelta -from collections import defaultdict +from datetime import datetime from django.http import QueryDict -from django.utils import timezone from django.db.models import Q, Model from django.utils.timezone import make_aware from django.core.exceptions import ValidationError @@ -16,12 +14,8 @@ from .exceptions import * from .models.event import Event from .models.recruitment import ( - Interview, Recruitment, OccupiedTimeslot, - InterviewTimeblock, - RecruitmentPosition, - RecruitmentApplication, RecruitmentInterviewAvailability, ) @@ -110,254 +104,3 @@ def get_perm(*, perm: str, model: type[Model]) -> Permission: content_type = ContentType.objects.get_for_model(model=model) permission = Permission.objects.get(codename=codename, content_type=content_type) return permission - - -def generate_interview_timeblocks(recruitment_id): - recruitment = Recruitment.objects.get(id=recruitment_id) - InterviewTimeblock.objects.filter(recruitment_position__recruitment=recruitment).delete() - - positions = RecruitmentPosition.objects.filter(recruitment=recruitment) - block_count = 0 - - current_date = timezone.now().date() # Get the current date - - for position in positions: - start_date = max(recruitment.visible_from.date(), current_date) # Use the later of visible_from or current date - end_date = recruitment.actual_application_deadline.date() - # create a timeframe for any day, for when interviews can be held. - start_time = time(14, 0) # TODO: decide if these should be modefialbe. Might want want to set this timeframe in some other way. - end_time = time(23, 0) # TODO: -- "" -- - interval = timedelta(minutes=30) # 30-minute intervals. TODO: decide if this is needed. - - current_date = start_date - while current_date <= end_date: - current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) - end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) - - unavailability = get_unavailability(recruitment) - blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) - - for block in blocks: - # adds the blocks to the database. TODO: check if this can be done in a more efficient way, maybe bulk add? - interview_block = InterviewTimeblock.objects.create( - recruitment_position=position, - date=current_date, - start_dt=block['start'], - end_dt=block['end'], - rating=calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), - ) - interview_block.available_interviewers.set(block['available_interviewers']) - block_count += 1 - - current_date += timedelta(days=1) - - return block_count - - -def get_unavailability(recruitment): - # Fetch all OccupiedTimeslot objects for the given recruitment - occupied_timeslots = OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') - - return occupied_timeslots - - -# this function generates timeblocks. If you want to understand interview time blocks read the documentation. # TODO: add documentation. -def generate_blocks(position, start_dt, end_dt, unavailability, interval): - all_interviewers = set( - position.interviewers.all() - ) # All interviewers for this position. TODO: decide if we want to have a predefined set of POSSIBLE interviewers for some position - blocks = [] - current_dt = start_dt - - while current_dt < end_dt: - block_end = min(current_dt + interval, end_dt) - available_interviewers = all_interviewers.copy() - - # Iterate through unavailability slots to check if any interviewers are unavailable - for slot in unavailability: - if slot.start_dt < block_end and slot.end_dt > current_dt: - # Remove the unavailable interviewer for this block - available_interviewers.discard(slot.user) - - # Always create a new block if available interviewers change. #TODO add print statements to check if this acctually works as expected. - if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): - # Create a new block when interviewer availability changes - blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) - else: - # Extend the last block if interviewer availability hasn't changed - blocks[-1]['end'] = block_end - - current_dt = block_end - - return blocks - - -def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: - # Define interview duration - interview_duration = timedelta(minutes=30) - - # want the higest rated earliest blocks first. If all blocks are equaly rated the earliest block will be first. - timeblocks = sorted(InterviewTimeblock.objects.filter(recruitment_position=position), key=lambda block: (-block.rating, block.start_dt)) - if not timeblocks: - raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') - - applications = list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) - if not applications: - raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') - - # Prepare unavailability data - applicant_unavailability = defaultdict(list) - interviewer_unavailability = defaultdict(list) - - # Collect unavailability for each applicant - for slot in OccupiedTimeslot.objects.filter(recruitment=position.recruitment): - applicant_unavailability[slot.user.id].append((slot.start_dt, slot.end_dt)) - - # Collect unavailability for each interviewer and existing interviews. #TODO check if this is needed, we allready have interview time blocks, which is an abstraction of interviewers availability. - existing_interviews = Interview.objects.filter(applications__recruitment_position=position) - for interview in existing_interviews: - for interviewer in interview.interviewers.all(): - interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) - # Mark the interview time as unavailable for all interviewers - for interviewer in position.interviewers.all(): - interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) - - interview_count = 0 # TODO remove, it is only used to check if something happens. Add better exceptions insted - all_applicants_unavailable = True - current_time = timezone.now() + timedelta( - hours=24 - ) # TODO: find out if 24h is needed. Ensure interviews are at least 24 hours in the future. Interviews should be planned well in advance. - - future_blocks = [block for block in timeblocks if block.end_dt > current_time] # for checking if there are any timeslots available. - if not future_blocks: - raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') - - for block in future_blocks: - block_start = max( - block.start_dt, current_time - ) # TODO: check if this is to specific. Do we care about seting interviews from "this monent" when interviews onlt can be allocated 24h in advance? - current_time = block_start - - while current_time + interview_duration <= block.end_dt and applications: - interview_end_time = current_time + interview_duration - - # Check if this time slot is already occupied by an existing interview - if any(interview.interview_time == current_time for interview in existing_interviews): - # TODO: replace print with a good exception. print(f' Slot {current_time} - {interview_end_time} already has an interview') - current_time += interview_duration - continue - - available_interviewers = get_available_interviewers( - block.available_interviewers.all(), current_time, interview_end_time, interviewer_unavailability - ) - - if not available_interviewers: - # TODO: replace print with a good exception. print(f' No available interviewers for slot: {current_time} - {interview_end_time}') - current_time += interview_duration - continue - - for application in applications[:]: - applicant = application.user - - if is_applicant_available(applicant, current_time, interview_end_time, applicant_unavailability): - all_applicants_unavailable = False - # Create and assign a new interview - interview = Interview.objects.create( - interview_time=current_time, - interview_location=f'Location for {position.name_en}', # TODO: set inteview room dependent on what rooms are available in the DB at the time of the intrerview - room=None, # Set the room if required - ) - interview.interviewers.set(available_interviewers) - interview.save() - - # Link the interview to the application - application.interview = interview - application.save() - - # Mark this time of interview as occupied for the applicant and interviewers. - # Applicants are given a 2 hour buffer after interviews to avoid time conflict with other positions. - mark_applicant_unavailable(applicant, current_time, interview_end_time) - mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) - - # Add the new interview to existing_interviews to prevent double-booking - existing_interviews = list(existing_interviews) + [interview] - - interview_count += 1 - applications.remove(application) - # TODO: Create a Exception for informing of what interviews was created in a HTTP response ??? print(f' Allocated interview for {applicant.username} at {current_time}') - - if limit_to_first_applicant: - # TODO remove this conditional with "limit to first applicant". It was used for testing. - return interview_count - - break - else: - print(f' Applicant {applicant.username} not available for this slot') - - current_time += interview_duration - - if not applications: - break - - if interview_count == 0: - if all_applicants_unavailable: - raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') - else: - raise NoAvailableInterviewersError(f'No available interviewers for any time slot for position: {position.name_en}') - - if applications: - raise InsufficientTimeBlocksError( - f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.' - ) - - return interview_count # TODO, decide if this is needed - - -def calculate_rating(start_dt, end_dt, available_interviewers_count) -> int: - block_length = (end_dt - start_dt).total_seconds() / 3600 - rating = (available_interviewers_count * 2) + (block_length * 0.5) - return max(0, rating) - - -def is_applicant_available(applicant, start_dt, end_dt, unavailability) -> bool: - """Check if the applicant is available during the given time block.""" - for unavail_start, unavail_end in unavailability.get(applicant.id, []): - if unavail_start < end_dt and unavail_end > start_dt: - return False # Applicant is unavailable during this time - return True - - -def mark_applicant_unavailable(applicant, start_dt, end_dt, buffer_hours=2): - """Mark the applicant's timeslot as occupied, including a buffer period after the interview.""" - buffer_end = end_dt + timedelta(hours=buffer_hours) - OccupiedTimeslot.objects.create( - user=applicant, - recruitment=applicant.applications.first().recruitment, - start_dt=start_dt, - end_dt=buffer_end, - ) - - -def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unavailability) -> None: - """Return a list of available interviewers who are free during the time block.""" - available_interviewers = [] - - for interviewer in interviewers: - if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability): - available_interviewers.append(interviewer) - - return available_interviewers - - -def is_interviewer_available(interviewer, start_dt, end_dt, unavailability) -> bool: - """Check if the interviewer is available during the given time block.""" - for unavail_start, unavail_end in unavailability.get(interviewer.id, []): - if unavail_start < end_dt and unavail_end > start_dt: - return False # Interviewer is unavailable during this time - return True - - -def mark_interviewers_unavailable(interviewers, start_dt, end_dt, unavailability) -> None: - """Mark the interviewers as unavailable for the given time block.""" - for interviewer in interviewers: - unavailability[interviewer.id].append((start_dt, end_dt)) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 93d1803af..b7e4d267d 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -5,8 +5,6 @@ import hmac import hashlib from typing import Any -from datetime import datetime, timedelta -from .exceptions import * from guardian.shortcuts import get_objects_for_user @@ -40,11 +38,14 @@ REQUESTED_IMPERSONATE_USER, ) -from .utils import allocate_interviews_for_position, event_query, generate_timeslots, generate_interview_timeblocks, get_occupied_timeslots_from_request +from samfundet.automatic_interview_allocation import generate_interview_timeblocks, allocate_interviews_for_position + +from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage +from .exceptions import * from .models.role import Role from .serializers import ( - InterviewTimeblockSerializer, + # InterviewTimeblockSerializer, TagSerializer, GangSerializer, MenuSerializer, @@ -135,7 +136,7 @@ Recruitment, InterviewRoom, OccupiedTimeslot, - InterviewTimeblock, + # InterviewTimeblock, RecruitmentPosition, RecruitmentStatistics, RecruitmentApplication, @@ -1277,28 +1278,6 @@ def create(self, request: Request) -> Response: return Response({'message': 'Successfully updated occupied timeslots'}) -class GenerateInterviewTimeblocksView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request: Request, pk): - try: - recruitment = get_object_or_404(Recruitment, id=pk) - block_count = generate_interview_timeblocks(recruitment.id) - return Response( - {'message': f'Interview blocks generated successfully for recruitment {pk}.', 'blocks_created': block_count}, status=status.HTTP_200_OK - ) - except Recruitment.DoesNotExist: - return Response({'error': f'Recruitment with id {pk} does not exist.'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - return Response({'error': f'Failed to generate interview blocks: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class InterviewTimeblockView(ModelViewSet): - permission_classes = [IsAuthenticated] # Corrected spelling - serializer_class = InterviewTimeblockSerializer - queryset = InterviewTimeblock.objects.all() - - class UserFeedbackView(CreateAPIView): permission_classes = [AllowAny] model = UserFeedbackModel @@ -1350,17 +1329,31 @@ def post(self, request: Request) -> Response: return Response(status=status.HTTP_201_CREATED, data={'message': 'Feedback submitted successfully!'}) -class AllocateInterviewsForPositionView(APIView): +class AutomaticInterviewAllocationView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk): + return Response({'message': 'Use POST method to allocate interviews.'}, status=status.HTTP_200_OK) + + def post(self, request, pk): try: position = get_object_or_404(RecruitmentPosition, id=pk) + + # Generate interview timeblocks + timeblocks = generate_interview_timeblocks(position.recruitment.id) + + # Allocate interviews interview_count = allocate_interviews_for_position(position) + return Response( - {'message': f'Interviews allocated successfully for position {pk}.', 'interviews_allocated': interview_count}, + { + 'message': f'Interviews allocated successfully for position {pk}.', + 'interviews_allocated': interview_count, + 'timeblocks_generated': len(timeblocks), + }, status=status.HTTP_200_OK, ) + except NoTimeBlocksAvailableError as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) except NoApplicationsWithoutInterviewsError as e: @@ -1373,7 +1366,5 @@ def get(self, request, pk): return Response({'error': str(e), 'partial_allocation': True}, status=status.HTTP_206_PARTIAL_CONTENT) except NoFutureTimeSlotsError as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except RecruitmentPosition.DoesNotExist: - return Response({'error': f'Recruitment position with id {pk} does not exist.'}, status=status.HTTP_404_NOT_FOUND) except Exception as e: return Response({'error': f'An unexpected error occurred: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) From b4c72e9469e12d7d83f15e43345c271405e41dcf Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 03:42:20 +0200 Subject: [PATCH 15/44] adds automatic interview allocation, which work without InterviewTimeBlock table --- .../automatic_interview_allocation.py | 111 ++++++++++++------ backend/samfundet/utils.py | 9 +- backend/samfundet/views.py | 41 ++++--- 3 files changed, 100 insertions(+), 61 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation.py index 8a2342e27..b3f562507 100644 --- a/backend/samfundet/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from datetime import time, datetime, timedelta from collections import defaultdict @@ -8,59 +9,95 @@ from .exceptions import * from .models.recruitment import ( Interview, - Recruitment, OccupiedTimeslot, - RecruitmentPosition, RecruitmentApplication, ) +logger = logging.getLogger(__name__) -def generate_interview_timeblocks(recruitment_id) -> list[dict]: - recruitment = Recruitment.objects.get(id=recruitment_id) - positions = RecruitmentPosition.objects.filter(recruitment=recruitment) + +def generate_interview_timeblocks(position): + recruitment = position.recruitment all_blocks = [] + logger.info(f'Generating interview timeblocks for position {position.id} - {position.name_en}') + logger.info(f'Recruitment dates: visible_from={recruitment.visible_from}, deadline={recruitment.actual_application_deadline}') + current_date = timezone.now().date() + logger.info(f'Current date: {current_date}') + + start_date = max(recruitment.visible_from.date(), current_date) + end_date = recruitment.actual_application_deadline.date() + start_time = time(8, 0) + end_time = time(23, 0) + interval = timedelta(minutes=30) + + logger.info(f'Position date range: {start_date} to {end_date}') + logger.info(f'Daily time range: {start_time} to {end_time}') + + interviewers = position.interviewers.all() + logger.info(f'Number of interviewers for position: {interviewers.count()}') + + current_date = start_date + while current_date <= end_date: + logger.info(f'Processing date: {current_date}') + current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) + end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) + + unavailability = get_unavailability(recruitment) + logger.info(f'Number of unavailability slots: {unavailability.count()}') + + blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) + logger.info(f'Generated {len(blocks)} blocks for date {current_date}') + + all_blocks.extend( + [ + { + 'recruitment_position': position, + 'date': current_date, + 'start_dt': block['start'], + 'end_dt': block['end'], + 'rating': calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), + 'available_interviewers': list(block['available_interviewers']), + } + for block in blocks + ] + ) - for position in positions: - start_date = max(recruitment.visible_from.date(), current_date) - end_date = recruitment.actual_application_deadline.date() - start_time = time(8, 0) - end_time = time(23, 0) - interval = timedelta(minutes=30) - - current_date = start_date - while current_date <= end_date: - current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) - end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) - - unavailability = get_unavailability(recruitment) - blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) - - all_blocks.extend( - [ - { - 'recruitment_position': position, - 'date': current_date, - 'start_dt': block['start'], - 'end_dt': block['end'], - 'rating': calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), - 'available_interviewers': list(block['available_interviewers']), - } - for block in blocks - ] - ) - - current_date += timedelta(days=1) + current_date += timedelta(days=1) + logger.info(f'Total blocks generated for position {position.id}: {len(all_blocks)}') return all_blocks +def generate_blocks(position, start_dt, end_dt, unavailability, interval): + all_interviewers = set(position.interviewers.all()) + blocks = [] + current_dt = start_dt + + while current_dt < end_dt: + block_end = min(current_dt + interval, end_dt) + available_interviewers = all_interviewers.copy() + + for slot in unavailability: + if slot.start_dt < block_end and slot.end_dt > current_dt: + available_interviewers.discard(slot.user) + + if available_interviewers: + if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): + blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) + else: + blocks[-1]['end'] = block_end + + current_dt = block_end + + return blocks + + def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: interview_duration = timedelta(minutes=30) - timeblocks = generate_interview_timeblocks(position.recruitment.id) - timeblocks = [block for block in timeblocks if block['recruitment_position'] == position] + timeblocks = generate_interview_timeblocks(position) timeblocks.sort(key=lambda block: (-block['rating'], block['start_dt'])) if not timeblocks: diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 3ec73ba55..6755ad78d 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +import datetime from django.http import QueryDict from django.db.models import Q, Model @@ -11,13 +11,8 @@ from django.contrib.contenttypes.models import ContentType from .models import User -from .exceptions import * from .models.event import Event -from .models.recruitment import ( - Recruitment, - OccupiedTimeslot, - RecruitmentInterviewAvailability, -) +from .models.recruitment import Recruitment, OccupiedTimeslot, RecruitmentInterviewAvailability ### diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index b7e4d267d..5cf8c82e4 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -4,6 +4,7 @@ import csv import hmac import hashlib +import logging from typing import Any from guardian.shortcuts import get_objects_for_user @@ -18,6 +19,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated, DjangoModelPermissions, DjangoModelPermissionsOrAnonReadOnly +logger = logging.getLogger(__name__) from django.conf import settings from django.http import QueryDict, HttpResponse from django.utils import timezone @@ -1332,15 +1334,29 @@ def post(self, request: Request) -> Response: class AutomaticInterviewAllocationView(APIView): permission_classes = [IsAuthenticated] - def get(self, request, pk): - return Response({'message': 'Use POST method to allocate interviews.'}, status=status.HTTP_200_OK) - def post(self, request, pk): try: position = get_object_or_404(RecruitmentPosition, id=pk) + logger.info(f'Attempting to allocate interviews for position {pk} - {position.name_en}') # Generate interview timeblocks - timeblocks = generate_interview_timeblocks(position.recruitment.id) + timeblocks = generate_interview_timeblocks(position) + + # No need to filter timeblocks as they are already for the specific position + logger.info(f'Found {len(timeblocks)} timeblocks for position {position.id}') + + if not timeblocks: + logger.error(f'No timeblocks available for position {position.id} (Name: {position.name_en})') + logger.error( + f'Recruitment details: ID: {position.recruitment.id}, Visible from: {position.recruitment.visible_from}, Deadline: {position.recruitment.actual_application_deadline}' + ) + return Response( + { + 'error': f'No available time blocks for position: {position.name_en}', + 'details': 'No suitable time blocks were generated. This might be due to recruitment dates, interviewer availability, or other constraints.', + }, + status=status.HTTP_400_BAD_REQUEST, + ) # Allocate interviews interview_count = allocate_interviews_for_position(position) @@ -1354,17 +1370,8 @@ def post(self, request, pk): status=status.HTTP_200_OK, ) - except NoTimeBlocksAvailableError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except NoApplicationsWithoutInterviewsError as e: - return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND) - except NoAvailableInterviewersError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except AllApplicantsUnavailableError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except InsufficientTimeBlocksError as e: - return Response({'error': str(e), 'partial_allocation': True}, status=status.HTTP_206_PARTIAL_CONTENT) - except NoFutureTimeSlotsError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: - return Response({'error': f'An unexpected error occurred: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + logger.exception(f'Error in AutomaticInterviewAllocationView for position {pk}') + return Response( + {'error': str(e), 'details': 'An unexpected error occurred during interview allocation.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) From a4e2232a3257aa056fa478cf5e97f3e13faa3ebf Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 05:31:29 +0200 Subject: [PATCH 16/44] adds more logging --- .../samfundet/automatic_interview_allocation.py | 5 +++++ .../0009_delete_interviewtimeblock.py | 16 ++++++++++++++++ backend/samfundet/views.py | 17 ++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 backend/samfundet/migrations/0009_delete_interviewtimeblock.py diff --git a/backend/samfundet/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation.py index b3f562507..be7dbace4 100644 --- a/backend/samfundet/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation.py @@ -50,6 +50,11 @@ def generate_interview_timeblocks(position): blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) logger.info(f'Generated {len(blocks)} blocks for date {current_date}') + for block in blocks: + block_length = (block['end'] - block['start']).total_seconds() / 60 # length in minutes + interviewer_count = len(block['available_interviewers']) + logger.info(f"Block: Start={block['start']}, Length={block_length} minutes, Interviewers={interviewer_count}") + all_blocks.extend( [ { diff --git a/backend/samfundet/migrations/0009_delete_interviewtimeblock.py b/backend/samfundet/migrations/0009_delete_interviewtimeblock.py new file mode 100644 index 000000000..f453131ef --- /dev/null +++ b/backend/samfundet/migrations/0009_delete_interviewtimeblock.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.1 on 2024-10-18 02:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0008_merge_20241018_0235'), + ] + + operations = [ + migrations.DeleteModel( + name='InterviewTimeblock', + ), + ] diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 5cf8c82e4..b87c160f6 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1342,7 +1342,21 @@ def post(self, request, pk): # Generate interview timeblocks timeblocks = generate_interview_timeblocks(position) - # No need to filter timeblocks as they are already for the specific position + # Process timeblocks for response + processed_timeblocks = [] + for block in timeblocks: + block_length = (block['end_dt'] - block['start_dt']).total_seconds() / 60 # length in minutes + processed_timeblocks.append( + { + 'date': block['date'], + 'start_time': block['start_dt'].time(), + 'end_time': block['end_dt'].time(), + 'length_minutes': block_length, + 'interviewer_count': len(block['available_interviewers']), + 'rating': block['rating'], + } + ) + logger.info(f'Found {len(timeblocks)} timeblocks for position {position.id}') if not timeblocks: @@ -1366,6 +1380,7 @@ def post(self, request, pk): 'message': f'Interviews allocated successfully for position {pk}.', 'interviews_allocated': interview_count, 'timeblocks_generated': len(timeblocks), + 'timeblocks_details': processed_timeblocks, }, status=status.HTTP_200_OK, ) From c8afe3f2a4cb276ab868a0682047a19d3e5ea60d Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 05:44:24 +0200 Subject: [PATCH 17/44] removes logging --- .../automatic_interview_allocation.py | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation.py index be7dbace4..735610019 100644 --- a/backend/samfundet/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from datetime import time, datetime, timedelta from collections import defaultdict @@ -13,47 +12,25 @@ RecruitmentApplication, ) -logger = logging.getLogger(__name__) - def generate_interview_timeblocks(position): recruitment = position.recruitment all_blocks = [] - logger.info(f'Generating interview timeblocks for position {position.id} - {position.name_en}') - logger.info(f'Recruitment dates: visible_from={recruitment.visible_from}, deadline={recruitment.actual_application_deadline}') - current_date = timezone.now().date() - logger.info(f'Current date: {current_date}') - start_date = max(recruitment.visible_from.date(), current_date) end_date = recruitment.actual_application_deadline.date() start_time = time(8, 0) end_time = time(23, 0) interval = timedelta(minutes=30) - logger.info(f'Position date range: {start_date} to {end_date}') - logger.info(f'Daily time range: {start_time} to {end_time}') - - interviewers = position.interviewers.all() - logger.info(f'Number of interviewers for position: {interviewers.count()}') - current_date = start_date while current_date <= end_date: - logger.info(f'Processing date: {current_date}') current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) unavailability = get_unavailability(recruitment) - logger.info(f'Number of unavailability slots: {unavailability.count()}') - blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) - logger.info(f'Generated {len(blocks)} blocks for date {current_date}') - - for block in blocks: - block_length = (block['end'] - block['start']).total_seconds() / 60 # length in minutes - interviewer_count = len(block['available_interviewers']) - logger.info(f"Block: Start={block['start']}, Length={block_length} minutes, Interviewers={interviewer_count}") all_blocks.extend( [ @@ -71,7 +48,6 @@ def generate_interview_timeblocks(position): current_date += timedelta(days=1) - logger.info(f'Total blocks generated for position {position.id}: {len(all_blocks)}') return all_blocks @@ -194,29 +170,6 @@ def get_unavailability(recruitment): return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') -def generate_blocks(position, start_dt, end_dt, unavailability, interval): - all_interviewers = set(position.interviewers.all()) - blocks = [] - current_dt = start_dt - - while current_dt < end_dt: - block_end = min(current_dt + interval, end_dt) - available_interviewers = all_interviewers.copy() - - for slot in unavailability: - if slot.start_dt < block_end and slot.end_dt > current_dt: - available_interviewers.discard(slot.user) - - if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): - blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) - else: - blocks[-1]['end'] = block_end - - current_dt = block_end - - return blocks - - def calculate_rating(start_dt, end_dt, available_interviewers_count) -> int: block_length = (end_dt - start_dt).total_seconds() / 3600 rating = (available_interviewers_count * 2) + (block_length * 0.5) From 6a91d43e4a20e9a231093800e8fbe29498765092 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 06:38:07 +0200 Subject: [PATCH 18/44] adds comments --- .../automatic_interview_allocation.py | 220 +++++++++++++++--- 1 file changed, 189 insertions(+), 31 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation.py index 735610019..c5473bdde 100644 --- a/backend/samfundet/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation.py @@ -1,37 +1,85 @@ from __future__ import annotations +from typing import Any from datetime import time, datetime, timedelta from collections import defaultdict from django.utils import timezone -from .exceptions import * +from samfundet.models.general import User + +from .exceptions import ( + NoFutureTimeSlotsError, + NoTimeBlocksAvailableError, + InsufficientTimeBlocksError, + NoAvailableInterviewersError, + AllApplicantsUnavailableError, + NoApplicationsWithoutInterviewsError, +) from .models.recruitment import ( Interview, + Recruitment, OccupiedTimeslot, + RecruitmentPosition, RecruitmentApplication, ) +""" +Automatic interview allocation currently works by creating interview timeblocks. +These time blocks are made by cutting days into sections, where sections are differentiated +the available interviewer count. +Availability is found by deriving it from unavailability. +In a case where occupied time slots can be set from 08:00 - 16:00, these are registred available interviewers +(I) If 4 interviewers are available from 08:00 - 12:00, +(II) 3 interviewers are available from 12:00 - 13:00, +(III) 3 interviewersa are available from 13:00 - 14:00, +(IV) and 5 interviewers are available from 14:00 - 16:00 + +(I) is one timeblock, (II) and (III) is combined into a second timeblock, and (IV) is a third timeblock. +E.i. timeblocks are differentiated by the amount of available interviewers. + +Interview timeblocks are given a rating, based on length of the timeblocks and the count of available interviewers. +""" + +# TODO: implement strategy for allocation interviews based on shared interviews (UKA, ISFiT, KSG) +# TODO: implement room allocation, based on rooms available at the time the interview has been set + -def generate_interview_timeblocks(position): +def generate_interview_timeblocks(position: RecruitmentPosition) -> list: + """ + Generates time blocks for interviews based on the recruitment's time range, + the availability of interviewers, and their unavailability. The blocks are divided + into 30-minute intervals for each day between the start and end dates. + + Args: + position: Recruitment position for which interview time blocks are generated. + + Returns: + List of time blocks with available interviewers, start and end times, and ratings. + """ recruitment = position.recruitment all_blocks = [] + # Determin the time range: current data, start and end dates for interview slots current_date = timezone.now().date() start_date = max(recruitment.visible_from.date(), current_date) end_date = recruitment.actual_application_deadline.date() - start_time = time(8, 0) - end_time = time(23, 0) - interval = timedelta(minutes=30) + start_time = time(8, 0) # Interviews start at 8:00 + end_time = time(23, 0) # Interviews end at 23:00 + interval = timedelta(minutes=30) # Each time block is 30 minutes + # Loop through each day in the range to generate time blocks current_date = start_date while current_date <= end_date: + # Create datetime objects for the start and end of the day current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) + # Fetch unavailability slots and generate blocks for the current day unavailability = get_unavailability(recruitment) blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) + # Add the generated blocks to the list, calculating ratings for each block all_blocks.extend( [ { @@ -46,50 +94,87 @@ def generate_interview_timeblocks(position): ] ) - current_date += timedelta(days=1) + current_date += timedelta(days=1) # Move to the next day return all_blocks -def generate_blocks(position, start_dt, end_dt, unavailability, interval): - all_interviewers = set(position.interviewers.all()) - blocks = [] - current_dt = start_dt +def generate_blocks(position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta) -> list: + """ + Generates time blocks within a given time range, accounting for interviewer unavailability. + Args: + position: Recruitment position. + start_dt: Start datetime of the interview range. + end_dt: End datetime of the interview range. + unavailability: List of unavailable timeslots for interviewers. + interval: Time interval for each block (30 minutes). + + Returns: + A list of time blocks with available interviewers for each block. + """ + all_interviewers = set(position.interviewers.all()) # Get all interviewers for the position + blocks: list = [] + current_dt = start_dt + # Iterate through the day in intervals while current_dt < end_dt: - block_end = min(current_dt + interval, end_dt) - available_interviewers = all_interviewers.copy() + block_end = min(current_dt + interval, end_dt) # End time for the current block + available_interviewers = all_interviewers.copy() # Start with all interviewers available + # Remove unavailable interviewers based on the unavailability times for slot in unavailability: if slot.start_dt < block_end and slot.end_dt > current_dt: available_interviewers.discard(slot.user) + # Only add blocks if there are available interviewers if available_interviewers: + # If the number of available interviewers changes, create a new block if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) else: - blocks[-1]['end'] = block_end + blocks[-1]['end'] = block_end # Extend the last block if interviewer count remains same - current_dt = block_end + current_dt = block_end # Move to the next block return blocks def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: - interview_duration = timedelta(minutes=30) - + """ + Allocates interviews for applicants of a given recruitment position based on available time blocks. + + Args: + position: The recruitment position for which interviews are being allocated. + limit_to_first_applicant: If True, the function will return after assigning an interview + to the first available applicant. + + Returns: + The number of interviews allocated. + + Raises: + NoTimeBlocksAvailableError: If no time blocks are available. + NoApplicationsWithoutInterviewsError: If no applications without interviews exist. + AllApplicantsUnavailableError: If all applicants are unavailable for the remaining time blocks. + NoAvailableInterviewersError: If no interviewers are available for any time slot. + InsufficientTimeBlocksError: If there are not enough time blocks for all applications. + """ + interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes + + # Generate and sort time blocks by rating (higher rating first) timeblocks = generate_interview_timeblocks(position) timeblocks.sort(key=lambda block: (-block['rating'], block['start_dt'])) if not timeblocks: raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') + # Fetch all applications without assigned interviews applications = list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) if not applications: raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') interviewer_unavailability = defaultdict(list) + # Get all existing interviews and mark interviewer unavailability existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) for interview in existing_interviews: for interviewer in interview.interviewers.all(): @@ -97,29 +182,34 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - interview_count = 0 all_applicants_unavailable = True - current_time = timezone.now() + timedelta(hours=24) + current_time = timezone.now() + timedelta(hours=24) # Only consider time slots 24 hours or more in the future future_blocks = [block for block in timeblocks if block['end_dt'] > current_time] if not future_blocks: raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') + # Allocate interviews within available future time blocks for block in future_blocks: - block_start = max(block['start_dt'], current_time) + block_start = max(block['start_dt'], current_time) # Ensure the block start is at least the current time current_time = block_start + # Allocate interviews within the block's time range while current_time + interview_duration <= block['end_dt'] and applications: interview_end_time = current_time + interview_duration + # Skip the block if there's an existing interview at the current time if any(interview.interview_time == current_time for interview in existing_interviews): current_time += interview_duration continue available_interviewers = get_available_interviewers(block['available_interviewers'], current_time, interview_end_time, interviewer_unavailability) + # If there are no available interviewers, move to the next time block if not available_interviewers: current_time += interview_duration continue + # Try to assign interviews to applicants for application in applications[:]: applicant = application.user @@ -133,26 +223,30 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - interview.interviewers.set(available_interviewers) interview.save() - application.interview = interview + application.interview = interview # Assign the interview to the application application.save() + # Mark the interviewers as unavailable for this time slot mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) existing_interviews = list(existing_interviews) + [interview] interview_count += 1 - applications.remove(application) + applications.remove(application) # Remove the assigned applicant from the list + # If we're only allocating to the first applicant, return early if limit_to_first_applicant: return interview_count - break + break # Move to the next time block current_time += interview_duration + # If all applications have been processed, stop early if not applications: break + # Raise errors based on the results of the allocation if interview_count == 0: if all_applicants_unavailable: raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') @@ -166,34 +260,98 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - return interview_count -def get_unavailability(recruitment): +def get_unavailability(recruitment: Recruitment) -> OccupiedTimeslot: + """ + Retrieves unavailable timeslots for a given recruitment. + + Args: + recruitment: The recruitment for which unavailable timeslots are fetched. + + Returns: + A queryset of OccupiedTimeslot objects ordered by start time. + """ return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') -def calculate_rating(start_dt, end_dt, available_interviewers_count) -> int: +def calculate_rating(start_dt: datetime, end_dt: datetime, available_interviewers_count: int) -> int: + """ + Calculates a rating for a time block based on the number of available interviewers and block length. + + Args: + start_dt: Start datetime of the block. + end_dt: End datetime of the block. + available_interviewers_count: Number of interviewers available for the block. + + Returns: + An integer rating for the time block. + """ block_length = (end_dt - start_dt).total_seconds() / 3600 rating = (available_interviewers_count * 2) + (block_length * 0.5) return max(0, int(rating)) -def is_applicant_available(applicant, start_dt, end_dt, recruitment) -> bool: +def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: + """ + Checks if an applicant is available for an interview during a given time range. + + Args: + applicant: The applicant to check availability for. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + recruitment: The recruitment to which the applicant has applied. + + Returns: + A boolean indicating whether the applicant is available for the given time range. + """ existing_interviews = Interview.objects.filter( applications__user=applicant, applications__recruitment=recruitment, interview_time__lt=end_dt, interview_time__gte=start_dt ) return not existing_interviews.exists() -def get_available_interviewers(interviewers, start_dt, end_dt, interviewer_unavailability): +def get_available_interviewers(interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: defaultdict[Any, list]) -> list: + """ + Filters interviewers who are available for a specific time slot. + + Args: + interviewers: List of interviewers to check availability for. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + interviewer_unavailability: Dictionary of interviewers and their unavailable time slots. + + Returns: + A list of available interviewers. + """ return [interviewer for interviewer in interviewers if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability)] -def is_interviewer_available(interviewer, start_dt, end_dt, unavailability) -> bool: - for unavail_start, unavail_end in unavailability.get(interviewer.id, []): - if unavail_start < end_dt and unavail_end > start_dt: - return False - return True +def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> bool: + """ + Checks if a specific interviewer is available during a given time range. + + Args: + interviewer: The interviewer to check. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + unavailability: List of unavailable time slots for the interviewer. + + Returns: + A boolean indicating whether the interviewer is available for the given time range. + """ + return all( + not (unavail_start < end_dt and unavail_end > start_dt) for unavail_start, unavail_end in unavailability.get(interviewer.id, []) + ) # Return True if the interviewer is available + +def mark_interviewers_unavailable(interviewers: list[Any], start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> None: + """ + Marks a group of interviewers as unavailable during a given time range. -def mark_interviewers_unavailable(interviewers, start_dt, end_dt, unavailability) -> None: + Args: + interviewers: List of interviewers to mark as unavailable. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + unavailability: Dictionary to store the unavailable times for each interviewer. + """ for interviewer in interviewers: unavailability[interviewer.id].append((start_dt, end_dt)) From 2a05fe6a692e44f11a1d6e55e7baeda87228cb85 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 06:43:44 +0200 Subject: [PATCH 19/44] adds python module for interview allocation --- backend/samfundet/automatic_interview_allocation/__init__.py | 0 .../automatic_interview_allocation.py | 4 ++-- backend/samfundet/views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 backend/samfundet/automatic_interview_allocation/__init__.py rename backend/samfundet/{ => automatic_interview_allocation}/automatic_interview_allocation.py (99%) diff --git a/backend/samfundet/automatic_interview_allocation/__init__.py b/backend/samfundet/automatic_interview_allocation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/samfundet/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py similarity index 99% rename from backend/samfundet/automatic_interview_allocation.py rename to backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py index c5473bdde..4b3833b4c 100644 --- a/backend/samfundet/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py @@ -8,7 +8,7 @@ from samfundet.models.general import User -from .exceptions import ( +from samfundet.exceptions import ( NoFutureTimeSlotsError, NoTimeBlocksAvailableError, InsufficientTimeBlocksError, @@ -16,7 +16,7 @@ AllApplicantsUnavailableError, NoApplicationsWithoutInterviewsError, ) -from .models.recruitment import ( +from samfundet.models.recruitment import ( Interview, Recruitment, OccupiedTimeslot, diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index b87c160f6..ad64a1da7 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -40,7 +40,7 @@ REQUESTED_IMPERSONATE_USER, ) -from samfundet.automatic_interview_allocation import generate_interview_timeblocks, allocate_interviews_for_position +from samfundet.automatic_interview_allocation.automatic_interview_allocation import generate_interview_timeblocks, allocate_interviews_for_position from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage From fec04c4b24d206c47140707e44e5754f47e570f9 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 06:46:27 +0200 Subject: [PATCH 20/44] moves exceptions to pythin package --- .../automatic_interview_allocation.py | 2 +- .../{ => automatic_interview_allocation}/exceptions.py | 0 backend/samfundet/views.py | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) rename backend/samfundet/{ => automatic_interview_allocation}/exceptions.py (100%) diff --git a/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py index 4b3833b4c..c715862eb 100644 --- a/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py @@ -8,7 +8,7 @@ from samfundet.models.general import User -from samfundet.exceptions import ( +from samfundet.automatic_interview_allocation.exceptions import ( NoFutureTimeSlotsError, NoTimeBlocksAvailableError, InsufficientTimeBlocksError, diff --git a/backend/samfundet/exceptions.py b/backend/samfundet/automatic_interview_allocation/exceptions.py similarity index 100% rename from backend/samfundet/exceptions.py rename to backend/samfundet/automatic_interview_allocation/exceptions.py diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index ad64a1da7..0310d3b48 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -44,7 +44,6 @@ from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage -from .exceptions import * from .models.role import Role from .serializers import ( # InterviewTimeblockSerializer, From dec54f3c97d30da39dfd6b8ce32772122382fc59 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 06:49:13 +0200 Subject: [PATCH 21/44] fixes automatic interview allocation place and adds type to view --- .../automatic_interview_allocation.py | 15 +++++++-------- backend/samfundet/views.py | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py index c715862eb..07a4eab7b 100644 --- a/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py +++ b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py @@ -7,7 +7,13 @@ from django.utils import timezone from samfundet.models.general import User - +from samfundet.models.recruitment import ( + Interview, + Recruitment, + OccupiedTimeslot, + RecruitmentPosition, + RecruitmentApplication, +) from samfundet.automatic_interview_allocation.exceptions import ( NoFutureTimeSlotsError, NoTimeBlocksAvailableError, @@ -16,13 +22,6 @@ AllApplicantsUnavailableError, NoApplicationsWithoutInterviewsError, ) -from samfundet.models.recruitment import ( - Interview, - Recruitment, - OccupiedTimeslot, - RecruitmentPosition, - RecruitmentApplication, -) """ Automatic interview allocation currently works by creating interview timeblocks. diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 0310d3b48..671d02171 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1333,7 +1333,7 @@ def post(self, request: Request) -> Response: class AutomaticInterviewAllocationView(APIView): permission_classes = [IsAuthenticated] - def post(self, request, pk): + def post(self, request: Request, pk) -> Response: try: position = get_object_or_404(RecruitmentPosition, id=pk) logger.info(f'Attempting to allocate interviews for position {pk} - {position.name_en}') From c0011812979974e93adc602a9890dd162eb3887b Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 06:50:29 +0200 Subject: [PATCH 22/44] removes logger from views --- backend/samfundet/views.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 671d02171..795f0f7d3 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -4,7 +4,6 @@ import csv import hmac import hashlib -import logging from typing import Any from guardian.shortcuts import get_objects_for_user @@ -19,7 +18,6 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated, DjangoModelPermissions, DjangoModelPermissionsOrAnonReadOnly -logger = logging.getLogger(__name__) from django.conf import settings from django.http import QueryDict, HttpResponse from django.utils import timezone @@ -1336,8 +1334,6 @@ class AutomaticInterviewAllocationView(APIView): def post(self, request: Request, pk) -> Response: try: position = get_object_or_404(RecruitmentPosition, id=pk) - logger.info(f'Attempting to allocate interviews for position {pk} - {position.name_en}') - # Generate interview timeblocks timeblocks = generate_interview_timeblocks(position) @@ -1356,13 +1352,7 @@ def post(self, request: Request, pk) -> Response: } ) - logger.info(f'Found {len(timeblocks)} timeblocks for position {position.id}') - if not timeblocks: - logger.error(f'No timeblocks available for position {position.id} (Name: {position.name_en})') - logger.error( - f'Recruitment details: ID: {position.recruitment.id}, Visible from: {position.recruitment.visible_from}, Deadline: {position.recruitment.actual_application_deadline}' - ) return Response( { 'error': f'No available time blocks for position: {position.name_en}', @@ -1385,7 +1375,6 @@ def post(self, request: Request, pk) -> Response: ) except Exception as e: - logger.exception(f'Error in AutomaticInterviewAllocationView for position {pk}') return Response( {'error': str(e), 'details': 'An unexpected error occurred during interview allocation.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) From 5d04cb814577957e05a5a68dbe9188a933d6f454 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 06:55:19 +0200 Subject: [PATCH 23/44] removes commeted out code --- backend/samfundet/models/recruitment.py | 11 ----------- backend/samfundet/views.py | 2 -- 2 files changed, 13 deletions(-) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 8a229b85e..2df6249d9 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -465,17 +465,6 @@ def resolve_org(self, *, return_id: bool = False) -> Organization | int: return self.recruitment.resolve_org(return_id=return_id) -# class InterviewTimeblock(models.Model): -# recruitment_position = models.ForeignKey( -# 'RecruitmentPosition', on_delete=models.CASCADE, help_text='The position which is recruiting', related_name='interview_timeblocks' -# ) -# date = models.DateField(help_text='Block date', null=False, blank=False) -# start_dt = models.DateTimeField(help_text='Block start time', null=False, blank=False) -# end_dt = models.DateTimeField(help_text='Block end time', null=False, blank=False) -# rating = models.FloatField(help_text='Rating used for optimizing interview time') -# available_interviewers = models.ManyToManyField('User', help_text='Interviewers in this time block', blank=True, related_name='interview_timeblocks') - - class RecruitmentStatistics(FullCleanSaveMixin): recruitment = models.OneToOneField(Recruitment, on_delete=models.CASCADE, blank=True, null=True, related_name='statistics') diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 795f0f7d3..33beb236c 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -44,7 +44,6 @@ from .homepage import homepage from .models.role import Role from .serializers import ( - # InterviewTimeblockSerializer, TagSerializer, GangSerializer, MenuSerializer, @@ -135,7 +134,6 @@ Recruitment, InterviewRoom, OccupiedTimeslot, - # InterviewTimeblock, RecruitmentPosition, RecruitmentStatistics, RecruitmentApplication, From 5b89e35fe6e2cc09b8860c2077807f148028c8d5 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 07:26:34 +0200 Subject: [PATCH 24/44] reafactor script structure --- .../allocate_interviews_for_position.py | 178 +++++++++ .../automatic_interview_allocation.py | 356 ------------------ .../generate_position_interview_schedule.py | 221 +++++++++++ .../automatic_interview_allocation/utils.py | 55 +++ backend/samfundet/views.py | 4 +- 5 files changed, 456 insertions(+), 358 deletions(-) create mode 100644 backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py delete mode 100644 backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py create mode 100644 backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py create mode 100644 backend/samfundet/automatic_interview_allocation/utils.py diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py new file mode 100644 index 000000000..4ee29c117 --- /dev/null +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from datetime import timezone, timedelta +from collections import defaultdict + +from samfundet.models.recruitment import Interview, RecruitmentApplication +from samfundet.automatic_interview_allocation.exceptions import ( + NoFutureTimeSlotsError, + NoTimeBlocksAvailableError, + InsufficientTimeBlocksError, + AllApplicantsUnavailableError, + NoApplicationsWithoutInterviewsError, +) +from samfundet.automatic_interview_allocation.generate_position_interview_schedule import ( + is_applicant_available, + create_daily_interview_blocks, +) + +from .utils import ( + get_available_interviewers, + mark_interviewers_unavailable, +) + + +def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: + """ + Allocates interviews for applicants of a given recruitment position based on available time blocks. + + Args: + position: The recruitment position for which interviews are being allocated. + limit_to_first_applicant: If True, the function will return after assigning an interview + to the first available applicant. + + Returns: + The number of interviews allocated. + + Raises: + NoTimeBlocksAvailableError: If no time blocks are available. + NoApplicationsWithoutInterviewsError: If no applications without interviews exist. + AllApplicantsUnavailableError: If all applicants are unavailable for the remaining time blocks. + NoAvailableInterviewersError: If no interviewers are available for any time slot. + InsufficientTimeBlocksError: If there are not enough time blocks for all applications. + """ + interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes + + # Generate and sort time blocks by rating (higher rating first) + timeblocks = get_sorted_timeblocks(position) + + # Fetch all applications without assigned interviews + applications = get_applications_without_interviews(position) + + interviewer_unavailability = get_interviewer_unavailability(position) + + validate_allocation_prerequisites(timeblocks, applications, position) + + interview_count = allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, limit_to_first_applicant) + + handle_allocation_results(interview_count, applications, position) + + return interview_count + + +def get_sorted_timeblocks(position): + """Generate and sort time blocks by rating (higher rating first).""" + timeblocks = create_daily_interview_blocks(position) + timeblocks.sort(key=lambda block: (-block['rating'], block['start'])) + return timeblocks + + +def get_applications_without_interviews(position): + """Fetch all applications without assigned interviews.""" + return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) + + +def get_interviewer_unavailability(position): + """Get all existing interviews and mark interviewer unavailability.""" + interviewer_unavailability = defaultdict(list) + existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) + for interview in existing_interviews: + for interviewer in interview.interviewers.all(): + interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) + return interviewer_unavailability + + +def validate_allocation_prerequisites(timeblocks, applications, position): + """Validate that there are available time blocks and applications.""" + if not timeblocks: + raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') + if not applications: + raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') + + +def allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, limit_to_first_applicant): + """Allocate interviews within available future time blocks.""" + interview_count = 0 + current_time = timezone.now() + timedelta(hours=24) # Only consider time slots 24 hours or more in the future + + future_blocks = [block for block in timeblocks if block['end'] > current_time] + if not future_blocks: + raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') + + for block in future_blocks: + interview_count += allocate_interviews_in_block( + block, applications, interviewer_unavailability, position, interview_duration, current_time, limit_to_first_applicant + ) + if limit_to_first_applicant and interview_count > 0: + break + if not applications: + break + + return interview_count + + +def allocate_interviews_in_block(block, applications, interviewer_unavailability, position, interview_duration, current_time, limit_to_first_applicant): + """Allocate interviews within a single time block.""" + block_interview_count = 0 + block_start = max(block['start'], current_time) + current_time = block_start + + while current_time + interview_duration <= block['end'] and applications: + if allocate_single_interview(current_time, block, applications, interviewer_unavailability, position, interview_duration): + block_interview_count += 1 + if limit_to_first_applicant: + return block_interview_count + current_time += interview_duration + + return block_interview_count + + +def allocate_single_interview(current_time, block, applications, interviewer_unavailability, position, interview_duration): + """Attempt to allocate a single interview at the current time.""" + interview_end_time = current_time + interview_duration + + # Skip the block if there's an existing interview at the current time + if any( + interview.interview_time == current_time for interview in Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) + ): + return False + + available_interviewers = get_available_interviewers(block['available_interviewers'], current_time, interview_end_time, interviewer_unavailability) + + # If there are no available interviewers, move to the next time block + if not available_interviewers: + return False + + # Try to assign interviews to applicants + for application in applications[:]: + applicant = application.user + if is_applicant_available(applicant, current_time, interview_end_time, position.recruitment): + create_interview(application, current_time, position, available_interviewers) + mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) + applications.remove(application) # Remove the assigned applicant from the list + return True + + return False + + +def create_interview(application, interview_time, position, available_interviewers): + """Create and save an interview for the given application.""" + interview = Interview.objects.create( + interview_time=interview_time, + interview_location=f'Location for {position.name_en}', + room=None, + ) + interview.interviewers.set(available_interviewers) + interview.save() + application.interview = interview # Assign the interview to the application + application.save() + + +def handle_allocation_results(interview_count, applications, position): + """Handle the results of the interview allocation process.""" + if interview_count == 0: + raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') + if applications: + raise InsufficientTimeBlocksError( + f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.' + ) diff --git a/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py b/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py deleted file mode 100644 index 07a4eab7b..000000000 --- a/backend/samfundet/automatic_interview_allocation/automatic_interview_allocation.py +++ /dev/null @@ -1,356 +0,0 @@ -from __future__ import annotations - -from typing import Any -from datetime import time, datetime, timedelta -from collections import defaultdict - -from django.utils import timezone - -from samfundet.models.general import User -from samfundet.models.recruitment import ( - Interview, - Recruitment, - OccupiedTimeslot, - RecruitmentPosition, - RecruitmentApplication, -) -from samfundet.automatic_interview_allocation.exceptions import ( - NoFutureTimeSlotsError, - NoTimeBlocksAvailableError, - InsufficientTimeBlocksError, - NoAvailableInterviewersError, - AllApplicantsUnavailableError, - NoApplicationsWithoutInterviewsError, -) - -""" -Automatic interview allocation currently works by creating interview timeblocks. -These time blocks are made by cutting days into sections, where sections are differentiated -the available interviewer count. -Availability is found by deriving it from unavailability. -In a case where occupied time slots can be set from 08:00 - 16:00, these are registred available interviewers -(I) If 4 interviewers are available from 08:00 - 12:00, -(II) 3 interviewers are available from 12:00 - 13:00, -(III) 3 interviewersa are available from 13:00 - 14:00, -(IV) and 5 interviewers are available from 14:00 - 16:00 - -(I) is one timeblock, (II) and (III) is combined into a second timeblock, and (IV) is a third timeblock. -E.i. timeblocks are differentiated by the amount of available interviewers. - -Interview timeblocks are given a rating, based on length of the timeblocks and the count of available interviewers. -""" - -# TODO: implement strategy for allocation interviews based on shared interviews (UKA, ISFiT, KSG) -# TODO: implement room allocation, based on rooms available at the time the interview has been set - - -def generate_interview_timeblocks(position: RecruitmentPosition) -> list: - """ - Generates time blocks for interviews based on the recruitment's time range, - the availability of interviewers, and their unavailability. The blocks are divided - into 30-minute intervals for each day between the start and end dates. - - Args: - position: Recruitment position for which interview time blocks are generated. - - Returns: - List of time blocks with available interviewers, start and end times, and ratings. - """ - recruitment = position.recruitment - all_blocks = [] - - # Determin the time range: current data, start and end dates for interview slots - current_date = timezone.now().date() - start_date = max(recruitment.visible_from.date(), current_date) - end_date = recruitment.actual_application_deadline.date() - start_time = time(8, 0) # Interviews start at 8:00 - end_time = time(23, 0) # Interviews end at 23:00 - interval = timedelta(minutes=30) # Each time block is 30 minutes - - # Loop through each day in the range to generate time blocks - current_date = start_date - while current_date <= end_date: - # Create datetime objects for the start and end of the day - current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) - end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) - - # Fetch unavailability slots and generate blocks for the current day - unavailability = get_unavailability(recruitment) - blocks = generate_blocks(position, current_datetime, end_datetime, unavailability, interval) - - # Add the generated blocks to the list, calculating ratings for each block - all_blocks.extend( - [ - { - 'recruitment_position': position, - 'date': current_date, - 'start_dt': block['start'], - 'end_dt': block['end'], - 'rating': calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), - 'available_interviewers': list(block['available_interviewers']), - } - for block in blocks - ] - ) - - current_date += timedelta(days=1) # Move to the next day - - return all_blocks - - -def generate_blocks(position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta) -> list: - """ - Generates time blocks within a given time range, accounting for interviewer unavailability. - - Args: - position: Recruitment position. - start_dt: Start datetime of the interview range. - end_dt: End datetime of the interview range. - unavailability: List of unavailable timeslots for interviewers. - interval: Time interval for each block (30 minutes). - - Returns: - A list of time blocks with available interviewers for each block. - """ - all_interviewers = set(position.interviewers.all()) # Get all interviewers for the position - blocks: list = [] - current_dt = start_dt - # Iterate through the day in intervals - while current_dt < end_dt: - block_end = min(current_dt + interval, end_dt) # End time for the current block - available_interviewers = all_interviewers.copy() # Start with all interviewers available - - # Remove unavailable interviewers based on the unavailability times - for slot in unavailability: - if slot.start_dt < block_end and slot.end_dt > current_dt: - available_interviewers.discard(slot.user) - - # Only add blocks if there are available interviewers - if available_interviewers: - # If the number of available interviewers changes, create a new block - if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): - blocks.append({'start': current_dt, 'end': block_end, 'available_interviewers': available_interviewers}) - else: - blocks[-1]['end'] = block_end # Extend the last block if interviewer count remains same - - current_dt = block_end # Move to the next block - - return blocks - - -def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: - """ - Allocates interviews for applicants of a given recruitment position based on available time blocks. - - Args: - position: The recruitment position for which interviews are being allocated. - limit_to_first_applicant: If True, the function will return after assigning an interview - to the first available applicant. - - Returns: - The number of interviews allocated. - - Raises: - NoTimeBlocksAvailableError: If no time blocks are available. - NoApplicationsWithoutInterviewsError: If no applications without interviews exist. - AllApplicantsUnavailableError: If all applicants are unavailable for the remaining time blocks. - NoAvailableInterviewersError: If no interviewers are available for any time slot. - InsufficientTimeBlocksError: If there are not enough time blocks for all applications. - """ - interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes - - # Generate and sort time blocks by rating (higher rating first) - timeblocks = generate_interview_timeblocks(position) - timeblocks.sort(key=lambda block: (-block['rating'], block['start_dt'])) - - if not timeblocks: - raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') - - # Fetch all applications without assigned interviews - applications = list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) - if not applications: - raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') - - interviewer_unavailability = defaultdict(list) - - # Get all existing interviews and mark interviewer unavailability - existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) - for interview in existing_interviews: - for interviewer in interview.interviewers.all(): - interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + interview_duration)) - - interview_count = 0 - all_applicants_unavailable = True - current_time = timezone.now() + timedelta(hours=24) # Only consider time slots 24 hours or more in the future - - future_blocks = [block for block in timeblocks if block['end_dt'] > current_time] - if not future_blocks: - raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') - - # Allocate interviews within available future time blocks - for block in future_blocks: - block_start = max(block['start_dt'], current_time) # Ensure the block start is at least the current time - current_time = block_start - - # Allocate interviews within the block's time range - while current_time + interview_duration <= block['end_dt'] and applications: - interview_end_time = current_time + interview_duration - - # Skip the block if there's an existing interview at the current time - if any(interview.interview_time == current_time for interview in existing_interviews): - current_time += interview_duration - continue - - available_interviewers = get_available_interviewers(block['available_interviewers'], current_time, interview_end_time, interviewer_unavailability) - - # If there are no available interviewers, move to the next time block - if not available_interviewers: - current_time += interview_duration - continue - - # Try to assign interviews to applicants - for application in applications[:]: - applicant = application.user - - if is_applicant_available(applicant, current_time, interview_end_time, position.recruitment): - all_applicants_unavailable = False - interview = Interview.objects.create( - interview_time=current_time, - interview_location=f'Location for {position.name_en}', - room=None, - ) - interview.interviewers.set(available_interviewers) - interview.save() - - application.interview = interview # Assign the interview to the application - application.save() - - # Mark the interviewers as unavailable for this time slot - mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) - - existing_interviews = list(existing_interviews) + [interview] - - interview_count += 1 - applications.remove(application) # Remove the assigned applicant from the list - - # If we're only allocating to the first applicant, return early - if limit_to_first_applicant: - return interview_count - - break # Move to the next time block - - current_time += interview_duration - - # If all applications have been processed, stop early - if not applications: - break - - # Raise errors based on the results of the allocation - if interview_count == 0: - if all_applicants_unavailable: - raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') - raise NoAvailableInterviewersError(f'No available interviewers for any time slot for position: {position.name_en}') - - if applications: - raise InsufficientTimeBlocksError( - f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.' - ) - - return interview_count - - -def get_unavailability(recruitment: Recruitment) -> OccupiedTimeslot: - """ - Retrieves unavailable timeslots for a given recruitment. - - Args: - recruitment: The recruitment for which unavailable timeslots are fetched. - - Returns: - A queryset of OccupiedTimeslot objects ordered by start time. - """ - return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') - - -def calculate_rating(start_dt: datetime, end_dt: datetime, available_interviewers_count: int) -> int: - """ - Calculates a rating for a time block based on the number of available interviewers and block length. - - Args: - start_dt: Start datetime of the block. - end_dt: End datetime of the block. - available_interviewers_count: Number of interviewers available for the block. - - Returns: - An integer rating for the time block. - """ - block_length = (end_dt - start_dt).total_seconds() / 3600 - rating = (available_interviewers_count * 2) + (block_length * 0.5) - return max(0, int(rating)) - - -def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: - """ - Checks if an applicant is available for an interview during a given time range. - - Args: - applicant: The applicant to check availability for. - start_dt: The start datetime of the interview slot. - end_dt: The end datetime of the interview slot. - recruitment: The recruitment to which the applicant has applied. - - Returns: - A boolean indicating whether the applicant is available for the given time range. - """ - existing_interviews = Interview.objects.filter( - applications__user=applicant, applications__recruitment=recruitment, interview_time__lt=end_dt, interview_time__gte=start_dt - ) - return not existing_interviews.exists() - - -def get_available_interviewers(interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: defaultdict[Any, list]) -> list: - """ - Filters interviewers who are available for a specific time slot. - - Args: - interviewers: List of interviewers to check availability for. - start_dt: The start datetime of the interview slot. - end_dt: The end datetime of the interview slot. - interviewer_unavailability: Dictionary of interviewers and their unavailable time slots. - - Returns: - A list of available interviewers. - """ - return [interviewer for interviewer in interviewers if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability)] - - -def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> bool: - """ - Checks if a specific interviewer is available during a given time range. - - Args: - interviewer: The interviewer to check. - start_dt: The start datetime of the interview slot. - end_dt: The end datetime of the interview slot. - unavailability: List of unavailable time slots for the interviewer. - - Returns: - A boolean indicating whether the interviewer is available for the given time range. - """ - return all( - not (unavail_start < end_dt and unavail_end > start_dt) for unavail_start, unavail_end in unavailability.get(interviewer.id, []) - ) # Return True if the interviewer is available - - -def mark_interviewers_unavailable(interviewers: list[Any], start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> None: - """ - Marks a group of interviewers as unavailable during a given time range. - - Args: - interviewers: List of interviewers to mark as unavailable. - start_dt: The start datetime of the interview slot. - end_dt: The end datetime of the interview slot. - unavailability: Dictionary to store the unavailable times for each interviewer. - """ - for interviewer in interviewers: - unavailability[interviewer.id].append((start_dt, end_dt)) diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py new file mode 100644 index 000000000..ecf58c7d4 --- /dev/null +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from typing import TypedDict +from datetime import time, datetime, timedelta + +from django.utils import timezone + +from samfundet.models.general import User +from samfundet.models.recruitment import ( + Interview, + Recruitment, + OccupiedTimeslot, + RecruitmentPosition, +) + +""" +Automatic interview allocation currently works by creating interview timeblocks. +These time blocks are made by cutting days into sections, where sections are differentiated +the available interviewer count. +Availability is found by deriving it from unavailability. +In a case where occupied time slots can be set from 08:00 - 16:00, these are registred available interviewers +(I) If 4 interviewers are available from 08:00 - 12:00, +(II) 3 interviewers are available from 12:00 - 13:00, +(III) 3 interviewersa are available from 13:00 - 14:00, +(IV) and 5 interviewers are available from 14:00 - 16:00 + +(I) is one timeblock, (II) and (III) is combined into a second timeblock, and (IV) is a third timeblock. +E.i. timeblocks are differentiated by the amount of available interviewers. + +Interview timeblocks are given a rating, based on length of the timeblocks and the count of available interviewers. +""" + +# TODO: implement strategy for allocation interviews based on shared interviews (UKA, ISFiT, KSG) +# TODO: implement room allocation, based on rooms available at the time the interview has been set + + +class InterviewBlock(TypedDict): + start: datetime + end: datetime + available_interviewers: set[User] + recruitment_position: RecruitmentPosition + date: datetime.date + rating: float + + +def create_daily_interview_blocks(position: RecruitmentPosition) -> list[InterviewBlock]: + """ + Generates time blocks for interviews based on the recruitment's time range, + the availability of interviewers, and their unavailability. The blocks are divided + into 30-minute intervals for each day between the start and end dates. + + Args: + position: Recruitment position for which interview time blocks are generated. + + Returns: + List of time blocks with available interviewers, start and end times, and ratings. + """ + recruitment = position.recruitment + + # Determine the time range: current data, start and end dates for interview slots + current_date = timezone.now().date() + start_date = max(recruitment.visible_from.date(), current_date) + end_date = recruitment.actual_application_deadline.date() + start_time = time(8, 0) # Interviews start at 8:00 + end_time = time(23, 0) # Interviews end at 23:00 + interval = timedelta(minutes=30) # Each time block is 30 minutes + + all_blocks: list[InterviewBlock] = [] + + # Loop through each day in the range to generate time blocks + current_date = start_date + while current_date <= end_date: + # Create datetime objects for the start and end of the day + current_datetime = timezone.make_aware(datetime.combine(current_date, start_time)) + end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) + + # Fetch unavailability slots and generate blocks for the current day + unavailability = get_unavailability(recruitment) + blocks = generate_position_interview_schedule(position, current_datetime, end_datetime, unavailability, interval) + + # Use list comprehension to create and add InterviewBlock objects + all_blocks.extend( + [ + InterviewBlock( + start=block['start'], + end=block['end'], + available_interviewers=set(block['available_interviewers']), + recruitment_position=position, + date=current_date, + rating=calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), + ) + for block in blocks + ] + ) + + current_date += timedelta(days=1) + + return all_blocks + + +class InterviewTimeBlock(TypedDict): + start: datetime + end: datetime + available_interviewers: set[User] + + +def generate_position_interview_schedule( + position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta +) -> list: + """ + Generates time blocks within a given time range, accounting for interviewer unavailability. + + Args: + position: Recruitment position. + start_dt: Start datetime of the interview range. + end_dt: End datetime of the interview range. + unavailability: List of unavailable timeslots for interviewers. + interval: Time interval for each block (30 minutes). + + Returns: + A list of time blocks with available interviewers for each block. + """ + all_interviewers = set(position.interviewers.all()) + blocks: list[InterviewTimeBlock] = [] + current_dt = start_dt + + while current_dt < end_dt: + block_end = min(current_dt + interval, end_dt) + available_interviewers = get_available_interviewers_for_block(all_interviewers, current_dt, block_end, unavailability) + + if available_interviewers: + update_or_create_block(blocks, current_dt, block_end, available_interviewers) + + current_dt = block_end + + return blocks + + +def get_available_interviewers_for_block(all_interviewers: set, start: datetime, end: datetime, unavailability: OccupiedTimeslot) -> set: + """ + Determines which interviewers are available for a given time block. + + Args: + all_interviewers: Set of all interviewers. + start: Start time of the block. + end: End time of the block. + unavailability: List of unavailable timeslots. + + Returns: + Set of available interviewers for the block. + """ + available_interviewers = all_interviewers.copy() + for slot in unavailability: + if slot.start_dt < end and slot.end_dt > start: + available_interviewers.discard(slot.user) + return available_interviewers + + +def update_or_create_block(blocks: list, start: datetime, end: datetime, available_interviewers: set) -> None: + """ + Updates an existing block or creates a new one based on available interviewers. + + Args: + blocks: List of existing blocks. + start: Start time of the current block. + end: End time of the current block. + available_interviewers: Set of available interviewers for the current block. + """ + if not blocks or len(blocks[-1]['available_interviewers']) != len(available_interviewers): + blocks.append({'start': start, 'end': end, 'available_interviewers': available_interviewers}) + else: + blocks[-1]['end'] = end + + +def get_unavailability(recruitment: Recruitment) -> OccupiedTimeslot: + """ + Retrieves unavailable timeslots for a given recruitment. + + Args: + recruitment: The recruitment for which unavailable timeslots are fetched. + + Returns: + A queryset of OccupiedTimeslot objects ordered by start time. + """ + return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') + + +def calculate_rating(start_dt: datetime, end_dt: datetime, available_interviewers_count: int) -> int: + """ + Calculates a rating for a time block based on the number of available interviewers and block length. + + Args: + start_dt: Start datetime of the block. + end_dt: End datetime of the block. + available_interviewers_count: Number of interviewers available for the block. + + Returns: + An integer rating for the time block. + """ + block_length = (end_dt - start_dt).total_seconds() / 3600 + rating = (available_interviewers_count * 2) + (block_length * 0.5) + return max(0, int(rating)) + + +def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: + """ + Checks if an applicant is available for an interview during a given time range. + + Args: + applicant: The applicant to check availability for. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + recruitment: The recruitment to which the applicant has applied. + + Returns: + A boolean indicating whether the applicant is available for the given time range. + """ + existing_interviews = Interview.objects.filter( + applications__user=applicant, applications__recruitment=recruitment, interview_time__lt=end_dt, interview_time__gte=start_dt + ) + return not existing_interviews.exists() diff --git a/backend/samfundet/automatic_interview_allocation/utils.py b/backend/samfundet/automatic_interview_allocation/utils.py new file mode 100644 index 000000000..7c3fa66d2 --- /dev/null +++ b/backend/samfundet/automatic_interview_allocation/utils.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Any +from datetime import datetime +from collections import defaultdict + +from samfundet.models.general import User + + +def get_available_interviewers(interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: defaultdict[Any, list]) -> list: + """ + Filters interviewers who are available for a specific time slot. + + Args: + interviewers: List of interviewers to check availability for. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + interviewer_unavailability: Dictionary of interviewers and their unavailable time slots. + + Returns: + A list of available interviewers. + """ + return [interviewer for interviewer in interviewers if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability)] + + +def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> bool: + """ + Checks if a specific interviewer is available during a given time range. + + Args: + interviewer: The interviewer to check. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + unavailability: List of unavailable time slots for the interviewer. + + Returns: + A boolean indicating whether the interviewer is available for the given time range. + """ + return all( + not (unavail_start < end_dt and unavail_end > start_dt) for unavail_start, unavail_end in unavailability.get(interviewer.id, []) + ) # Return True if the interviewer is available + + +def mark_interviewers_unavailable(interviewers: list[Any], start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> None: + """ + Marks a group of interviewers as unavailable during a given time range. + + Args: + interviewers: List of interviewers to mark as unavailable. + start_dt: The start datetime of the interview slot. + end_dt: The end datetime of the interview slot. + unavailability: Dictionary to store the unavailable times for each interviewer. + """ + for interviewer in interviewers: + unavailability[interviewer.id].append((start_dt, end_dt)) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 33beb236c..ec370ba9e 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -38,7 +38,7 @@ REQUESTED_IMPERSONATE_USER, ) -from samfundet.automatic_interview_allocation.automatic_interview_allocation import generate_interview_timeblocks, allocate_interviews_for_position +from samfundet.automatic_interview_allocation.generate_position_interview_schedule import create_daily_interview_blocks, allocate_interviews_for_position from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage @@ -1333,7 +1333,7 @@ def post(self, request: Request, pk) -> Response: try: position = get_object_or_404(RecruitmentPosition, id=pk) # Generate interview timeblocks - timeblocks = generate_interview_timeblocks(position) + timeblocks = create_daily_interview_blocks(position) # Process timeblocks for response processed_timeblocks = [] From db464987d9165d4a3acc3477ca17df879952f533 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 07:28:54 +0200 Subject: [PATCH 25/44] fixed imports --- backend/samfundet/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index ec370ba9e..0aa81ef02 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -38,7 +38,8 @@ REQUESTED_IMPERSONATE_USER, ) -from samfundet.automatic_interview_allocation.generate_position_interview_schedule import create_daily_interview_blocks, allocate_interviews_for_position +from samfundet.automatic_interview_allocation.allocate_interviews_for_position import allocate_interviews_for_position +from samfundet.automatic_interview_allocation.generate_position_interview_schedule import create_daily_interview_blocks from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage From 18a886ea155e6323378622609aaa63a94e2cc2c4 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 18 Oct 2024 07:41:07 +0200 Subject: [PATCH 26/44] adds default params to generate_position_interview_schedule --- .../generate_position_interview_schedule.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py index ecf58c7d4..691790112 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -43,7 +43,9 @@ class InterviewBlock(TypedDict): rating: float -def create_daily_interview_blocks(position: RecruitmentPosition) -> list[InterviewBlock]: +def create_daily_interview_blocks( + position: RecruitmentPosition, start_time: time = time(8, 0), end_time: time = time(23, 0), interval: timedelta = timedelta(minutes=30) +) -> list[InterviewBlock]: """ Generates time blocks for interviews based on the recruitment's time range, the availability of interviewers, and their unavailability. The blocks are divided @@ -61,9 +63,6 @@ def create_daily_interview_blocks(position: RecruitmentPosition) -> list[Intervi current_date = timezone.now().date() start_date = max(recruitment.visible_from.date(), current_date) end_date = recruitment.actual_application_deadline.date() - start_time = time(8, 0) # Interviews start at 8:00 - end_time = time(23, 0) # Interviews end at 23:00 - interval = timedelta(minutes=30) # Each time block is 30 minutes all_blocks: list[InterviewBlock] = [] From 5d2a226225ae8c1395d0d296f463f278e19d400e Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 04:31:21 +0200 Subject: [PATCH 27/44] completes adding types --- .../allocate_interviews_for_position.py | 79 ++++++++++++------- .../generate_position_interview_schedule.py | 4 +- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index 4ee29c117..176f20926 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -1,9 +1,12 @@ from __future__ import annotations -from datetime import timezone, timedelta +from datetime import datetime, timedelta from collections import defaultdict -from samfundet.models.recruitment import Interview, RecruitmentApplication +from django.utils import timezone + +from samfundet.models.general import User +from samfundet.models.recruitment import Interview, RecruitmentPosition, RecruitmentApplication from samfundet.automatic_interview_allocation.exceptions import ( NoFutureTimeSlotsError, NoTimeBlocksAvailableError, @@ -12,6 +15,7 @@ NoApplicationsWithoutInterviewsError, ) from samfundet.automatic_interview_allocation.generate_position_interview_schedule import ( + InterviewBlock, is_applicant_available, create_daily_interview_blocks, ) @@ -22,14 +26,13 @@ ) -def allocate_interviews_for_position(position, limit_to_first_applicant=False) -> int: +def allocate_interviews_for_position(position: RecruitmentPosition, *, allocation_limit: int | None = None) -> int: """ Allocates interviews for applicants of a given recruitment position based on available time blocks. Args: position: The recruitment position for which interviews are being allocated. - limit_to_first_applicant: If True, the function will return after assigning an interview - to the first available applicant. + allocation_limit: If set, limits the number of interviews to allocate. If None, allocates for all applicants. Returns: The number of interviews allocated. @@ -43,36 +46,32 @@ def allocate_interviews_for_position(position, limit_to_first_applicant=False) - """ interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes - # Generate and sort time blocks by rating (higher rating first) timeblocks = get_sorted_timeblocks(position) - - # Fetch all applications without assigned interviews applications = get_applications_without_interviews(position) - interviewer_unavailability = get_interviewer_unavailability(position) validate_allocation_prerequisites(timeblocks, applications, position) - interview_count = allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, limit_to_first_applicant) + interview_count = allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, allocation_limit) handle_allocation_results(interview_count, applications, position) return interview_count -def get_sorted_timeblocks(position): +def get_sorted_timeblocks(position: RecruitmentPosition) -> list[InterviewBlock]: """Generate and sort time blocks by rating (higher rating first).""" timeblocks = create_daily_interview_blocks(position) timeblocks.sort(key=lambda block: (-block['rating'], block['start'])) return timeblocks -def get_applications_without_interviews(position): +def get_applications_without_interviews(position: RecruitmentPosition) -> list[RecruitmentApplication]: """Fetch all applications without assigned interviews.""" return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) -def get_interviewer_unavailability(position): +def get_interviewer_unavailability(position: RecruitmentPosition) -> defaultdict[int, list[tuple[datetime, datetime]]]: """Get all existing interviews and mark interviewer unavailability.""" interviewer_unavailability = defaultdict(list) existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) @@ -82,7 +81,7 @@ def get_interviewer_unavailability(position): return interviewer_unavailability -def validate_allocation_prerequisites(timeblocks, applications, position): +def validate_allocation_prerequisites(timeblocks: list[InterviewBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: """Validate that there are available time blocks and applications.""" if not timeblocks: raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') @@ -90,7 +89,14 @@ def validate_allocation_prerequisites(timeblocks, applications, position): raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') -def allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, limit_to_first_applicant): +def allocate_interviews( + timeblocks: list[InterviewBlock], + applications: list[RecruitmentApplication], + interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], + position: RecruitmentPosition, + interview_duration: timedelta, + allocation_limit: int | None, +) -> int: """Allocate interviews within available future time blocks.""" interview_count = 0 current_time = timezone.now() + timedelta(hours=24) # Only consider time slots 24 hours or more in the future @@ -100,18 +106,28 @@ def allocate_interviews(timeblocks, applications, interviewer_unavailability, po raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') for block in future_blocks: - interview_count += allocate_interviews_in_block( - block, applications, interviewer_unavailability, position, interview_duration, current_time, limit_to_first_applicant - ) - if limit_to_first_applicant and interview_count > 0: + block_interview_count = allocate_interviews_in_block(block, applications, interviewer_unavailability, position, interview_duration, current_time) + interview_count += block_interview_count + + if allocation_limit is not None and interview_count >= allocation_limit: + # If we've reached or exceeded the allocation limit, stop allocating + interview_count = min(interview_count, allocation_limit) break + if not applications: break return interview_count -def allocate_interviews_in_block(block, applications, interviewer_unavailability, position, interview_duration, current_time, limit_to_first_applicant): +def allocate_interviews_in_block( + block: InterviewBlock, + applications: list[RecruitmentApplication], + interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], + position: RecruitmentPosition, + interview_duration: timedelta, + current_time: datetime, +) -> int: """Allocate interviews within a single time block.""" block_interview_count = 0 block_start = max(block['start'], current_time) @@ -120,29 +136,35 @@ def allocate_interviews_in_block(block, applications, interviewer_unavailability while current_time + interview_duration <= block['end'] and applications: if allocate_single_interview(current_time, block, applications, interviewer_unavailability, position, interview_duration): block_interview_count += 1 - if limit_to_first_applicant: - return block_interview_count current_time += interview_duration return block_interview_count -def allocate_single_interview(current_time, block, applications, interviewer_unavailability, position, interview_duration): +def allocate_single_interview( + current_time: datetime, + block: InterviewBlock, + applications: list[RecruitmentApplication], + interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], + position: RecruitmentPosition, + interview_duration: timedelta, +) -> bool: """Attempt to allocate a single interview at the current time.""" interview_end_time = current_time + interview_duration - # Skip the block if there's an existing interview at the current time if any( interview.interview_time == current_time for interview in Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) ): return False - available_interviewers = get_available_interviewers(block['available_interviewers'], current_time, interview_end_time, interviewer_unavailability) + # Explicitly convert to list + available_interviewers = get_available_interviewers( + list(block.get('available_interviewers', [])), current_time, interview_end_time, interviewer_unavailability + ) # If there are no available interviewers, move to the next time block if not available_interviewers: return False - # Try to assign interviews to applicants for application in applications[:]: applicant = application.user @@ -151,11 +173,10 @@ def allocate_single_interview(current_time, block, applications, interviewer_una mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) applications.remove(application) # Remove the assigned applicant from the list return True - return False -def create_interview(application, interview_time, position, available_interviewers): +def create_interview(application: RecruitmentApplication, interview_time: datetime, position: RecruitmentPosition, available_interviewers: list[User]) -> None: """Create and save an interview for the given application.""" interview = Interview.objects.create( interview_time=interview_time, @@ -168,7 +189,7 @@ def create_interview(application, interview_time, position, available_interviewe application.save() -def handle_allocation_results(interview_count, applications, position): +def handle_allocation_results(interview_count: int, applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: """Handle the results of the interview allocation process.""" if interview_count == 0: raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py index 691790112..df0ab202f 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TypedDict -from datetime import time, datetime, timedelta +from datetime import date, time, datetime, timedelta from django.utils import timezone @@ -39,7 +39,7 @@ class InterviewBlock(TypedDict): end: datetime available_interviewers: set[User] recruitment_position: RecruitmentPosition - date: datetime.date + date: date rating: float From 642c9940d39ce6b6e884e92deff4d676087a6481 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 04:38:18 +0200 Subject: [PATCH 28/44] =?UTF-8?q?=F0=9F=A6=AE=F0=9F=A6=AE=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/root/management/commands/seed_scripts/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/root/management/commands/seed_scripts/__init__.py b/backend/root/management/commands/seed_scripts/__init__.py index 249f9f71d..820e79bc5 100755 --- a/backend/root/management/commands/seed_scripts/__init__.py +++ b/backend/root/management/commands/seed_scripts/__init__.py @@ -21,7 +21,6 @@ recruitment_position, recruitment_applications, recruitment_occupied_time, - recruitment_occupied_time, recruitment_separate_position, recruitment_interviewavailability, recruitment_position_interviewers, From ceb4e5a62f6c2d5a87138afa79ac1920b778e805 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 05:01:00 +0200 Subject: [PATCH 29/44] Improved doc string for InterviewBlock --- .../generate_position_interview_schedule.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py index df0ab202f..f31fb3744 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -13,28 +13,34 @@ RecruitmentPosition, ) -""" -Automatic interview allocation currently works by creating interview timeblocks. -These time blocks are made by cutting days into sections, where sections are differentiated -the available interviewer count. -Availability is found by deriving it from unavailability. -In a case where occupied time slots can be set from 08:00 - 16:00, these are registred available interviewers -(I) If 4 interviewers are available from 08:00 - 12:00, -(II) 3 interviewers are available from 12:00 - 13:00, -(III) 3 interviewersa are available from 13:00 - 14:00, -(IV) and 5 interviewers are available from 14:00 - 16:00 - -(I) is one timeblock, (II) and (III) is combined into a second timeblock, and (IV) is a third timeblock. -E.i. timeblocks are differentiated by the amount of available interviewers. - -Interview timeblocks are given a rating, based on length of the timeblocks and the count of available interviewers. -""" - # TODO: implement strategy for allocation interviews based on shared interviews (UKA, ISFiT, KSG) # TODO: implement room allocation, based on rooms available at the time the interview has been set class InterviewBlock(TypedDict): + """ + Represents a time block for interviews during the recruitment process. + + Interview blocks are created by dividing days into sections based on interviewer availability. + Each block is characterized by a unique combination of available interviewers. + + For example, given interviewer availability from 08:00 to 16:00: + 1. 08:00 - 12:00: 4 interviewers available (Block I) + 2. 12:00 - 14:00: 3 interviewers available (Block II) + 3. 14:00 - 16:00: 5 interviewers available (Block III) + + Blocks are rated based on their duration and the number of available interviewers, + which helps prioritize optimal interview slots during the allocation process. + + Attributes: + start (datetime): Start time of the block + end (datetime): End time of the block + available_interviewers (set[User]): Set of available interviewers for this block + recruitment_position (RecruitmentPosition): The position being recruited for + date (date): The date of the interview block + rating (float): A calculated rating based on block duration and interviewer availability + """ + start: datetime end: datetime available_interviewers: set[User] From a08f179a14a5a633794bfdf74da0b02098710ad8 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 06:40:24 +0200 Subject: [PATCH 30/44] adds consideration for shared interviews in block rating --- .../generate_position_interview_schedule.py | 44 ++++++++++++++++--- backend/samfundet/models/recruitment.py | 12 +++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py index f31fb3744..59fd749db 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -92,7 +92,12 @@ def create_daily_interview_blocks( available_interviewers=set(block['available_interviewers']), recruitment_position=position, date=current_date, - rating=calculate_rating(block['start'], block['end'], len(block['available_interviewers'])), + rating=calculate_rating( + block['start'], + block['end'], + set(block['available_interviewers']), + position, + ), ) for block in blocks ] @@ -190,20 +195,47 @@ def get_unavailability(recruitment: Recruitment) -> OccupiedTimeslot: return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') -def calculate_rating(start_dt: datetime, end_dt: datetime, available_interviewers_count: int) -> int: +def calculate_rating(start_dt: datetime, end_dt: datetime, available_interviewers: set[User], position: RecruitmentPosition) -> int: """ - Calculates a rating for a time block based on the number of available interviewers and block length. + Calculates a rating for a time block based on interviewer availability, block length, and section diversity. + + For shared interviews (multiple sections), the rating considers: + 1. The number of available interviewers + 2. The length of the time block + 3. The diversity of sections represented by available interviewers + 4. The average number of available interviewers per section + + For non-shared interviews or single-section positions, only the number of + available interviewers and block length are considered. Args: start_dt: Start datetime of the block. end_dt: End datetime of the block. - available_interviewers_count: Number of interviewers available for the block. + available_interviewers: Set of interviewers available for the block. + position: The RecruitmentPosition for which the rating is calculated. Returns: - An integer rating for the time block. + An integer rating for the time block. Higher values indicate more + favorable blocks for scheduling interviews. """ + block_length = (end_dt - start_dt).total_seconds() / 3600 - rating = (available_interviewers_count * 2) + (block_length * 0.5) + interviewers_grouped_by_section = position.get_interviewers_grouped_by_section() + + if len(interviewers_grouped_by_section) > 1: + represented_sections = sum(1 for section_interviewers in interviewers_grouped_by_section.values() if set(section_interviewers) & available_interviewers) + section_diversity_factor = represented_sections / len(interviewers_grouped_by_section) + + # Calculate the average number of interviewers available per section + avg_interviewers_per_section = sum( + len(set(section_interviewers) & available_interviewers) for section_interviewers in interviewers_grouped_by_section.values() + ) / len(interviewers_grouped_by_section) + + rating = (len(available_interviewers) * 2) + (block_length * 0.5) + (section_diversity_factor * 5) + (avg_interviewers_per_section * 3) + else: + # For non-shared interviews or single section, use the original rating calculation + rating = (len(available_interviewers) * 2) + (block_length * 0.5) + return max(0, int(rating)) diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 2df6249d9..b2dfd5330 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -8,6 +8,7 @@ from django.db import models, transaction from django.utils import timezone +from django.db.models import QuerySet from django.core.exceptions import ValidationError from root.utils.mixins import CustomBaseModel, FullCleanSaveMixin @@ -163,6 +164,17 @@ class RecruitmentPosition(CustomBaseModel): # TODO: Implement interviewer functionality interviewers = models.ManyToManyField(to=User, help_text='Interviewers for the position', blank=True, related_name='interviewers') + def get_interviewers_grouped_by_section(self) -> dict[GangSection, QuerySet[User]]: + interviewers_by_section = defaultdict(set) + + positions = self.shared_interview_group.positions.all() if self.shared_interview_group else [self] + + for position in positions: + if position.section: + interviewers_by_section[position.section].update(position.interviewers.all()) + + return {section: User.objects.filter(id__in=[u.id for u in interviewers]) for section, interviewers in interviewers_by_section.items()} + def resolve_section(self, *, return_id: bool = False) -> GangSection | int: if return_id: # noinspection PyTypeChecker From 8f227abecf4b504200c2ea4bcb9bae3eb6db662c Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 06:40:40 +0200 Subject: [PATCH 31/44] improved typing in util functions --- .../automatic_interview_allocation/utils.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/utils.py b/backend/samfundet/automatic_interview_allocation/utils.py index 7c3fa66d2..d656063f8 100644 --- a/backend/samfundet/automatic_interview_allocation/utils.py +++ b/backend/samfundet/automatic_interview_allocation/utils.py @@ -1,50 +1,48 @@ from __future__ import annotations -from typing import Any from datetime import datetime -from collections import defaultdict from samfundet.models.general import User +UserIdType = int +TimeSlotType = tuple[datetime, datetime] -def get_available_interviewers(interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: defaultdict[Any, list]) -> list: +UnavailabilityTypeDict = dict[UserIdType, list[TimeSlotType]] + + +def get_available_interviewers( + interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: UnavailabilityTypeDict +) -> list[User]: """ Filters interviewers who are available for a specific time slot. - Args: interviewers: List of interviewers to check availability for. start_dt: The start datetime of the interview slot. end_dt: The end datetime of the interview slot. interviewer_unavailability: Dictionary of interviewers and their unavailable time slots. - Returns: A list of available interviewers. """ return [interviewer for interviewer in interviewers if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability)] -def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> bool: +def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, unavailability: UnavailabilityTypeDict) -> bool: """ Checks if a specific interviewer is available during a given time range. - Args: interviewer: The interviewer to check. start_dt: The start datetime of the interview slot. end_dt: The end datetime of the interview slot. - unavailability: List of unavailable time slots for the interviewer. - + unavailability: Dictionary of unavailable time slots for interviewers. Returns: A boolean indicating whether the interviewer is available for the given time range. """ - return all( - not (unavail_start < end_dt and unavail_end > start_dt) for unavail_start, unavail_end in unavailability.get(interviewer.id, []) - ) # Return True if the interviewer is available + return all(not (unavail_start < end_dt and unavail_end > start_dt) for unavail_start, unavail_end in unavailability.get(interviewer.id, [])) -def mark_interviewers_unavailable(interviewers: list[Any], start_dt: datetime, end_dt: datetime, unavailability: defaultdict[Any, list]) -> None: +def mark_interviewers_unavailable(interviewers: list[User], start_dt: datetime, end_dt: datetime, unavailability: UnavailabilityTypeDict) -> None: """ Marks a group of interviewers as unavailable during a given time range. - Args: interviewers: List of interviewers to mark as unavailable. start_dt: The start datetime of the interview slot. @@ -52,4 +50,6 @@ def mark_interviewers_unavailable(interviewers: list[Any], start_dt: datetime, e unavailability: Dictionary to store the unavailable times for each interviewer. """ for interviewer in interviewers: + if interviewer.id not in unavailability: + unavailability[interviewer.id] = [] unavailability[interviewer.id].append((start_dt, end_dt)) From d115008f60703f98b215ddfd66717d4180cb733b Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 06:52:48 +0200 Subject: [PATCH 32/44] improved doc strings and typing --- .../allocate_interviews_for_position.py | 12 ++-- .../generate_position_interview_schedule.py | 56 ++++++++++++------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index 176f20926..a946e7af5 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -15,7 +15,7 @@ NoApplicationsWithoutInterviewsError, ) from samfundet.automatic_interview_allocation.generate_position_interview_schedule import ( - InterviewBlock, + FinalizedTimeBlock, is_applicant_available, create_daily_interview_blocks, ) @@ -59,7 +59,7 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio return interview_count -def get_sorted_timeblocks(position: RecruitmentPosition) -> list[InterviewBlock]: +def get_sorted_timeblocks(position: RecruitmentPosition) -> list[FinalizedTimeBlock]: """Generate and sort time blocks by rating (higher rating first).""" timeblocks = create_daily_interview_blocks(position) timeblocks.sort(key=lambda block: (-block['rating'], block['start'])) @@ -81,7 +81,7 @@ def get_interviewer_unavailability(position: RecruitmentPosition) -> defaultdict return interviewer_unavailability -def validate_allocation_prerequisites(timeblocks: list[InterviewBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: +def validate_allocation_prerequisites(timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: """Validate that there are available time blocks and applications.""" if not timeblocks: raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') @@ -90,7 +90,7 @@ def validate_allocation_prerequisites(timeblocks: list[InterviewBlock], applicat def allocate_interviews( - timeblocks: list[InterviewBlock], + timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, @@ -121,7 +121,7 @@ def allocate_interviews( def allocate_interviews_in_block( - block: InterviewBlock, + block: FinalizedTimeBlock, applications: list[RecruitmentApplication], interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, @@ -143,7 +143,7 @@ def allocate_interviews_in_block( def allocate_single_interview( current_time: datetime, - block: InterviewBlock, + block: FinalizedTimeBlock, applications: list[RecruitmentApplication], interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py index 59fd749db..3aad34220 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -13,32 +13,34 @@ RecruitmentPosition, ) -# TODO: implement strategy for allocation interviews based on shared interviews (UKA, ISFiT, KSG) +# TODO: optimize block rating and interview allocation strategy # TODO: implement room allocation, based on rooms available at the time the interview has been set -class InterviewBlock(TypedDict): +class FinalizedTimeBlock(TypedDict): """ - Represents a time block for interviews during the recruitment process. + Represents a finalized time block for interviews during the recruitment process. - Interview blocks are created by dividing days into sections based on interviewer availability. - Each block is characterized by a unique combination of available interviewers. + InterviewBlocks are created by processing InterviewTimeBlocks and adding recruitment-specific + context and prioritization. These blocks are used for scheduling across multiple days. + + Each block is characterized by a unique combination of available interviewers and is + associated with a specific recruitment position. Blocks are rated to help prioritize + optimal interview slots during the allocation process. For example, given interviewer availability from 08:00 to 16:00: 1. 08:00 - 12:00: 4 interviewers available (Block I) 2. 12:00 - 14:00: 3 interviewers available (Block II) 3. 14:00 - 16:00: 5 interviewers available (Block III) - Blocks are rated based on their duration and the number of available interviewers, - which helps prioritize optimal interview slots during the allocation process. - Attributes: start (datetime): Start time of the block end (datetime): End time of the block available_interviewers (set[User]): Set of available interviewers for this block recruitment_position (RecruitmentPosition): The position being recruited for date (date): The date of the interview block - rating (float): A calculated rating based on block duration and interviewer availability + rating (float): A calculated rating based on block duration, interviewer availability, + and other recruitment-specific factors """ start: datetime @@ -49,9 +51,31 @@ class InterviewBlock(TypedDict): rating: float +class IntermediateTimeBlock(TypedDict): + """ + Represents an intermediate time block used for calculations within a single day. + + InterviewTimeBlocks are created during the initial phase of interview scheduling, + focusing on interviewer availability within a specific day. These blocks are later + processed to create finalized InterviewBlocks. + + InterviewTimeBlocks are simpler than InterviewBlocks, containing only the essential + time and availability information without recruitment-specific context or ratings. + + Attributes: + start (datetime): Start time of the block + end (datetime): End time of the block + available_interviewers (set[User]): Set of available interviewers for this block + """ + + start: datetime + end: datetime + available_interviewers: set[User] + + def create_daily_interview_blocks( position: RecruitmentPosition, start_time: time = time(8, 0), end_time: time = time(23, 0), interval: timedelta = timedelta(minutes=30) -) -> list[InterviewBlock]: +) -> list[FinalizedTimeBlock]: """ Generates time blocks for interviews based on the recruitment's time range, the availability of interviewers, and their unavailability. The blocks are divided @@ -70,7 +94,7 @@ def create_daily_interview_blocks( start_date = max(recruitment.visible_from.date(), current_date) end_date = recruitment.actual_application_deadline.date() - all_blocks: list[InterviewBlock] = [] + all_blocks: list[FinalizedTimeBlock] = [] # Loop through each day in the range to generate time blocks current_date = start_date @@ -86,7 +110,7 @@ def create_daily_interview_blocks( # Use list comprehension to create and add InterviewBlock objects all_blocks.extend( [ - InterviewBlock( + FinalizedTimeBlock( start=block['start'], end=block['end'], available_interviewers=set(block['available_interviewers']), @@ -108,12 +132,6 @@ def create_daily_interview_blocks( return all_blocks -class InterviewTimeBlock(TypedDict): - start: datetime - end: datetime - available_interviewers: set[User] - - def generate_position_interview_schedule( position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta ) -> list: @@ -131,7 +149,7 @@ def generate_position_interview_schedule( A list of time blocks with available interviewers for each block. """ all_interviewers = set(position.interviewers.all()) - blocks: list[InterviewTimeBlock] = [] + blocks: list[IntermediateTimeBlock] = [] current_dt = start_dt while current_dt < end_dt: From 5305afbc244d303f73cdb96b9c199ab9263c9926 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 07:21:49 +0200 Subject: [PATCH 33/44] refactor naming of functions --- .../allocate_interviews_for_position.py | 26 +++++++++-------- .../generate_position_interview_schedule.py | 28 +++++++++---------- .../automatic_interview_allocation/utils.py | 2 +- backend/samfundet/views.py | 4 +-- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index a946e7af5..4befbd565 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -17,12 +17,12 @@ from samfundet.automatic_interview_allocation.generate_position_interview_schedule import ( FinalizedTimeBlock, is_applicant_available, - create_daily_interview_blocks, + create_final_interview_blocks, ) from .utils import ( - get_available_interviewers, mark_interviewers_unavailable, + get_available_interviewers_for_timeslot, ) @@ -46,22 +46,22 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio """ interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes - timeblocks = get_sorted_timeblocks(position) + timeblocks = generate_and_sort_timeblocks(position) applications = get_applications_without_interviews(position) - interviewer_unavailability = get_interviewer_unavailability(position) + interviewer_unavailability = create_interviewer_unavailability_map(position) - validate_allocation_prerequisites(timeblocks, applications, position) + check_timeblocks_and_applications_availability(timeblocks, applications, position) interview_count = allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, allocation_limit) - handle_allocation_results(interview_count, applications, position) + check_allocation_completeness(interview_count, applications, position) return interview_count -def get_sorted_timeblocks(position: RecruitmentPosition) -> list[FinalizedTimeBlock]: +def generate_and_sort_timeblocks(position: RecruitmentPosition) -> list[FinalizedTimeBlock]: """Generate and sort time blocks by rating (higher rating first).""" - timeblocks = create_daily_interview_blocks(position) + timeblocks = create_final_interview_blocks(position) timeblocks.sort(key=lambda block: (-block['rating'], block['start'])) return timeblocks @@ -71,7 +71,7 @@ def get_applications_without_interviews(position: RecruitmentPosition) -> list[R return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) -def get_interviewer_unavailability(position: RecruitmentPosition) -> defaultdict[int, list[tuple[datetime, datetime]]]: +def create_interviewer_unavailability_map(position: RecruitmentPosition) -> defaultdict[int, list[tuple[datetime, datetime]]]: """Get all existing interviews and mark interviewer unavailability.""" interviewer_unavailability = defaultdict(list) existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) @@ -81,7 +81,9 @@ def get_interviewer_unavailability(position: RecruitmentPosition) -> defaultdict return interviewer_unavailability -def validate_allocation_prerequisites(timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: +def check_timeblocks_and_applications_availability( + timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition +) -> None: """Validate that there are available time blocks and applications.""" if not timeblocks: raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') @@ -158,7 +160,7 @@ def allocate_single_interview( return False # Explicitly convert to list - available_interviewers = get_available_interviewers( + available_interviewers = get_available_interviewers_for_timeslot( list(block.get('available_interviewers', [])), current_time, interview_end_time, interviewer_unavailability ) @@ -189,7 +191,7 @@ def create_interview(application: RecruitmentApplication, interview_time: dateti application.save() -def handle_allocation_results(interview_count: int, applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: +def check_allocation_completeness(interview_count: int, applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: """Handle the results of the interview allocation process.""" if interview_count == 0: raise AllApplicantsUnavailableError(f'All applicants are unavailable for the remaining time slots for position: {position.name_en}') diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py index 3aad34220..7a3e10c02 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py @@ -73,7 +73,7 @@ class IntermediateTimeBlock(TypedDict): available_interviewers: set[User] -def create_daily_interview_blocks( +def create_final_interview_blocks( position: RecruitmentPosition, start_time: time = time(8, 0), end_time: time = time(23, 0), interval: timedelta = timedelta(minutes=30) ) -> list[FinalizedTimeBlock]: """ @@ -104,8 +104,8 @@ def create_daily_interview_blocks( end_datetime = timezone.make_aware(datetime.combine(current_date, end_time)) # Fetch unavailability slots and generate blocks for the current day - unavailability = get_unavailability(recruitment) - blocks = generate_position_interview_schedule(position, current_datetime, end_datetime, unavailability, interval) + unavailability = get_occupied_timeslots(recruitment) + blocks = generate_intermediate_blocks(position, current_datetime, end_datetime, unavailability, interval) # Use list comprehension to create and add InterviewBlock objects all_blocks.extend( @@ -116,7 +116,7 @@ def create_daily_interview_blocks( available_interviewers=set(block['available_interviewers']), recruitment_position=position, date=current_date, - rating=calculate_rating( + rating=calculate_block_rating( block['start'], block['end'], set(block['available_interviewers']), @@ -132,18 +132,18 @@ def create_daily_interview_blocks( return all_blocks -def generate_position_interview_schedule( +def generate_intermediate_blocks( position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta -) -> list: +) -> list[IntermediateTimeBlock]: """ - Generates time blocks within a given time range, accounting for interviewer unavailability. + Generates intermediate time within a given time range, accounting for interviewer unavailability. Args: position: Recruitment position. start_dt: Start datetime of the interview range. end_dt: End datetime of the interview range. unavailability: List of unavailable timeslots for interviewers. - interval: Time interval for each block (30 minutes). + interval: Time interval for each block. Returns: A list of time blocks with available interviewers for each block. @@ -154,17 +154,17 @@ def generate_position_interview_schedule( while current_dt < end_dt: block_end = min(current_dt + interval, end_dt) - available_interviewers = get_available_interviewers_for_block(all_interviewers, current_dt, block_end, unavailability) + available_interviewers = filter_available_interviewers_for_block(all_interviewers, current_dt, block_end, unavailability) if available_interviewers: - update_or_create_block(blocks, current_dt, block_end, available_interviewers) + append_or_extend_last_block(blocks, current_dt, block_end, available_interviewers) current_dt = block_end return blocks -def get_available_interviewers_for_block(all_interviewers: set, start: datetime, end: datetime, unavailability: OccupiedTimeslot) -> set: +def filter_available_interviewers_for_block(all_interviewers: set, start: datetime, end: datetime, unavailability: OccupiedTimeslot) -> set: """ Determines which interviewers are available for a given time block. @@ -184,7 +184,7 @@ def get_available_interviewers_for_block(all_interviewers: set, start: datetime, return available_interviewers -def update_or_create_block(blocks: list, start: datetime, end: datetime, available_interviewers: set) -> None: +def append_or_extend_last_block(blocks: list, start: datetime, end: datetime, available_interviewers: set) -> None: """ Updates an existing block or creates a new one based on available interviewers. @@ -200,7 +200,7 @@ def update_or_create_block(blocks: list, start: datetime, end: datetime, availab blocks[-1]['end'] = end -def get_unavailability(recruitment: Recruitment) -> OccupiedTimeslot: +def get_occupied_timeslots(recruitment: Recruitment) -> OccupiedTimeslot: """ Retrieves unavailable timeslots for a given recruitment. @@ -213,7 +213,7 @@ def get_unavailability(recruitment: Recruitment) -> OccupiedTimeslot: return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') -def calculate_rating(start_dt: datetime, end_dt: datetime, available_interviewers: set[User], position: RecruitmentPosition) -> int: +def calculate_block_rating(start_dt: datetime, end_dt: datetime, available_interviewers: set[User], position: RecruitmentPosition) -> int: """ Calculates a rating for a time block based on interviewer availability, block length, and section diversity. diff --git a/backend/samfundet/automatic_interview_allocation/utils.py b/backend/samfundet/automatic_interview_allocation/utils.py index d656063f8..fbc26eda6 100644 --- a/backend/samfundet/automatic_interview_allocation/utils.py +++ b/backend/samfundet/automatic_interview_allocation/utils.py @@ -10,7 +10,7 @@ UnavailabilityTypeDict = dict[UserIdType, list[TimeSlotType]] -def get_available_interviewers( +def get_available_interviewers_for_timeslot( interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: UnavailabilityTypeDict ) -> list[User]: """ diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 0aa81ef02..2cb8883e4 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -39,7 +39,7 @@ ) from samfundet.automatic_interview_allocation.allocate_interviews_for_position import allocate_interviews_for_position -from samfundet.automatic_interview_allocation.generate_position_interview_schedule import create_daily_interview_blocks +from samfundet.automatic_interview_allocation.generate_position_interview_schedule import create_final_interview_blocks from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage @@ -1334,7 +1334,7 @@ def post(self, request: Request, pk) -> Response: try: position = get_object_or_404(RecruitmentPosition, id=pk) # Generate interview timeblocks - timeblocks = create_daily_interview_blocks(position) + timeblocks = create_final_interview_blocks(position) # Process timeblocks for response processed_timeblocks = [] From 7099f8c2f9094bac34ab9b1450c73ae1d2e44419 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 10:02:31 +0200 Subject: [PATCH 34/44] large refactor, to implement more efficient checks, as well as improved error handling in view. Also renamed some functions to make the code more intuitv --- .../allocate_interviews_for_position.py | 57 +++----- ...le.py => generate_interview_timeblocks.py} | 127 ++++++++---------- .../automatic_interview_allocation/utils.py | 117 ++++++++++++---- backend/samfundet/models/recruitment.py | 12 -- backend/samfundet/views.py | 50 ++----- 5 files changed, 182 insertions(+), 181 deletions(-) rename backend/samfundet/automatic_interview_allocation/{generate_position_interview_schedule.py => generate_interview_timeblocks.py} (88%) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index 4befbd565..c95402888 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -14,14 +14,13 @@ AllApplicantsUnavailableError, NoApplicationsWithoutInterviewsError, ) -from samfundet.automatic_interview_allocation.generate_position_interview_schedule import ( +from samfundet.automatic_interview_allocation.generate_interview_timeblocks import ( FinalizedTimeBlock, - is_applicant_available, - create_final_interview_blocks, + generate_and_sort_timeblocks, ) from .utils import ( - mark_interviewers_unavailable, + is_applicant_available, get_available_interviewers_for_timeslot, ) @@ -49,8 +48,7 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio timeblocks = generate_and_sort_timeblocks(position) applications = get_applications_without_interviews(position) interviewer_unavailability = create_interviewer_unavailability_map(position) - - check_timeblocks_and_applications_availability(timeblocks, applications, position) + check_timeblocks_and_applications(timeblocks, applications, position) interview_count = allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, allocation_limit) @@ -59,13 +57,6 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio return interview_count -def generate_and_sort_timeblocks(position: RecruitmentPosition) -> list[FinalizedTimeBlock]: - """Generate and sort time blocks by rating (higher rating first).""" - timeblocks = create_final_interview_blocks(position) - timeblocks.sort(key=lambda block: (-block['rating'], block['start'])) - return timeblocks - - def get_applications_without_interviews(position: RecruitmentPosition) -> list[RecruitmentApplication]: """Fetch all applications without assigned interviews.""" return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) @@ -81,9 +72,7 @@ def create_interviewer_unavailability_map(position: RecruitmentPosition) -> defa return interviewer_unavailability -def check_timeblocks_and_applications_availability( - timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition -) -> None: +def check_timeblocks_and_applications(timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: """Validate that there are available time blocks and applications.""" if not timeblocks: raise NoTimeBlocksAvailableError(f'No available time blocks for position: {position.name_en}') @@ -108,7 +97,7 @@ def allocate_interviews( raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') for block in future_blocks: - block_interview_count = allocate_interviews_in_block(block, applications, interviewer_unavailability, position, interview_duration, current_time) + block_interview_count = allocate_interviews_in_block(block, applications, position, interview_duration, current_time) interview_count += block_interview_count if allocation_limit is not None and interview_count >= allocation_limit: @@ -125,7 +114,7 @@ def allocate_interviews( def allocate_interviews_in_block( block: FinalizedTimeBlock, applications: list[RecruitmentApplication], - interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], + # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, interview_duration: timedelta, current_time: datetime, @@ -136,7 +125,9 @@ def allocate_interviews_in_block( current_time = block_start while current_time + interview_duration <= block['end'] and applications: - if allocate_single_interview(current_time, block, applications, interviewer_unavailability, position, interview_duration): + application = applications[0] # Get the next application to process + if allocate_single_interview(current_time, block, application, position, interview_duration): + applications.pop(0) # Remove the application that was just allocated block_interview_count += 1 current_time += interview_duration @@ -146,35 +137,29 @@ def allocate_interviews_in_block( def allocate_single_interview( current_time: datetime, block: FinalizedTimeBlock, - applications: list[RecruitmentApplication], - interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], + application: RecruitmentApplication, + # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, interview_duration: timedelta, ) -> bool: """Attempt to allocate a single interview at the current time.""" interview_end_time = current_time + interview_duration - # Skip the block if there's an existing interview at the current time - if any( - interview.interview_time == current_time for interview in Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) - ): + + # Check for existing interviews only once per timeslot + if Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment, interview_time=current_time).exists(): return False - # Explicitly convert to list available_interviewers = get_available_interviewers_for_timeslot( - list(block.get('available_interviewers', [])), current_time, interview_end_time, interviewer_unavailability + list(block['available_interviewers']), current_time, interview_end_time, position.recruitment ) - # If there are no available interviewers, move to the next time block if not available_interviewers: return False - # Try to assign interviews to applicants - for application in applications[:]: - applicant = application.user - if is_applicant_available(applicant, current_time, interview_end_time, position.recruitment): - create_interview(application, current_time, position, available_interviewers) - mark_interviewers_unavailable(available_interviewers, current_time, interview_end_time, interviewer_unavailability) - applications.remove(application) # Remove the assigned applicant from the list - return True + + if is_applicant_available(application.user, current_time, interview_end_time, position.recruitment): + create_interview(application, current_time, position, available_interviewers) + return True + return False diff --git a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py b/backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py similarity index 88% rename from backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py rename to backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py index 7a3e10c02..e140bfd5b 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_position_interview_schedule.py +++ b/backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py @@ -7,11 +7,12 @@ from samfundet.models.general import User from samfundet.models.recruitment import ( - Interview, + # Interview, Recruitment, OccupiedTimeslot, RecruitmentPosition, ) +from samfundet.automatic_interview_allocation.utils import get_interviewers_grouped_by_section # TODO: optimize block rating and interview allocation strategy # TODO: implement room allocation, based on rooms available at the time the interview has been set @@ -73,6 +74,51 @@ class IntermediateTimeBlock(TypedDict): available_interviewers: set[User] +def get_occupied_timeslots(recruitment: Recruitment) -> OccupiedTimeslot: + """ + Retrieves unavailable timeslots for a given recruitment. + + Args: + recruitment: The recruitment for which unavailable timeslots are fetched. + + Returns: + A queryset of OccupiedTimeslot objects ordered by start time. + """ + return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') + + +def generate_intermediate_blocks( + position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta +) -> list[IntermediateTimeBlock]: + """ + Generates intermediate time within a given time range, accounting for interviewer unavailability. + + Args: + position: Recruitment position. + start_dt: Start datetime of the interview range. + end_dt: End datetime of the interview range. + unavailability: List of unavailable timeslots for interviewers. + interval: Time interval for each block. + + Returns: + A list of time blocks with available interviewers for each block. + """ + all_interviewers = set(position.interviewers.all()) + intermediate_blocks: list[IntermediateTimeBlock] = [] + current_dt = start_dt + + while current_dt < end_dt: + block_end = min(current_dt + interval, end_dt) + available_interviewers = filter_available_interviewers_for_block(all_interviewers, current_dt, block_end, unavailability) + + if available_interviewers: + append_or_extend_last_block(intermediate_blocks, current_dt, block_end, available_interviewers) + + current_dt = block_end + + return intermediate_blocks + + def create_final_interview_blocks( position: RecruitmentPosition, start_time: time = time(8, 0), end_time: time = time(23, 0), interval: timedelta = timedelta(minutes=30) ) -> list[FinalizedTimeBlock]: @@ -94,7 +140,7 @@ def create_final_interview_blocks( start_date = max(recruitment.visible_from.date(), current_date) end_date = recruitment.actual_application_deadline.date() - all_blocks: list[FinalizedTimeBlock] = [] + final_blocks: list[FinalizedTimeBlock] = [] # Loop through each day in the range to generate time blocks current_date = start_date @@ -105,10 +151,10 @@ def create_final_interview_blocks( # Fetch unavailability slots and generate blocks for the current day unavailability = get_occupied_timeslots(recruitment) - blocks = generate_intermediate_blocks(position, current_datetime, end_datetime, unavailability, interval) + intermediate_blocks = generate_intermediate_blocks(position, current_datetime, end_datetime, unavailability, interval) # Use list comprehension to create and add InterviewBlock objects - all_blocks.extend( + final_blocks.extend( [ FinalizedTimeBlock( start=block['start'], @@ -123,45 +169,13 @@ def create_final_interview_blocks( position, ), ) - for block in blocks + for block in intermediate_blocks ] ) current_date += timedelta(days=1) - return all_blocks - - -def generate_intermediate_blocks( - position: RecruitmentPosition, start_dt: datetime, end_dt: datetime, unavailability: OccupiedTimeslot, interval: timedelta -) -> list[IntermediateTimeBlock]: - """ - Generates intermediate time within a given time range, accounting for interviewer unavailability. - - Args: - position: Recruitment position. - start_dt: Start datetime of the interview range. - end_dt: End datetime of the interview range. - unavailability: List of unavailable timeslots for interviewers. - interval: Time interval for each block. - - Returns: - A list of time blocks with available interviewers for each block. - """ - all_interviewers = set(position.interviewers.all()) - blocks: list[IntermediateTimeBlock] = [] - current_dt = start_dt - - while current_dt < end_dt: - block_end = min(current_dt + interval, end_dt) - available_interviewers = filter_available_interviewers_for_block(all_interviewers, current_dt, block_end, unavailability) - - if available_interviewers: - append_or_extend_last_block(blocks, current_dt, block_end, available_interviewers) - - current_dt = block_end - - return blocks + return final_blocks def filter_available_interviewers_for_block(all_interviewers: set, start: datetime, end: datetime, unavailability: OccupiedTimeslot) -> set: @@ -200,19 +214,6 @@ def append_or_extend_last_block(blocks: list, start: datetime, end: datetime, av blocks[-1]['end'] = end -def get_occupied_timeslots(recruitment: Recruitment) -> OccupiedTimeslot: - """ - Retrieves unavailable timeslots for a given recruitment. - - Args: - recruitment: The recruitment for which unavailable timeslots are fetched. - - Returns: - A queryset of OccupiedTimeslot objects ordered by start time. - """ - return OccupiedTimeslot.objects.filter(recruitment=recruitment).order_by('start_dt') - - def calculate_block_rating(start_dt: datetime, end_dt: datetime, available_interviewers: set[User], position: RecruitmentPosition) -> int: """ Calculates a rating for a time block based on interviewer availability, block length, and section diversity. @@ -238,7 +239,7 @@ def calculate_block_rating(start_dt: datetime, end_dt: datetime, available_inter """ block_length = (end_dt - start_dt).total_seconds() / 3600 - interviewers_grouped_by_section = position.get_interviewers_grouped_by_section() + interviewers_grouped_by_section = get_interviewers_grouped_by_section(position) if len(interviewers_grouped_by_section) > 1: represented_sections = sum(1 for section_interviewers in interviewers_grouped_by_section.values() if set(section_interviewers) & available_interviewers) @@ -257,20 +258,8 @@ def calculate_block_rating(start_dt: datetime, end_dt: datetime, available_inter return max(0, int(rating)) -def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: - """ - Checks if an applicant is available for an interview during a given time range. - - Args: - applicant: The applicant to check availability for. - start_dt: The start datetime of the interview slot. - end_dt: The end datetime of the interview slot. - recruitment: The recruitment to which the applicant has applied. - - Returns: - A boolean indicating whether the applicant is available for the given time range. - """ - existing_interviews = Interview.objects.filter( - applications__user=applicant, applications__recruitment=recruitment, interview_time__lt=end_dt, interview_time__gte=start_dt - ) - return not existing_interviews.exists() +def generate_and_sort_timeblocks(position: RecruitmentPosition) -> list[FinalizedTimeBlock]: + """Generate and sort time blocks by rating (higher rating first).""" + timeblocks = create_final_interview_blocks(position) + timeblocks.sort(key=lambda block: (-block['rating'], block['start'])) + return timeblocks diff --git a/backend/samfundet/automatic_interview_allocation/utils.py b/backend/samfundet/automatic_interview_allocation/utils.py index fbc26eda6..db8bc5bb9 100644 --- a/backend/samfundet/automatic_interview_allocation/utils.py +++ b/backend/samfundet/automatic_interview_allocation/utils.py @@ -1,8 +1,12 @@ from __future__ import annotations from datetime import datetime +from collections import defaultdict -from samfundet.models.general import User +from django.db.models import Q, QuerySet + +from samfundet.models.general import User, GangSection +from samfundet.models.recruitment import Interview, Recruitment, OccupiedTimeslot, RecruitmentPosition UserIdType = int TimeSlotType = tuple[datetime, datetime] @@ -10,46 +14,109 @@ UnavailabilityTypeDict = dict[UserIdType, list[TimeSlotType]] -def get_available_interviewers_for_timeslot( - interviewers: list[User], start_dt: datetime, end_dt: datetime, interviewer_unavailability: UnavailabilityTypeDict -) -> list[User]: +def get_available_interviewers_for_timeslot(interviewers: list[User], start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> list[User]: """ Filters interviewers who are available for a specific time slot. Args: interviewers: List of interviewers to check availability for. start_dt: The start datetime of the interview slot. end_dt: The end datetime of the interview slot. - interviewer_unavailability: Dictionary of interviewers and their unavailable time slots. + recruitment: The recruitment for which to check availability. Returns: A list of available interviewers. """ - return [interviewer for interviewer in interviewers if is_interviewer_available(interviewer, start_dt, end_dt, interviewer_unavailability)] + unavailable_interviewer_ids = ( + OccupiedTimeslot.objects.filter(user__in=interviewers, recruitment=recruitment) + .filter( + Q(start_dt__lt=end_dt, end_dt__gt=start_dt) # Overlaps with the start + | Q(start_dt__lt=end_dt, end_dt__gt=end_dt) # Overlaps with the end + | Q(start_dt__gte=start_dt, end_dt__lte=end_dt) # Fully within the interval + ) + .values_list('id', flat=True) + ) + + return [interviewer for interviewer in interviewers if interviewer.id not in unavailable_interviewer_ids] + + +# def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: +# """ +# Checks if a specific interviewer is available during a given time range. +# Args: +# interviewer: The interviewer to check. +# start_dt: The start datetime of the interview slot. +# end_dt: The end datetime of the interview slot. +# recruitment: The recruitment for which to check availability. +# Returns: +# A boolean indicating whether the interviewer is available for the given time range. +# """ +# interviewer_unavailable = ( +# OccupiedTimeslot.objects.filter(user=interviewer, recruitment=recruitment) +# .filter( +# Q(start_dt__lt=end_dt, end_dt__gt=start_dt) # Overlaps with the start +# | Q(start_dt__lt=end_dt, end_dt__gt=end_dt) # Overlaps with the end +# | Q(start_dt__gte=start_dt, end_dt__lte=end_dt) # Fully within the interval +# ) +# .exists() +# ) + +# return not interviewer_unavailable -def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, unavailability: UnavailabilityTypeDict) -> bool: +def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: """ - Checks if a specific interviewer is available during a given time range. + Checks if an applicant is available for an interview during a given time range. Args: - interviewer: The interviewer to check. + applicant: The applicant to check availability for. start_dt: The start datetime of the interview slot. end_dt: The end datetime of the interview slot. - unavailability: Dictionary of unavailable time slots for interviewers. + recruitment: The recruitment to which the applicant has applied. Returns: - A boolean indicating whether the interviewer is available for the given time range. + A boolean indicating whether the applicant is available for the given time range. """ - return all(not (unavail_start < end_dt and unavail_end > start_dt) for unavail_start, unavail_end in unavailability.get(interviewer.id, [])) + # Check for existing interviews + existing_interview = Interview.objects.filter( + applications__user=applicant, applications__recruitment=recruitment, interview_time__lt=end_dt, interview_time__gte=start_dt + ).exists() + if existing_interview: + return False -def mark_interviewers_unavailable(interviewers: list[User], start_dt: datetime, end_dt: datetime, unavailability: UnavailabilityTypeDict) -> None: - """ - Marks a group of interviewers as unavailable during a given time range. - Args: - interviewers: List of interviewers to mark as unavailable. - start_dt: The start datetime of the interview slot. - end_dt: The end datetime of the interview slot. - unavailability: Dictionary to store the unavailable times for each interviewer. - """ - for interviewer in interviewers: - if interviewer.id not in unavailability: - unavailability[interviewer.id] = [] - unavailability[interviewer.id].append((start_dt, end_dt)) + # Check applicant's unavailability + applicant_unavailable = ( + OccupiedTimeslot.objects.filter(user=applicant, recruitment=recruitment) + .filter( + Q(start_dt__lt=end_dt, end_dt__gt=start_dt) # Overlaps with the start + | Q(start_dt__lt=end_dt, end_dt__gt=end_dt) # Overlaps with the end + | Q(start_dt__gte=start_dt, end_dt__lte=end_dt) # Fully within the interval + ) + .exists() + ) + + return not applicant_unavailable + + +# def mark_interviewers_unavailable(interviewers: list[User], start_dt: datetime, end_dt: datetime, unavailability: UnavailabilityTypeDict) -> None: +# """ +# Marks a group of interviewers as unavailable during a given time range. +# Args: +# interviewers: List of interviewers to mark as unavailable. +# start_dt: The start datetime of the interview slot. +# end_dt: The end datetime of the interview slot. +# unavailability: Dictionary to store the unavailable times for each interviewer. +# """ +# for interviewer in interviewers: +# if interviewer.id not in unavailability: +# unavailability[interviewer.id] = [] +# unavailability[interviewer.id].append((start_dt, end_dt)) + + +def get_interviewers_grouped_by_section(recruitment_position: RecruitmentPosition) -> dict[GangSection, QuerySet[User]]: + interviewers_by_section = defaultdict(set) + + positions = recruitment_position.shared_interview_group.positions.all() if recruitment_position.shared_interview_group else [recruitment_position] + + for position in positions: + if position.section: + interviewers_by_section[position.section].update(position.interviewers.all()) + + return {section: User.objects.filter(id__in=[u.id for u in interviewers]) for section, interviewers in interviewers_by_section.items()} diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index b2dfd5330..2df6249d9 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -8,7 +8,6 @@ from django.db import models, transaction from django.utils import timezone -from django.db.models import QuerySet from django.core.exceptions import ValidationError from root.utils.mixins import CustomBaseModel, FullCleanSaveMixin @@ -164,17 +163,6 @@ class RecruitmentPosition(CustomBaseModel): # TODO: Implement interviewer functionality interviewers = models.ManyToManyField(to=User, help_text='Interviewers for the position', blank=True, related_name='interviewers') - def get_interviewers_grouped_by_section(self) -> dict[GangSection, QuerySet[User]]: - interviewers_by_section = defaultdict(set) - - positions = self.shared_interview_group.positions.all() if self.shared_interview_group else [self] - - for position in positions: - if position.section: - interviewers_by_section[position.section].update(position.interviewers.all()) - - return {section: User.objects.filter(id__in=[u.id for u in interviewers]) for section, interviewers in interviewers_by_section.items()} - def resolve_section(self, *, return_id: bool = False) -> GangSection | int: if return_id: # noinspection PyTypeChecker diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 2cb8883e4..ddf5eb457 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -39,7 +39,6 @@ ) from samfundet.automatic_interview_allocation.allocate_interviews_for_position import allocate_interviews_for_position -from samfundet.automatic_interview_allocation.generate_position_interview_schedule import create_final_interview_blocks from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage @@ -1330,50 +1329,23 @@ def post(self, request: Request) -> Response: class AutomaticInterviewAllocationView(APIView): permission_classes = [IsAuthenticated] - def post(self, request: Request, pk) -> Response: - try: - position = get_object_or_404(RecruitmentPosition, id=pk) - # Generate interview timeblocks - timeblocks = create_final_interview_blocks(position) - - # Process timeblocks for response - processed_timeblocks = [] - for block in timeblocks: - block_length = (block['end_dt'] - block['start_dt']).total_seconds() / 60 # length in minutes - processed_timeblocks.append( - { - 'date': block['date'], - 'start_time': block['start_dt'].time(), - 'end_time': block['end_dt'].time(), - 'length_minutes': block_length, - 'interviewer_count': len(block['available_interviewers']), - 'rating': block['rating'], - } - ) - - if not timeblocks: - return Response( - { - 'error': f'No available time blocks for position: {position.name_en}', - 'details': 'No suitable time blocks were generated. This might be due to recruitment dates, interviewer availability, or other constraints.', - }, - status=status.HTTP_400_BAD_REQUEST, - ) + def post(self, request: Request, pk: int) -> Response: + position = get_object_or_404(RecruitmentPosition, id=pk) - # Allocate interviews - interview_count = allocate_interviews_for_position(position) + interview_count = allocate_interviews_for_position(position) + if interview_count > 0: return Response( { 'message': f'Interviews allocated successfully for position {pk}.', 'interviews_allocated': interview_count, - 'timeblocks_generated': len(timeblocks), - 'timeblocks_details': processed_timeblocks, }, status=status.HTTP_200_OK, ) - - except Exception as e: - return Response( - {'error': str(e), 'details': 'An unexpected error occurred during interview allocation.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response( + { + 'message': f'No interviews were allocated for position {pk}.', + 'interviews_allocated': 0, + }, + status=status.HTTP_204_NO_CONTENT, + ) From ebc49e4a23baddabbd752a58ff937caaba196891 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 10:28:49 +0200 Subject: [PATCH 35/44] naming refactor --- .../allocate_interviews_for_position.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index c95402888..deb044d6f 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -46,7 +46,7 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes timeblocks = generate_and_sort_timeblocks(position) - applications = get_applications_without_interviews(position) + applications = get_applications_without_interview(position) interviewer_unavailability = create_interviewer_unavailability_map(position) check_timeblocks_and_applications(timeblocks, applications, position) @@ -57,7 +57,7 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio return interview_count -def get_applications_without_interviews(position: RecruitmentPosition) -> list[RecruitmentApplication]: +def get_applications_without_interview(position: RecruitmentPosition) -> list[RecruitmentApplication]: """Fetch all applications without assigned interviews.""" return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) From 1cadd193e6f9d3be93c136570a3a85d5448c06a4 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 12:51:31 +0200 Subject: [PATCH 36/44] renaming --- .../allocate_interviews_for_position.py | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index deb044d6f..132a5a411 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime, timedelta -from collections import defaultdict from django.utils import timezone @@ -47,10 +46,17 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio timeblocks = generate_and_sort_timeblocks(position) applications = get_applications_without_interview(position) - interviewer_unavailability = create_interviewer_unavailability_map(position) + # interviewer_unavailability = create_interviewer_unavailability_map(position) check_timeblocks_and_applications(timeblocks, applications, position) - interview_count = allocate_interviews(timeblocks, applications, interviewer_unavailability, position, interview_duration, allocation_limit) + interview_count = allocate_all_interviews( + timeblocks, + applications, + # interviewer_unavailability, + position, + interview_duration, + allocation_limit, + ) check_allocation_completeness(interview_count, applications, position) @@ -62,14 +68,14 @@ def get_applications_without_interview(position: RecruitmentPosition) -> list[Re return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) -def create_interviewer_unavailability_map(position: RecruitmentPosition) -> defaultdict[int, list[tuple[datetime, datetime]]]: - """Get all existing interviews and mark interviewer unavailability.""" - interviewer_unavailability = defaultdict(list) - existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) - for interview in existing_interviews: - for interviewer in interview.interviewers.all(): - interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) - return interviewer_unavailability +# def create_interviewer_unavailability_map(position: RecruitmentPosition) -> defaultdict[int, list[tuple[datetime, datetime]]]: +# """Get all existing interviews and mark interviewer unavailability.""" +# interviewer_unavailability = defaultdict(list) +# existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) +# for interview in existing_interviews: +# for interviewer in interview.interviewers.all(): +# interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) +# return interviewer_unavailability def check_timeblocks_and_applications(timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: @@ -80,10 +86,10 @@ def check_timeblocks_and_applications(timeblocks: list[FinalizedTimeBlock], appl raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}') -def allocate_interviews( +def allocate_all_interviews( timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], - interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], + # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, interview_duration: timedelta, allocation_limit: int | None, @@ -97,7 +103,7 @@ def allocate_interviews( raise NoFutureTimeSlotsError(f'No time slots available at least 24 hours in the future for position: {position.name_en}') for block in future_blocks: - block_interview_count = allocate_interviews_in_block(block, applications, position, interview_duration, current_time) + block_interview_count = place_interviews_in_block(block, applications, position, interview_duration, current_time) interview_count += block_interview_count if allocation_limit is not None and interview_count >= allocation_limit: @@ -111,7 +117,7 @@ def allocate_interviews( return interview_count -def allocate_interviews_in_block( +def place_interviews_in_block( block: FinalizedTimeBlock, applications: list[RecruitmentApplication], # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], @@ -126,15 +132,15 @@ def allocate_interviews_in_block( while current_time + interview_duration <= block['end'] and applications: application = applications[0] # Get the next application to process - if allocate_single_interview(current_time, block, application, position, interview_duration): - applications.pop(0) # Remove the application that was just allocated + if allocate_interview(current_time, block, application, position, interview_duration): + applications.pop(0) # Remove the application that was just allocated an interview block_interview_count += 1 current_time += interview_duration return block_interview_count -def allocate_single_interview( +def allocate_interview( current_time: datetime, block: FinalizedTimeBlock, application: RecruitmentApplication, From 277fc273420558e38c1922a505462fb6ac849155 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 13:16:08 +0200 Subject: [PATCH 37/44] remove commented code --- .../allocate_interviews_for_position.py | 17 +------- .../generate_interview_timeblocks.py | 1 - .../automatic_interview_allocation/utils.py | 39 ------------------- 3 files changed, 1 insertion(+), 56 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index 132a5a411..9093a2877 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -36,7 +36,7 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio The number of interviews allocated. Raises: - NoTimeBlocksAvailableError: If no time blocks are available. + NoTimeBlocksAvailableError: If no timeblocks are available. NoApplicationsWithoutInterviewsError: If no applications without interviews exist. AllApplicantsUnavailableError: If all applicants are unavailable for the remaining time blocks. NoAvailableInterviewersError: If no interviewers are available for any time slot. @@ -46,13 +46,11 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio timeblocks = generate_and_sort_timeblocks(position) applications = get_applications_without_interview(position) - # interviewer_unavailability = create_interviewer_unavailability_map(position) check_timeblocks_and_applications(timeblocks, applications, position) interview_count = allocate_all_interviews( timeblocks, applications, - # interviewer_unavailability, position, interview_duration, allocation_limit, @@ -68,16 +66,6 @@ def get_applications_without_interview(position: RecruitmentPosition) -> list[Re return list(RecruitmentApplication.objects.filter(recruitment_position=position, withdrawn=False, interview__isnull=True)) -# def create_interviewer_unavailability_map(position: RecruitmentPosition) -> defaultdict[int, list[tuple[datetime, datetime]]]: -# """Get all existing interviews and mark interviewer unavailability.""" -# interviewer_unavailability = defaultdict(list) -# existing_interviews = Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment) -# for interview in existing_interviews: -# for interviewer in interview.interviewers.all(): -# interviewer_unavailability[interviewer.id].append((interview.interview_time, interview.interview_time + timedelta(minutes=30))) -# return interviewer_unavailability - - def check_timeblocks_and_applications(timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], position: RecruitmentPosition) -> None: """Validate that there are available time blocks and applications.""" if not timeblocks: @@ -89,7 +77,6 @@ def check_timeblocks_and_applications(timeblocks: list[FinalizedTimeBlock], appl def allocate_all_interviews( timeblocks: list[FinalizedTimeBlock], applications: list[RecruitmentApplication], - # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, interview_duration: timedelta, allocation_limit: int | None, @@ -120,7 +107,6 @@ def allocate_all_interviews( def place_interviews_in_block( block: FinalizedTimeBlock, applications: list[RecruitmentApplication], - # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, interview_duration: timedelta, current_time: datetime, @@ -144,7 +130,6 @@ def allocate_interview( current_time: datetime, block: FinalizedTimeBlock, application: RecruitmentApplication, - # interviewer_unavailability: defaultdict[int, list[tuple[datetime, datetime]]], position: RecruitmentPosition, interview_duration: timedelta, ) -> bool: diff --git a/backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py b/backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py index e140bfd5b..b524d2924 100644 --- a/backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py +++ b/backend/samfundet/automatic_interview_allocation/generate_interview_timeblocks.py @@ -7,7 +7,6 @@ from samfundet.models.general import User from samfundet.models.recruitment import ( - # Interview, Recruitment, OccupiedTimeslot, RecruitmentPosition, diff --git a/backend/samfundet/automatic_interview_allocation/utils.py b/backend/samfundet/automatic_interview_allocation/utils.py index db8bc5bb9..87277e3f7 100644 --- a/backend/samfundet/automatic_interview_allocation/utils.py +++ b/backend/samfundet/automatic_interview_allocation/utils.py @@ -38,30 +38,6 @@ def get_available_interviewers_for_timeslot(interviewers: list[User], start_dt: return [interviewer for interviewer in interviewers if interviewer.id not in unavailable_interviewer_ids] -# def is_interviewer_available(interviewer: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: -# """ -# Checks if a specific interviewer is available during a given time range. -# Args: -# interviewer: The interviewer to check. -# start_dt: The start datetime of the interview slot. -# end_dt: The end datetime of the interview slot. -# recruitment: The recruitment for which to check availability. -# Returns: -# A boolean indicating whether the interviewer is available for the given time range. -# """ -# interviewer_unavailable = ( -# OccupiedTimeslot.objects.filter(user=interviewer, recruitment=recruitment) -# .filter( -# Q(start_dt__lt=end_dt, end_dt__gt=start_dt) # Overlaps with the start -# | Q(start_dt__lt=end_dt, end_dt__gt=end_dt) # Overlaps with the end -# | Q(start_dt__gte=start_dt, end_dt__lte=end_dt) # Fully within the interval -# ) -# .exists() -# ) - -# return not interviewer_unavailable - - def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime, recruitment: Recruitment) -> bool: """ Checks if an applicant is available for an interview during a given time range. @@ -95,21 +71,6 @@ def is_applicant_available(applicant: User, start_dt: datetime, end_dt: datetime return not applicant_unavailable -# def mark_interviewers_unavailable(interviewers: list[User], start_dt: datetime, end_dt: datetime, unavailability: UnavailabilityTypeDict) -> None: -# """ -# Marks a group of interviewers as unavailable during a given time range. -# Args: -# interviewers: List of interviewers to mark as unavailable. -# start_dt: The start datetime of the interview slot. -# end_dt: The end datetime of the interview slot. -# unavailability: Dictionary to store the unavailable times for each interviewer. -# """ -# for interviewer in interviewers: -# if interviewer.id not in unavailability: -# unavailability[interviewer.id] = [] -# unavailability[interviewer.id].append((start_dt, end_dt)) - - def get_interviewers_grouped_by_section(recruitment_position: RecruitmentPosition) -> dict[GangSection, QuerySet[User]]: interviewers_by_section = defaultdict(set) From b4226bb0ab3e05bb2e07fc5d155872ac8f585a30 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 13:47:41 +0200 Subject: [PATCH 38/44] removes check not needed --- .../allocate_interviews_for_position.py | 14 ++------ backend/samfundet/views.py | 34 ++++++++++++++++++- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py index 9093a2877..add17872f 100644 --- a/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py +++ b/backend/samfundet/automatic_interview_allocation/allocate_interviews_for_position.py @@ -24,7 +24,7 @@ ) -def allocate_interviews_for_position(position: RecruitmentPosition, *, allocation_limit: int | None = None) -> int: +def allocate_interviews_for_position(position: RecruitmentPosition) -> int: """ Allocates interviews for applicants of a given recruitment position based on available time blocks. @@ -53,7 +53,6 @@ def allocate_interviews_for_position(position: RecruitmentPosition, *, allocatio applications, position, interview_duration, - allocation_limit, ) check_allocation_completeness(interview_count, applications, position) @@ -79,7 +78,6 @@ def allocate_all_interviews( applications: list[RecruitmentApplication], position: RecruitmentPosition, interview_duration: timedelta, - allocation_limit: int | None, ) -> int: """Allocate interviews within available future time blocks.""" interview_count = 0 @@ -93,14 +91,6 @@ def allocate_all_interviews( block_interview_count = place_interviews_in_block(block, applications, position, interview_duration, current_time) interview_count += block_interview_count - if allocation_limit is not None and interview_count >= allocation_limit: - # If we've reached or exceeded the allocation limit, stop allocating - interview_count = min(interview_count, allocation_limit) - break - - if not applications: - break - return interview_count @@ -136,7 +126,7 @@ def allocate_interview( """Attempt to allocate a single interview at the current time.""" interview_end_time = current_time + interview_duration - # Check for existing interviews only once per timeslot + # Check for existing interviews if Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment, interview_time=current_time).exists(): return False diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index ddf5eb457..62d285241 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -38,6 +38,15 @@ REQUESTED_IMPERSONATE_USER, ) +from samfundet.automatic_interview_allocation.exceptions import ( + NoFutureTimeSlotsError, + InterviewAllocationError, + NoTimeBlocksAvailableError, + InsufficientTimeBlocksError, + NoAvailableInterviewersError, + AllApplicantsUnavailableError, + NoApplicationsWithoutInterviewsError, +) from samfundet.automatic_interview_allocation.allocate_interviews_for_position import allocate_interviews_for_position from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request @@ -1332,8 +1341,15 @@ class AutomaticInterviewAllocationView(APIView): def post(self, request: Request, pk: int) -> Response: position = get_object_or_404(RecruitmentPosition, id=pk) - interview_count = allocate_interviews_for_position(position) + try: + interview_count = allocate_interviews_for_position(position, allocation_limit=1) + return self.get_success_response(pk, interview_count) + except InterviewAllocationError as e: + return self.handle_allocation_error(e, interview_count=getattr(e, 'interview_count', 0)) + except Exception as e: + return Response({'error': f'An unexpected error occurred: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def get_success_response(self, pk: int, interview_count: int) -> Response: if interview_count > 0: return Response( { @@ -1349,3 +1365,19 @@ def post(self, request: Request, pk: int) -> Response: }, status=status.HTTP_204_NO_CONTENT, ) + + def handle_allocation_error(self, error: InterviewAllocationError, interview_count: int = 0) -> Response: + error_responses = { + NoTimeBlocksAvailableError: ('No available time blocks for interviews.', status.HTTP_400_BAD_REQUEST), + NoApplicationsWithoutInterviewsError: ('No applications without interviews found.', status.HTTP_400_BAD_REQUEST), + NoAvailableInterviewersError: ('No available interviewers for any time slot.', status.HTTP_400_BAD_REQUEST), + AllApplicantsUnavailableError: ('All applicants are unavailable for the remaining time slots.', status.HTTP_400_BAD_REQUEST), + NoFutureTimeSlotsError: ('No time slots available at least 24 hours in the future.', status.HTTP_400_BAD_REQUEST), + InsufficientTimeBlocksError: ('Not enough time blocks to accommodate all applications.', status.HTTP_206_PARTIAL_CONTENT), + } + + message, status_code = error_responses.get(type(error), ('An error occurred during interview allocation.', status.HTTP_400_BAD_REQUEST)) + + response_data = {'error': message, 'interviews_allocated': interview_count} + + return Response(response_data, status=status_code) From 71dfac6f4a5b1fca290649d91e36ee70a3040f8d Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 15:06:55 +0200 Subject: [PATCH 39/44] adds tests --- .../automatic_interview_allocation/utils.py | 4 +- backend/samfundet/tests/test_all.py | 416 ++++++++++++++++++ 2 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 backend/samfundet/tests/test_all.py diff --git a/backend/samfundet/automatic_interview_allocation/utils.py b/backend/samfundet/automatic_interview_allocation/utils.py index 87277e3f7..50f79ac41 100644 --- a/backend/samfundet/automatic_interview_allocation/utils.py +++ b/backend/samfundet/automatic_interview_allocation/utils.py @@ -25,14 +25,14 @@ def get_available_interviewers_for_timeslot(interviewers: list[User], start_dt: Returns: A list of available interviewers. """ - unavailable_interviewer_ids = ( + unavailable_interviewer_ids = set( OccupiedTimeslot.objects.filter(user__in=interviewers, recruitment=recruitment) .filter( Q(start_dt__lt=end_dt, end_dt__gt=start_dt) # Overlaps with the start | Q(start_dt__lt=end_dt, end_dt__gt=end_dt) # Overlaps with the end | Q(start_dt__gte=start_dt, end_dt__lte=end_dt) # Fully within the interval ) - .values_list('id', flat=True) + .values_list('user_id', flat=True) ) return [interviewer for interviewer in interviewers if interviewer.id not in unavailable_interviewer_ids] diff --git a/backend/samfundet/tests/test_all.py b/backend/samfundet/tests/test_all.py new file mode 100644 index 000000000..71a97066e --- /dev/null +++ b/backend/samfundet/tests/test_all.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from django.utils import timezone + +from samfundet.models.general import Gang, User, GangSection, Organization +from samfundet.models.recruitment import Recruitment, OccupiedTimeslot, RecruitmentPosition, RecruitmentPositionSharedInterviewGroup +from samfundet.automatic_interview_allocation.utils import is_applicant_available, get_interviewers_grouped_by_section, get_available_interviewers_for_timeslot + + +@pytest.fixture +def setup_recruitment(): + organization = Organization.objects.create(name='Test Org') + now = timezone.now() + recruitment = Recruitment.objects.create( + name_nb='Test Recruitment', + name_en='Test Recruitment', + organization=organization, + visible_from=now, + actual_application_deadline=now + timedelta(days=30), + shown_application_deadline=now + timedelta(days=28), + reprioritization_deadline_for_applicant=now + timedelta(days=35), + reprioritization_deadline_for_groups=now + timedelta(days=40), + ) + return organization, recruitment + + +@pytest.fixture +def setup_users(): + user1 = User.objects.create(username='user1', email='user1@example.com') + user2 = User.objects.create(username='user2', email='user2@example.com') + user3 = User.objects.create(username='user3', email='user3@example.com') + return user1, user2, user3 + + +@pytest.mark.django_db +def test_get_available_interviewers_for_timeslot(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + + with patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_filter: + mock_filter.return_value.filter.return_value.values_list.return_value = [user1.id] + + start_dt = timezone.now() + end_dt = start_dt + timedelta(hours=1) + + available_interviewers = get_available_interviewers_for_timeslot([user1, user2, user3], start_dt, end_dt, recruitment) + + assert len(available_interviewers) == 2 + assert user1 not in available_interviewers + assert user2 in available_interviewers + assert user3 in available_interviewers + + +@pytest.mark.django_db +def test_is_applicant_available(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + + with ( + patch('samfundet.models.recruitment.Interview.objects.filter') as mock_interview_filter, + patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_occupied_filter, + ): + # Test when applicant is available + mock_interview_filter.return_value.exists.return_value = False + mock_occupied_filter.return_value.filter.return_value.exists.return_value = False + + start_dt = timezone.now() + end_dt = start_dt + timedelta(hours=1) + + is_available = is_applicant_available(user1, start_dt, end_dt, recruitment) + assert is_available is True + + # Test when applicant has an interview + mock_interview_filter.return_value.exists.return_value = True + + is_available = is_applicant_available(user1, start_dt, end_dt, recruitment) + assert is_available is False + + # Test when applicant has an occupied timeslot + mock_interview_filter.return_value.exists.return_value = False + mock_occupied_filter.return_value.filter.return_value.exists.return_value = True + + is_available = is_applicant_available(user1, start_dt, end_dt, recruitment) + assert is_available is False + + +@pytest.mark.django_db +def test_get_interviewers_grouped_by_section(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + + gang1 = Gang.objects.create(name_nb='Gang 1', name_en='Gang 1', organization=organization) + gang2 = Gang.objects.create(name_nb='Gang 2', name_en='Gang 2', organization=organization) + + section1 = GangSection.objects.create(name_nb='Section 1', name_en='Section 1', gang=gang1) + section2 = GangSection.objects.create(name_nb='Section 2', name_en='Section 2', gang=gang2) + + position1 = RecruitmentPosition.objects.create( + name_nb='Position 1', + name_en='Position 1', + section=section1, + recruitment=recruitment, + short_description_nb='Short description 1', + short_description_en='Short description 1 EN', + long_description_nb='Long description 1', + long_description_en='Long description 1 EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default application letter 1', + default_application_letter_en='Default application letter 1 EN', + tags='tag1,tag2', + ) + position2 = RecruitmentPosition.objects.create( + name_nb='Position 2', + name_en='Position 2', + section=section2, + recruitment=recruitment, + short_description_nb='Short description 2', + short_description_en='Short description 2 EN', + long_description_nb='Long description 2', + long_description_en='Long description 2 EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default application letter 2', + default_application_letter_en='Default application letter 2 EN', + tags='tag2,tag3', + ) + + position1.interviewers.add(user1, user2) + position2.interviewers.add(user2, user3) + + shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=recruitment) + shared_group.positions.add(position1, position2) + + recruitment_position = RecruitmentPosition.objects.create( + name_nb='Shared Position', + name_en='Shared Position', + recruitment=recruitment, + short_description_nb='Short description shared', + short_description_en='Short description shared EN', + long_description_nb='Long description shared', + long_description_en='Long description shared EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default application letter shared', + default_application_letter_en='Default application letter shared EN', + tags='tag1,tag2,tag3', + gang=gang1, + ) + recruitment_position.shared_interview_group = shared_group + recruitment_position.save() + + result = get_interviewers_grouped_by_section(recruitment_position) + + assert len(result) == 2 + assert section1 in result + assert section2 in result + assert result[section1].count() == 2 + assert result[section2].count() == 2 + assert user1 in result[section1] + assert user2 in result[section1] + assert user2 in result[section2] + assert user3 in result[section2] + + +# New tests start here + + +@pytest.mark.django_db +def test_get_available_interviewers_no_interviewers(setup_recruitment): + organization, recruitment = setup_recruitment + start_dt = timezone.now() + end_dt = start_dt + timedelta(hours=1) + + available_interviewers = get_available_interviewers_for_timeslot([], start_dt, end_dt, recruitment) + assert len(available_interviewers) == 0 + + +@pytest.mark.django_db +def test_get_available_interviewers_all_occupied(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + start_dt = timezone.now() + end_dt = start_dt + timedelta(hours=1) + + with patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_filter: + mock_filter.return_value.filter.return_value.values_list.return_value = {user1.id, user2.id, user3.id} + available_interviewers = get_available_interviewers_for_timeslot([user1, user2, user3], start_dt, end_dt, recruitment) + assert len(available_interviewers) == 0 + + +@pytest.mark.django_db +def test_get_available_interviewers_complex_overlap(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + start_dt = timezone.now() + mid_dt = start_dt + timedelta(minutes=30) + end_dt = start_dt + timedelta(hours=1) + + with patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_filter: + mock_filter.return_value.filter.return_value.values_list.return_value = { + user1.id, # user1 is occupied for the entire period + user2.id, # user2 is occupied for the first half + } + available_interviewers = get_available_interviewers_for_timeslot([user1, user2, user3], start_dt, end_dt, recruitment) + assert len(available_interviewers) == 1 + assert user3 in available_interviewers + + # Now check for the second half of the time period + mock_filter.return_value.filter.return_value.values_list.return_value = { + user1.id, # user1 is still occupied + user3.id, # user3 is now occupied + } + available_interviewers = get_available_interviewers_for_timeslot([user1, user2, user3], mid_dt, end_dt, recruitment) + assert len(available_interviewers) == 1 + assert user2 in available_interviewers + + +@pytest.mark.django_db +def test_is_applicant_available_multiple_conflicts(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, _, _ = setup_users + start_dt = timezone.now() + end_dt = start_dt + timedelta(hours=2) + + with ( + patch('samfundet.models.recruitment.Interview.objects.filter') as mock_interview_filter, + patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_occupied_filter, + ): + mock_interview_filter.return_value.exists.return_value = True + mock_occupied_filter.return_value.filter.return_value.exists.return_value = True + + is_available = is_applicant_available(user1, start_dt, end_dt, recruitment) + assert is_available is False + + +@pytest.mark.django_db +def test_is_applicant_available_edge_cases(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, _, _ = setup_users + start_dt = timezone.now() + end_dt = start_dt + timedelta(microseconds=1) + + with ( + patch('samfundet.models.recruitment.Interview.objects.filter') as mock_interview_filter, + patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_occupied_filter, + ): + mock_interview_filter.return_value.exists.return_value = False + mock_occupied_filter.return_value.filter.return_value.exists.return_value = False + + is_available = is_applicant_available(user1, start_dt, end_dt, recruitment) + assert is_available is True + + +@pytest.mark.django_db +def test_get_interviewers_grouped_by_section_complex(setup_recruitment, setup_users): + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + + gang1 = Gang.objects.create(name_nb='Gang 1', name_en='Gang 1', organization=organization) + gang2 = Gang.objects.create(name_nb='Gang 2', name_en='Gang 2', organization=organization) + + section1 = GangSection.objects.create(name_nb='Section 1', name_en='Section 1', gang=gang1) + section2 = GangSection.objects.create(name_nb='Section 2', name_en='Section 2', gang=gang2) + section3 = GangSection.objects.create(name_nb='Section 3', name_en='Section 3', gang=gang1) + + position1 = RecruitmentPosition.objects.create( + name_nb='Position 1', + name_en='Position 1', + section=section1, + recruitment=recruitment, + short_description_nb='Short 1', + short_description_en='Short 1 EN', + long_description_nb='Long 1', + long_description_en='Long 1 EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default 1', + default_application_letter_en='Default 1 EN', + tags='tag1', + ) + position2 = RecruitmentPosition.objects.create( + name_nb='Position 2', + name_en='Position 2', + section=section2, + recruitment=recruitment, + short_description_nb='Short 2', + short_description_en='Short 2 EN', + long_description_nb='Long 2', + long_description_en='Long 2 EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default 2', + default_application_letter_en='Default 2 EN', + tags='tag2', + ) + position3 = RecruitmentPosition.objects.create( + name_nb='Position 3', + name_en='Position 3', + section=section3, + recruitment=recruitment, + short_description_nb='Short 3', + short_description_en='Short 3 EN', + long_description_nb='Long 3', + long_description_en='Long 3 EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default 3', + default_application_letter_en='Default 3 EN', + tags='tag3', + ) + + position1.interviewers.add(user1, user2) + position2.interviewers.add(user2, user3) + position3.interviewers.add(user1, user3) + + shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=recruitment) + shared_group.positions.add(position1, position2, position3) + + recruitment_position = RecruitmentPosition.objects.create( + name_nb='Shared Position', + name_en='Shared Position', + recruitment=recruitment, + short_description_nb='Short shared', + short_description_en='Short shared EN', + long_description_nb='Long shared', + long_description_en='Long shared EN', + is_funksjonaer_position=False, + default_application_letter_nb='Default shared', + default_application_letter_en='Default shared EN', + tags='tag1,tag2,tag3', + gang=gang1, + ) + recruitment_position.shared_interview_group = shared_group + recruitment_position.save() + + result = get_interviewers_grouped_by_section(recruitment_position) + + assert len(result) == 3 + assert section1 in result + assert section2 in result + assert section3 in result + assert result[section1].count() == 2 + assert result[section2].count() == 2 + assert result[section3].count() == 2 + assert user1 in result[section1] + assert user2 in result[section1] + assert user2 in result[section2] + assert user3 in result[section2] + assert user1 in result[section3] + assert user3 in result[section3] + + +@pytest.mark.django_db +def test_get_available_interviewers_for_timeslot_filtering(setup_recruitment, setup_users): + """Test the get_available_interviewers_for_timeslot function with various scenarios.""" + organization, recruitment = setup_recruitment + user1, user2, user3 = setup_users + user4, user5, user6 = [User.objects.create(username=f'user{i}', email=f'user{i}@example.com') for i in range(4, 7)] + start_dt = timezone.now() + end_dt = start_dt + timedelta(hours=2) + + def create_occupied_timeslot(user: User, start_offset: timedelta, end_offset: timedelta) -> OccupiedTimeslot: + return OccupiedTimeslot.objects.create(user=user, recruitment=recruitment, start_dt=start_dt + start_offset, end_dt=start_dt + end_offset) + + # Create OccupiedTimeslots for different scenarios + scenarios = [ + (user1, timedelta(hours=-1), timedelta(minutes=30)), # Overlaps start + (user2, timedelta(hours=1, minutes=30), timedelta(hours=3)), # Overlaps end + (user3, timedelta(minutes=30), timedelta(hours=1, minutes=30)), # Fully within + (user4, timedelta(hours=-2), timedelta(hours=-1)), # Before interval + (user5, timedelta(hours=3), timedelta(hours=4)), # After interval + (user6, timedelta(hours=-1), timedelta(hours=3)), # Completely encompasses interval + ] + + for user, start_offset, end_offset in scenarios: + create_occupied_timeslot(user, start_offset, end_offset) + + # Test with the exact interval + available_interviewers = get_available_interviewers_for_timeslot([user1, user2, user3, user4, user5, user6], start_dt, end_dt, recruitment) + assert set(available_interviewers) == {user4, user5} + + # Add detailed checks immediately after the exact interval test + assert user1 not in available_interviewers, 'User1 should be unavailable due to overlap with start' + assert user2 not in available_interviewers, 'User2 should be unavailable due to overlap with end' + assert user3 not in available_interviewers, 'User3 should be unavailable due to being fully within interval' + assert user4 in available_interviewers, 'User4 should be available (occupied time is before interval)' + assert user5 in available_interviewers, 'User5 should be available (occupied time is after interval)' + assert user6 not in available_interviewers, 'User6 should be unavailable (occupied time encompasses interval)' + + # Test with a smaller interval + smaller_end_dt = start_dt + timedelta(hours=1) + available_interviewers_smaller = get_available_interviewers_for_timeslot([user1, user2, user3, user4, user5, user6], start_dt, smaller_end_dt, recruitment) + assert set(available_interviewers_smaller) == {user2, user4, user5} + + # Test with a larger interval + larger_end_dt = start_dt + timedelta(hours=3) + available_interviewers_larger = get_available_interviewers_for_timeslot([user1, user2, user3, user4, user5, user6], start_dt, larger_end_dt, recruitment) + + # Since user5's occupied time is after the requested interval, they should be available + assert set(available_interviewers_larger) == {user4, user5} + + # Detailed assertions + assert user1 not in available_interviewers, 'User1 should be unavailable due to overlap with start' + assert user2 not in available_interviewers, 'User2 should be unavailable due to overlap with end' + assert user3 not in available_interviewers, 'User3 should be unavailable due to being fully within interval' + assert user4 in available_interviewers, 'User4 should be available (occupied time is before interval)' + assert user5 in available_interviewers, 'User5 should be available (occupied time is after interval)' + assert user6 not in available_interviewers, 'User6 should be unavailable (occupied time encompasses interval)' + + # Test with no interviewers + assert len(get_available_interviewers_for_timeslot([], start_dt, end_dt, recruitment)) == 0 + + # Test with no occupied timeslots + OccupiedTimeslot.objects.all().delete() + all_available = get_available_interviewers_for_timeslot([user1, user2, user3, user4, user5, user6], start_dt, end_dt, recruitment) + assert set(all_available) == {user1, user2, user3, user4, user5, user6} From cbd3c33c2c83aa73b012c66b0d30bed05b37b61c Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 15:10:06 +0200 Subject: [PATCH 40/44] renamed test file and fixed interview allocation view --- .../tests/{test_all.py => test_interview_allocation.py} | 0 backend/samfundet/views.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename backend/samfundet/tests/{test_all.py => test_interview_allocation.py} (100%) diff --git a/backend/samfundet/tests/test_all.py b/backend/samfundet/tests/test_interview_allocation.py similarity index 100% rename from backend/samfundet/tests/test_all.py rename to backend/samfundet/tests/test_interview_allocation.py diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 62d285241..7aefb3203 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1342,7 +1342,7 @@ def post(self, request: Request, pk: int) -> Response: position = get_object_or_404(RecruitmentPosition, id=pk) try: - interview_count = allocate_interviews_for_position(position, allocation_limit=1) + interview_count = allocate_interviews_for_position(position) return self.get_success_response(pk, interview_count) except InterviewAllocationError as e: return self.handle_allocation_error(e, interview_count=getattr(e, 'interview_count', 0)) From 8a2cfacdffd5467e45de4f6426174e2492ccbd98 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Sun, 20 Oct 2024 15:14:03 +0200 Subject: [PATCH 41/44] ruff --- .../tests/test_interview_allocation.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/samfundet/tests/test_interview_allocation.py b/backend/samfundet/tests/test_interview_allocation.py index 71a97066e..063290616 100644 --- a/backend/samfundet/tests/test_interview_allocation.py +++ b/backend/samfundet/tests/test_interview_allocation.py @@ -39,7 +39,7 @@ def setup_users(): @pytest.mark.django_db def test_get_available_interviewers_for_timeslot(setup_recruitment, setup_users): - organization, recruitment = setup_recruitment + recruitment = setup_recruitment user1, user2, user3 = setup_users with patch('samfundet.models.recruitment.OccupiedTimeslot.objects.filter') as mock_filter: @@ -58,8 +58,8 @@ def test_get_available_interviewers_for_timeslot(setup_recruitment, setup_users) @pytest.mark.django_db def test_is_applicant_available(setup_recruitment, setup_users): - organization, recruitment = setup_recruitment - user1, user2, user3 = setup_users + recruitment = setup_recruitment + user1 = setup_users with ( patch('samfundet.models.recruitment.Interview.objects.filter') as mock_interview_filter, @@ -170,7 +170,7 @@ def test_get_interviewers_grouped_by_section(setup_recruitment, setup_users): @pytest.mark.django_db def test_get_available_interviewers_no_interviewers(setup_recruitment): - organization, recruitment = setup_recruitment + recruitment = setup_recruitment start_dt = timezone.now() end_dt = start_dt + timedelta(hours=1) @@ -180,7 +180,7 @@ def test_get_available_interviewers_no_interviewers(setup_recruitment): @pytest.mark.django_db def test_get_available_interviewers_all_occupied(setup_recruitment, setup_users): - organization, recruitment = setup_recruitment + recruitment = setup_recruitment user1, user2, user3 = setup_users start_dt = timezone.now() end_dt = start_dt + timedelta(hours=1) @@ -193,7 +193,7 @@ def test_get_available_interviewers_all_occupied(setup_recruitment, setup_users) @pytest.mark.django_db def test_get_available_interviewers_complex_overlap(setup_recruitment, setup_users): - organization, recruitment = setup_recruitment + recruitment = setup_recruitment user1, user2, user3 = setup_users start_dt = timezone.now() mid_dt = start_dt + timedelta(minutes=30) @@ -220,7 +220,7 @@ def test_get_available_interviewers_complex_overlap(setup_recruitment, setup_use @pytest.mark.django_db def test_is_applicant_available_multiple_conflicts(setup_recruitment, setup_users): - organization, recruitment = setup_recruitment + recruitment = setup_recruitment user1, _, _ = setup_users start_dt = timezone.now() end_dt = start_dt + timedelta(hours=2) @@ -238,7 +238,7 @@ def test_is_applicant_available_multiple_conflicts(setup_recruitment, setup_user @pytest.mark.django_db def test_is_applicant_available_edge_cases(setup_recruitment, setup_users): - organization, recruitment = setup_recruitment + recruitment = setup_recruitment user1, _, _ = setup_users start_dt = timezone.now() end_dt = start_dt + timedelta(microseconds=1) @@ -353,7 +353,7 @@ def test_get_interviewers_grouped_by_section_complex(setup_recruitment, setup_us @pytest.mark.django_db def test_get_available_interviewers_for_timeslot_filtering(setup_recruitment, setup_users): """Test the get_available_interviewers_for_timeslot function with various scenarios.""" - organization, recruitment = setup_recruitment + recruitment = setup_recruitment user1, user2, user3 = setup_users user4, user5, user6 = [User.objects.create(username=f'user{i}', email=f'user{i}@example.com') for i in range(4, 7)] start_dt = timezone.now() From e7b83ffe3ae32df75feff1ff8499b6a477a6e105 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 22 Oct 2024 19:26:28 +0200 Subject: [PATCH 42/44] deltes my migration babies --- .../migrations/0002_interviewtimeblock.py | 27 ------------------- .../migrations/0004_merge_20240924_0058.py | 14 ---------- .../migrations/0006_merge_20240925_1842.py | 14 ---------- .../migrations/0008_merge_20241018_0235.py | 14 ---------- .../0009_delete_interviewtimeblock.py | 16 ----------- 5 files changed, 85 deletions(-) delete mode 100644 backend/samfundet/migrations/0002_interviewtimeblock.py delete mode 100644 backend/samfundet/migrations/0004_merge_20240924_0058.py delete mode 100644 backend/samfundet/migrations/0006_merge_20240925_1842.py delete mode 100644 backend/samfundet/migrations/0008_merge_20241018_0235.py delete mode 100644 backend/samfundet/migrations/0009_delete_interviewtimeblock.py diff --git a/backend/samfundet/migrations/0002_interviewtimeblock.py b/backend/samfundet/migrations/0002_interviewtimeblock.py deleted file mode 100644 index e85d2ebc3..000000000 --- a/backend/samfundet/migrations/0002_interviewtimeblock.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.0.7 on 2024-07-22 13:18 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('samfundet', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='InterviewTimeblock', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField(help_text='Block date')), - ('start_dt', models.DateTimeField(help_text='Block start time')), - ('end_dt', models.DateTimeField(help_text='Block end time')), - ('rating', models.FloatField(help_text='Rating used for optimizing interview time')), - ('available_interviewers', models.ManyToManyField(blank=True, help_text='Interviewers in this time block', related_name='interview_timeblocks', to=settings.AUTH_USER_MODEL)), - ('recruitment_position', models.ForeignKey(help_text='The position which is recruiting', on_delete=django.db.models.deletion.CASCADE, related_name='interview_timeblocks', to='samfundet.recruitmentposition')), - ], - ), - ] diff --git a/backend/samfundet/migrations/0004_merge_20240924_0058.py b/backend/samfundet/migrations/0004_merge_20240924_0058.py deleted file mode 100644 index c2f93f9e8..000000000 --- a/backend/samfundet/migrations/0004_merge_20240924_0058.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-23 22:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('samfundet', '0002_interviewtimeblock'), - ('samfundet', '0003_remove_gang_event_admin_group_and_more'), - ] - - operations = [ - ] diff --git a/backend/samfundet/migrations/0006_merge_20240925_1842.py b/backend/samfundet/migrations/0006_merge_20240925_1842.py deleted file mode 100644 index 5b127e527..000000000 --- a/backend/samfundet/migrations/0006_merge_20240925_1842.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-25 16:42 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('samfundet', '0004_merge_20240924_0058'), - ('samfundet', '0005_role_content_type_role_created_at_role_created_by_and_more'), - ] - - operations = [ - ] diff --git a/backend/samfundet/migrations/0008_merge_20241018_0235.py b/backend/samfundet/migrations/0008_merge_20241018_0235.py deleted file mode 100644 index 117bbe038..000000000 --- a/backend/samfundet/migrations/0008_merge_20241018_0235.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-18 00:35 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('samfundet', '0006_merge_20240925_1842'), - ('samfundet', '0007_alter_infobox_color_alter_infobox_image_and_more'), - ] - - operations = [ - ] diff --git a/backend/samfundet/migrations/0009_delete_interviewtimeblock.py b/backend/samfundet/migrations/0009_delete_interviewtimeblock.py deleted file mode 100644 index f453131ef..000000000 --- a/backend/samfundet/migrations/0009_delete_interviewtimeblock.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-18 02:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('samfundet', '0008_merge_20241018_0235'), - ] - - operations = [ - migrations.DeleteModel( - name='InterviewTimeblock', - ), - ] From 262c9206eb406707015931d7ba44d32b08643600 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 22 Oct 2024 19:27:34 +0200 Subject: [PATCH 43/44] fixes test, works in Docker on my maching --- backend/samfundet/tests/test_interview_allocation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/samfundet/tests/test_interview_allocation.py b/backend/samfundet/tests/test_interview_allocation.py index 063290616..301d60086 100644 --- a/backend/samfundet/tests/test_interview_allocation.py +++ b/backend/samfundet/tests/test_interview_allocation.py @@ -170,7 +170,7 @@ def test_get_interviewers_grouped_by_section(setup_recruitment, setup_users): @pytest.mark.django_db def test_get_available_interviewers_no_interviewers(setup_recruitment): - recruitment = setup_recruitment + organization, recruitment = setup_recruitment start_dt = timezone.now() end_dt = start_dt + timedelta(hours=1) @@ -353,7 +353,7 @@ def test_get_interviewers_grouped_by_section_complex(setup_recruitment, setup_us @pytest.mark.django_db def test_get_available_interviewers_for_timeslot_filtering(setup_recruitment, setup_users): """Test the get_available_interviewers_for_timeslot function with various scenarios.""" - recruitment = setup_recruitment + organization, recruitment = setup_recruitment # Fixed: properly unpack the tuple user1, user2, user3 = setup_users user4, user5, user6 = [User.objects.create(username=f'user{i}', email=f'user{i}@example.com') for i in range(4, 7)] start_dt = timezone.now() From a99a6320c0a631559321c6c21359a8ecac7449ca Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 22 Oct 2024 19:32:13 +0200 Subject: [PATCH 44/44] ruff you dirty dog! --- backend/samfundet/tests/test_interview_allocation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/samfundet/tests/test_interview_allocation.py b/backend/samfundet/tests/test_interview_allocation.py index 301d60086..8f8523f73 100644 --- a/backend/samfundet/tests/test_interview_allocation.py +++ b/backend/samfundet/tests/test_interview_allocation.py @@ -170,7 +170,7 @@ def test_get_interviewers_grouped_by_section(setup_recruitment, setup_users): @pytest.mark.django_db def test_get_available_interviewers_no_interviewers(setup_recruitment): - organization, recruitment = setup_recruitment + _, recruitment = setup_recruitment start_dt = timezone.now() end_dt = start_dt + timedelta(hours=1) @@ -353,7 +353,7 @@ def test_get_interviewers_grouped_by_section_complex(setup_recruitment, setup_us @pytest.mark.django_db def test_get_available_interviewers_for_timeslot_filtering(setup_recruitment, setup_users): """Test the get_available_interviewers_for_timeslot function with various scenarios.""" - organization, recruitment = setup_recruitment # Fixed: properly unpack the tuple + _, recruitment = setup_recruitment user1, user2, user3 = setup_users user4, user5, user6 = [User.objects.create(username=f'user{i}', email=f'user{i}@example.com') for i in range(4, 7)] start_dt = timezone.now()