Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Find optimal interview slot #1271

Open
wants to merge 48 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
fcc650b
adds occupied time slot seed script
Snorre98 Jul 22, 2024
3bbaee1
ruff 🐕‍🦺🐕‍🦺
Snorre98 Jul 22, 2024
47bcf4d
adds seed script for interviewers. Seeds at least three interviewers …
Snorre98 Jul 22, 2024
4a6973d
adds InterviewTimeblock model
Snorre98 Jul 22, 2024
b1cdae1
working BAD solution. Generate blocks using API call
Snorre98 Jul 22, 2024
61caacb
adds "generate and rate blocks" script
Snorre98 Jul 23, 2024
53623a4
Merge branch 'master' into find-optimal-interview-slot
Snorre98 Sep 23, 2024
a7b4398
something generated
Snorre98 Sep 23, 2024
c0f0bb6
working, but without interviewers
Snorre98 Sep 23, 2024
80b3aeb
code creates correct blocks with rating
Snorre98 Sep 24, 2024
e433ddb
adds assign interview logic
Snorre98 Sep 24, 2024
216a31f
Merge branch 'master' into find-optimal-interview-slot
Snorre98 Sep 25, 2024
8c3062f
good working interview allocation for Samfundet
Snorre98 Sep 25, 2024
94eb43c
adds ecceptions
Snorre98 Sep 25, 2024
fe934ab
adds comments for pre-preview
Snorre98 Sep 25, 2024
553d568
Merge branch 'master' into find-optimal-interview-slot
Snorre98 Oct 1, 2024
e2af7fe
Merge branch 'master' into find-optimal-interview-slot
Snorre98 Oct 17, 2024
c44ca6b
starts removing InterviewTimeBlocks table
Snorre98 Oct 18, 2024
b4c72e9
adds automatic interview allocation, which work without InterviewTime…
Snorre98 Oct 18, 2024
a4e2232
adds more logging
Snorre98 Oct 18, 2024
c8afe3f
removes logging
Snorre98 Oct 18, 2024
6a91d43
adds comments
Snorre98 Oct 18, 2024
2a05fe6
adds python module for interview allocation
Snorre98 Oct 18, 2024
fec04c4
moves exceptions to pythin package
Snorre98 Oct 18, 2024
dec54f3
fixes automatic interview allocation place and adds type to view
Snorre98 Oct 18, 2024
c001181
removes logger from views
Snorre98 Oct 18, 2024
5d04cb8
removes commeted out code
Snorre98 Oct 18, 2024
5b89e35
reafactor script structure
Snorre98 Oct 18, 2024
db46498
fixed imports
Snorre98 Oct 18, 2024
18a886e
adds default params to generate_position_interview_schedule
Snorre98 Oct 18, 2024
5d2a226
completes adding types
Snorre98 Oct 20, 2024
642c994
🦮🦮 ruff
Snorre98 Oct 20, 2024
ceb4e5a
Improved doc string for InterviewBlock
Snorre98 Oct 20, 2024
a08f179
adds consideration for shared interviews in block rating
Snorre98 Oct 20, 2024
8f227ab
improved typing in util functions
Snorre98 Oct 20, 2024
d115008
improved doc strings and typing
Snorre98 Oct 20, 2024
5305afb
refactor naming of functions
Snorre98 Oct 20, 2024
7099f8c
large refactor, to implement more efficient checks, as well as improv…
Snorre98 Oct 20, 2024
ebc49e4
naming refactor
Snorre98 Oct 20, 2024
1cadd19
renaming
Snorre98 Oct 20, 2024
277fc27
remove commented code
Snorre98 Oct 20, 2024
b4226bb
removes check not needed
Snorre98 Oct 20, 2024
71dfac6
adds tests
Snorre98 Oct 20, 2024
cbd3c33
renamed test file and fixed interview allocation view
Snorre98 Oct 20, 2024
8a2cfac
ruff
Snorre98 Oct 20, 2024
e7b83ff
deltes my migration babies
Snorre98 Oct 22, 2024
262c920
fixes test, works in Docker on my maching
Snorre98 Oct 22, 2024
a99a632
ruff you dirty dog!
Snorre98 Oct 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from __future__ import annotations

from datetime import datetime, timedelta

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,
InsufficientTimeBlocksError,
AllApplicantsUnavailableError,
NoApplicationsWithoutInterviewsError,
)
from samfundet.automatic_interview_allocation.generate_interview_timeblocks import (
FinalizedTimeBlock,
generate_and_sort_timeblocks,
)

from .utils import (
is_applicant_available,
get_available_interviewers_for_timeslot,
)


def allocate_interviews_for_position(position: RecruitmentPosition) -> 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.
allocation_limit: If set, limits the number of interviews to allocate. If None, allocates for all applicants.

Returns:
The number of interviews allocated.

Raises:
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.
InsufficientTimeBlocksError: If there are not enough time blocks for all applications.
"""
interview_duration = timedelta(minutes=30) # Each interview lasts 30 minutes

timeblocks = generate_and_sort_timeblocks(position)
applications = get_applications_without_interview(position)
check_timeblocks_and_applications(timeblocks, applications, position)

interview_count = allocate_all_interviews(
timeblocks,
applications,
position,
interview_duration,
)

check_allocation_completeness(interview_count, applications, position)

return interview_count


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))


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}')
if not applications:
raise NoApplicationsWithoutInterviewsError(f'No applications without interviews for position: {position.name_en}')


def allocate_all_interviews(
timeblocks: list[FinalizedTimeBlock],
applications: list[RecruitmentApplication],
position: RecruitmentPosition,
interview_duration: timedelta,
) -> 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

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:
block_interview_count = place_interviews_in_block(block, applications, position, interview_duration, current_time)
interview_count += block_interview_count

return interview_count


def place_interviews_in_block(
block: FinalizedTimeBlock,
applications: list[RecruitmentApplication],
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)
current_time = block_start

while current_time + interview_duration <= block['end'] and applications:
application = applications[0] # Get the next application to process
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_interview(
current_time: datetime,
block: FinalizedTimeBlock,
application: RecruitmentApplication,
position: RecruitmentPosition,
interview_duration: timedelta,
) -> bool:
"""Attempt to allocate a single interview at the current time."""
interview_end_time = current_time + interview_duration

# Check for existing interviews
if Interview.objects.filter(applications__recruitment_position__recruitment=position.recruitment, interview_time=current_time).exists():
return False

available_interviewers = get_available_interviewers_for_timeslot(
list(block['available_interviewers']), current_time, interview_end_time, position.recruitment
)

if not available_interviewers:
return False

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


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,
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 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}')
if applications:
raise InsufficientTimeBlocksError(
f'Not enough time blocks to accommodate all applications for position: {position.name_en}. Allocated {interview_count} interviews.'
)
43 changes: 43 additions & 0 deletions backend/samfundet/automatic_interview_allocation/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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


class NoFutureTimeSlotsError(InterviewAllocationError):
"""Raised when there are no time slots available at least 24 hours in the future."""

pass
Loading
Loading