diff --git a/.env.local.template b/.env.local.template index 3e58d9e5..a68bc96b 100644 --- a/.env.local.template +++ b/.env.local.template @@ -12,3 +12,6 @@ AUTH0_ISSUER=#issuer base url, example: example.us.auth0.com AWS_SECRET_ACCESS_KEY=#aws secret access key AWS_ACCESS_KEY_ID=#aws access key id + +TWILIO_ACCOUNT_SID=#twilio account sid +TWILIO_AUTH_TOKEN=#twilio auth token diff --git a/.github/workflows/algorithm.yml b/.github/workflows/algorithm.yml index 96ba20c3..ce22a1e0 100644 --- a/.github/workflows/algorithm.yml +++ b/.github/workflows/algorithm.yml @@ -1,10 +1,10 @@ on: push: paths: - - "algorithm/*" + - "algorithm/**" pull_request: paths: - - "algorithm/*" + - "algorithm/**" workflow_dispatch: jobs: diff --git a/algorithm/README.md b/algorithm/README.md index f40a55b8..0bb231d9 100644 --- a/algorithm/README.md +++ b/algorithm/README.md @@ -1,23 +1,16 @@ # Algoritme -Algoritmen baserer seg på MIP-programmering (Mixed Integer Linear Programming). +**mip_matching** er en pakke for å tildele intervjutider til søkere basert på ledige tider for søkere og komitéer. + +Algoritmen baserer seg på MIP-programmering (Mixed Integer Linear Programming). Se [Modellering.md](./src/Modellering.md) for detaljer. ## Setup Python Venv ```bash cd algorithm python -m venv ".venv" -``` - -``` .\.venv\Scripts\activate pip install -e . pip install -r requirements.txt +pip install pymongo[srv] ``` - -## TODOs - -- [x] Lage funksjon som deler opp fra en komités slot -- [x] Sette opp begrensningene fra modelleringen -- [ ] Flikke litt på modelleringen. -- [ ] Finn ut hvordan man kan preprosessere dataen for å få ned kjøretiden (f. eks ved å lage lister av personer for hver komité.) diff --git a/algorithm/bridge/fetch_applicants_and_committees.py b/algorithm/bridge/fetch_applicants_and_committees.py index b01cf645..98b2ce76 100644 --- a/algorithm/bridge/fetch_applicants_and_committees.py +++ b/algorithm/bridge/fetch_applicants_and_committees.py @@ -1,29 +1,60 @@ from pymongo import MongoClient from dotenv import load_dotenv -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import os import certifi +from typing import List, Dict + +from mip_matching.Committee import Committee +from mip_matching.TimeInterval import TimeInterval +from mip_matching.Applicant import Applicant +from mip_matching.match_meetings import match_meetings, MeetingMatch def main(): periods = fetch_periods() - #Sjekker om perioden er etter søknadstiden og før intervjuslutt og hasSentInterviewtimes er false, og returnerer søkere og komitétider dersom det er tilfelle for period in periods: periodId = str(period["_id"]) - interview_end = datetime.fromisoformat(period["interviewPeriod"]["end"].replace("Z", "+00:00")) application_end = datetime.fromisoformat(period["applicationPeriod"]["end"].replace("Z", "+00:00")) - now = datetime.now(timezone.utc) - if application_end > now and period["hasSentInterviewTimes"] == False and interview_end < now: + #or period["name"] == "Juli Opptak" + if (application_end < now and period["hasSentInterviewTimes"] == False): applicants = fetch_applicants(periodId) committee_times = fetch_committee_times(periodId) - print(applicants) - print(committee_times) - return applicants, committee_times + committee_objects = create_committee_objects(committee_times) + + all_committees = {committee.name: committee for committee in committee_objects} + + applicant_objects = create_applicant_objects(applicants, all_committees) + + print(applicant_objects) + print(committee_objects) + + match_result = match_meetings(applicant_objects, committee_objects) + + send_to_db(match_result, applicants, periodId) + return match_result +def send_to_db(match_result: MeetingMatch, applicants: List[dict], periodId): + load_dotenv() + formatted_results = format_match_results(match_result, applicants, periodId) + print("Sending to db") + print(formatted_results) + + mongo_uri = os.getenv("MONGODB_URI") + db_name = os.getenv("DB_NAME") + client = MongoClient(mongo_uri, tlsCAFile=certifi.where()) + + db = client[db_name] # type: ignore + + collection = db["interviews"] + + collection.insert_many(formatted_results) + + client.close() def connect_to_db(collection_name): load_dotenv() @@ -40,37 +71,97 @@ def connect_to_db(collection_name): return collection, client def fetch_periods(): - collection, client = connect_to_db("period") + collection, client = connect_to_db("periods") - periods = collection.find() - - periods = list(periods) + periods = list(collection.find()) client.close() return periods def fetch_applicants(periodId): - collection, client = connect_to_db("applicant") - - applicants = collection.find({"periodId": periodId}) + collection, client = connect_to_db("applications") - applicants = list(applicants) + applicants = list(collection.find({"periodId": periodId})) client.close() return applicants def fetch_committee_times(periodId): - collection, client = connect_to_db("committee") - - committee_times = collection.find({"periodId": periodId}) + collection, client = connect_to_db("committees") - committee_times = list(committee_times) + committee_times = list(collection.find({"periodId": periodId})) client.close() return committee_times +def format_match_results(match_results: MeetingMatch, applicants: List[dict], periodId) -> List[Dict]: + transformed_results = {} + + for result in match_results['matchings']: + applicant_id = str(result[0]) + + if applicant_id not in transformed_results: + transformed_results[applicant_id] = { + "periodId": periodId, + "applicantId": applicant_id, + "interviews": [] + } + + committee = result[1] + time_interval = result[2] + start = time_interval.start.isoformat() + end = time_interval.end.isoformat() + room = result[3] + + transformed_results[applicant_id]["interviews"].append({ + "start": start, + "end": end, + "committeeName": committee.name, + "room": room + }) + + return list(transformed_results.values()) + +def create_applicant_objects(applicants_data: List[dict], all_committees: dict[str, Committee]) -> set[Applicant]: + applicants = set() + for data in applicants_data: + applicant = Applicant(name=str(data['_id'])) + + optional_committee_names = data.get('optionalCommittees', []) + optional_committees = {all_committees[name] for name in optional_committee_names if name in all_committees} + applicant.add_committees(optional_committees) + + preferences = data.get('preferences', {}) + preference_committees = {all_committees[committee_name] for committee_name in preferences.values() if committee_name in all_committees} + applicant.add_committees(preference_committees) + + for interval_data in data['selectedTimes']: + interval = TimeInterval( + start=datetime.fromisoformat(interval_data['start'].replace("Z", "+00:00")), + end=datetime.fromisoformat(interval_data['end'].replace("Z", "+00:00")) + ) + applicant.add_interval(interval) + + applicants.add(applicant) + return applicants + +def create_committee_objects(committee_data: List[dict]) -> set[Committee]: + committees = set() + for data in committee_data: + committee = Committee(name=data['committee'], interview_length=timedelta(minutes=int(data["timeslot"]))) + for interval_data in data['availabletimes']: + interval = TimeInterval( + start=datetime.fromisoformat(interval_data['start'].replace("Z", "+00:00")), + end=datetime.fromisoformat(interval_data['end'].replace("Z", "+00:00")) + ) + room = interval_data["room"] + committee.add_interview_slot(interval, room) + committees.add(committee) + return committees + + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/algorithm/src/Modellering.md b/algorithm/src/Modellering.md index 3fea01e0..9f1ab19b 100644 --- a/algorithm/src/Modellering.md +++ b/algorithm/src/Modellering.md @@ -1,52 +1,62 @@ -# Modellering av problem gjennom Mixed Integer Linear Programming - -## Nyttige ressurser - -- https://python-mip.readthedocs.io/en/latest/quickstart.html -- https://towardsdatascience.com/mixed-integer-linear-programming-1-bc0ef201ee87 -- https://towardsdatascience.com/mixed-integer-linear-programming-formal-definition-and-solution-space-6b3286d54892 -- https://www.gurobi.com/resources/mixed-integer-programming-mip-a-primer-on-the-basics/ +# Modellering av møtetildelingsproblem gjennom Mixed Integer Linear Programming ## Variabler `p` + - Person `k` + - Komité `t` -- Timeslot (Må gjøres til intervaller etter hvert) + +- Timeslot `m(p, k, t)` + - Binær variabel - Person `p` har møte med komité `k` i timeslot `t` ## Hjelpevariabler `c(p, t)` + - Binære variabler - Tidspunkt `t` passer for person `p` `c(k, t)` + - Heltallsvariabel - Kapasitet for komité `k` på tidspunkt `t` (hvor mange intervju de kan ha på det gitte tidspunktet) ## Begrensninger For alle `p`: - -- `m(p, k, t) <= 1` dersom - - `p` har søkt på komité `k` - - `c(p, t) => 1` - - `c(k, t) => 1` + +- `m(p, k, t_1) + m(p, k, t_2) < 2` for alle gyldige `k, t_1` og `k, t_2`, hvor t_1 og t_2 overlapper eller er innenfor et gitt buffer-intervall. +- `m(p, k, t) <= 1` dersom + - `p` har søkt på komité `k` + - `c(p, t) => 1` + - `c(k, t) => 1` - `m(p, k, t) <= 0` ellers For alle `k`: + - `sum(m(p, k, t)) <= c(k, t)` for alle personer `p` og tidspunkt `t` +## Mål +Maksimere `sum(m(p, k, t))` for alle `p`, `k` og `t`. Altså: Maksimere antall intervjuer som tildeles. -## Mål +### Sekundærmål + +- La intervjuene klumpe seg rundt klokken 12 og dermed også minske hvor mange hull komitéene får i sin intervjuplan. -Maksimere `sum(m(p, k, t))` for alle `p`, `k` og `t` +## Nyttige ressurser + +- https://python-mip.readthedocs.io/en/latest/quickstart.html +- https://towardsdatascience.com/mixed-integer-linear-programming-1-bc0ef201ee87 +- https://towardsdatascience.com/mixed-integer-linear-programming-formal-definition-and-solution-space-6b3286d54892 +- https://www.gurobi.com/resources/mixed-integer-programming-mip-a-primer-on-the-basics/ diff --git a/algorithm/src/mip_matching/Applicant.py b/algorithm/src/mip_matching/Applicant.py index d769ebc7..6172e5fb 100644 --- a/algorithm/src/mip_matching/Applicant.py +++ b/algorithm/src/mip_matching/Applicant.py @@ -83,4 +83,4 @@ def __str__(self) -> str: return self.name def __repr__(self) -> str: - return str(self) + return str(self) \ No newline at end of file diff --git a/algorithm/src/mip_matching/Committee.py b/algorithm/src/mip_matching/Committee.py index 115debc4..29095116 100644 --- a/algorithm/src/mip_matching/Committee.py +++ b/algorithm/src/mip_matching/Committee.py @@ -1,17 +1,12 @@ from __future__ import annotations from datetime import timedelta -import sys -print(sys.path) -print(__name__) -# sys.path.append("C:\\Users\\Jørgen Galdal\\Documents\\lokalSkoleprogrammering\\appkom\\OnlineOpptak\\algorithm\\mip_matching") from mip_matching.Applicant import Applicant +from mip_matching.TimeInterval import TimeInterval from typing import Iterator -# from typing import TYPE_CHECKING -# if TYPE_CHECKING: -# # Unngår cyclic import -from mip_matching.TimeInterval import TimeInterval + +from mip_matching.types import Room class Committee: @@ -28,39 +23,40 @@ class Committee: """ def __init__(self, name: str, interview_length: timedelta = timedelta(minutes=15)): - self.capacities: dict[TimeInterval, int] = dict() + self.interview_slots: dict[TimeInterval, set[Room]] = dict() self.interview_length: timedelta = interview_length self.applicants: set[Applicant] = set() self.name = name - def add_interval(self, interval: TimeInterval, capacity: int = 1) -> None: - """Legger til et nytt intervall med gitt kapasitet hvis intervallet - ikke allerede har en kapasitet for denne komitéen. + def add_interview_slot(self, interval: TimeInterval, room: Room) -> None: + """Legger til et nytt intervall med gitt rom. Når intervaller legges til deles det automatisk opp i intervaller med lik lengde som intervjulengder.""" minimal_intervals = TimeInterval.divide_interval( interval=interval, length=self.interview_length) for interval in minimal_intervals: - if interval not in self.capacities: - self.capacities[interval] = capacity - else: - self.capacities[interval] += capacity - - def add_intervals_with_capacities(self, intervals_with_capacities: dict[TimeInterval, int]): - """Legger til flere tidsintervaller samtidig.""" - for interval, capacity in intervals_with_capacities.items(): - self.add_interval(interval, capacity) + if interval not in self.interview_slots: + self.interview_slots[interval] = set() + self.interview_slots[interval].add(room) def get_intervals_and_capacities(self) -> Iterator[tuple[TimeInterval, int]]: """Generator som returnerer interval-kapasitet-par.""" - for interval, capacity in self.capacities.items(): - yield interval, capacity + for interval, rooms in self.interview_slots.items(): + yield interval, len(rooms) + + def get_capacity(self, interval: TimeInterval) -> int: + """Returnerer komitéens kapasitet ved et gitt interval (ferdiginndelt etter lengde)""" + return len(self.interview_slots[interval]) def get_intervals(self) -> Iterator[TimeInterval]: """Generator som returnerer kun intervallene""" - for interval in self.capacities.keys(): + for interval in self.interview_slots.keys(): yield interval + def get_rooms(self, interval: TimeInterval) -> Iterator[Room]: + for room in self.interview_slots[interval]: + yield room + def _add_applicant(self, applicant: Applicant): """Metode brukt for å holde toveis-assosiasjonen.""" self.applicants.add(applicant) @@ -69,10 +65,6 @@ def get_applicants(self) -> Iterator[Applicant]: for applicant in self.applicants: yield applicant - def get_capacity(self, interval: TimeInterval) -> int: - """Returnerer komitéens kapasitet ved et gitt interval (ferdiginndelt etter lengde)""" - return self.capacities[interval] - def get_applicant_count(self) -> int: return len(self.applicants) diff --git a/algorithm/src/mip_matching/TimeInterval.py b/algorithm/src/mip_matching/TimeInterval.py index f2156d5e..83064dad 100644 --- a/algorithm/src/mip_matching/TimeInterval.py +++ b/algorithm/src/mip_matching/TimeInterval.py @@ -64,6 +64,9 @@ def get_contained_slots(self, slots: list[TimeInterval]): def divide(self, length: timedelta) -> list[TimeInterval]: return TimeInterval.divide_interval(self, length) + def is_within_distance(self, other: TimeInterval, distance: timedelta) -> bool: + return (self.end <= other.start < self.end + distance) or (other.end <= self.start < other.end + distance) + @staticmethod def divide_interval(interval: TimeInterval, length: timedelta) -> list[TimeInterval]: """ @@ -85,29 +88,3 @@ def divide_interval(interval: TimeInterval, length: timedelta) -> list[TimeInter local_end += length return result - - -""" -Dette er gammel kode som nå er flyttet til de passende komité-/søker-klassene. -Foreløpig beholdt for referanse. -""" -# class TimeIntervals: -# def __init__(self, initial_list: list[TimeInterval] = None): -# self.list: list[TimeInterval] = initial_list if initial_list else [] - -# def add(self, interval: TimeInterval): -# self.list.append(interval) - -# def recursive_intersection(self, other: TimeIntervals): -# """ -# Returnerer alle tidsintervallene i *other* som er inneholdt i et av *self* sine intervaller""" -# result = TimeIntervals() - -# for self_interval, other_interval in itertools.product(self.list, other.list): -# if self_interval.contains(other_interval): -# result.add(other_interval) - -# return result - -# def __iter__(self): -# return self.list.__iter__() diff --git a/algorithm/src/mip_matching/match_meetings.py b/algorithm/src/mip_matching/match_meetings.py index 9bbd512e..4f51cf21 100644 --- a/algorithm/src/mip_matching/match_meetings.py +++ b/algorithm/src/mip_matching/match_meetings.py @@ -5,61 +5,85 @@ from mip_matching.Applicant import Applicant import mip -# from typing import TypedDict +from datetime import timedelta, time +from itertools import combinations +from mip_matching.types import Matching, MeetingMatch +from mip_matching.utils import subtract_time -class MeetingMatch(TypedDict): - """Type definition of a meeting match object""" - solver_status: mip.OptimizationStatus - matched_meetings: int - total_wanted_meetings: int - matchings: list[tuple[Applicant, Committee, TimeInterval]] + +# Hvor stort buffer man ønsker å ha mellom intervjuene +APPLICANT_BUFFER_LENGTH = timedelta(minutes=15) + +# Et mål på hvor viktig det er at intervjuer er i nærheten av hverandre +CLUSTERING_WEIGHT = 0.001 + +# Når på dagen man helst vil ha intervjuene rundt +CLUSTERING_TIME_BASELINE = time(12, 00) +MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200) # TODO: Rename variable def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> MeetingMatch: """Matches meetings and returns a MeetingMatch-object""" model = mip.Model(sense=mip.MAXIMIZE) - m = {} + m: dict[Matching, mip.Var] = {} # Lager alle maksimeringsvariabler for applicant in applicants: for committee in applicant.get_committees(): for interval in applicant.get_fitting_committee_slots(committee): - m[(applicant, committee, interval)] = model.add_var( - var_type=mip.BINARY, name=f"({applicant}, {committee}, {interval})") + for room in committee.get_rooms(interval): + m[(applicant, committee, interval, room)] = model.add_var( + var_type=mip.BINARY, name=f"({applicant}, {committee}, {interval}, {room})") # Legger inn begrensninger for at en komité kun kan ha antall møter i et slot lik kapasiteten. for committee in committees: for interval, capacity in committee.get_intervals_and_capacities(): - model += mip.xsum(m[(applicant, committee, interval)] + model += mip.xsum(m[(applicant, committee, interval, room)] for applicant in committee.get_applicants() + for room in committee.get_rooms(interval) + if (applicant, committee, interval, room) in m # type: ignore - if (applicant, committee, interval) in m) <= capacity + ) <= capacity # Legger inn begrensninger for at en person kun har ett intervju med hver komité for applicant in applicants: for committee in applicant.get_committees(): - model += mip.xsum(m[(applicant, committee, interval)] + model += mip.xsum(m[(applicant, committee, interval, room)] + for interval in applicant.get_fitting_committee_slots(committee) + for room in committee.get_rooms(interval) # type: ignore - for interval in applicant.get_fitting_committee_slots(committee)) <= 1 + ) <= 1 - # Legger inn begrensninger for at en person kun kan ha ett intervju på hvert tidspunkt + # Legger inn begrensninger for at en søker ikke kan ha overlappende intervjutider + # og minst har et buffer mellom hvert intervju som angitt for applicant in applicants: - potential_intervals = set() - for applicant_candidate, committee, interval in m: - if applicant == applicant_candidate: - potential_intervals.add(interval) + potential_interviews = set(slot for slot in m.keys() if slot[0] == applicant) - for interval in potential_intervals: + for interview_a, interview_b in combinations(potential_interviews, r=2): + if interview_a[2].intersects(interview_b[2]) or interview_a[2].is_within_distance(interview_b[2], APPLICANT_BUFFER_LENGTH): + model += m[interview_a] + m[interview_b] <= 1 # type: ignore - model += mip.xsum(m[(applicant, committee, interval)] - for committee in applicant.get_committees() - # type: ignore - if (applicant, committee, interval) in m) <= 1 + # Legger til sekundærmål om at man ønsker å sentrere intervjuer rundt CLUSTERING_TIME_BASELINE + clustering_objectives = [] + + for name, variable in m.items(): + applicant, committee, interval, room = name + if interval.start.time() < CLUSTERING_TIME_BASELINE: + relative_distance_from_baseline = subtract_time(CLUSTERING_TIME_BASELINE, + interval.end.time()) / MAX_SCALE_CLUSTERING_TIME + else: + relative_distance_from_baseline = subtract_time(interval.start.time(), + CLUSTERING_TIME_BASELINE) / MAX_SCALE_CLUSTERING_TIME + + clustering_objectives.append( + CLUSTERING_WEIGHT * relative_distance_from_baseline * variable) # type: ignore # Setter mål til å være maksimering av antall møter - model.objective = mip.maximize(mip.xsum(m.values())) + # med sekundærmål om å samle intervjuene rundt CLUSTERING_TIME_BASELINE + model.objective = mip.maximize( + mip.xsum(m.values()) + mip.xsum(clustering_objectives)) # Kjør optimeringen solver_status = model.optimize() @@ -71,7 +95,6 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me if variable.x: antall_matchede_møter += 1 matchings.append(name) - print(f"{name}") antall_ønskede_møter = sum( len(applicant.get_committees()) for applicant in applicants) diff --git a/algorithm/src/mip_matching/types.py b/algorithm/src/mip_matching/types.py new file mode 100644 index 00000000..a7318a28 --- /dev/null +++ b/algorithm/src/mip_matching/types.py @@ -0,0 +1,22 @@ +""" +Typealiaser +""" + +from typing import TypedDict, TYPE_CHECKING +import mip +if TYPE_CHECKING: + # Unngår cyclic import + from mip_matching.Applicant import Applicant + from mip_matching.Committee import Committee + from mip_matching.TimeInterval import TimeInterval + + +type Room = str +type Matching = tuple[Applicant, Committee, TimeInterval, Room] + +class MeetingMatch(TypedDict): + """Type definition of a meeting match object""" + solver_status: mip.OptimizationStatus + matched_meetings: int + total_wanted_meetings: int + matchings: list[Matching] \ No newline at end of file diff --git a/algorithm/src/mip_matching/utils.py b/algorithm/src/mip_matching/utils.py new file mode 100644 index 00000000..b248ae3e --- /dev/null +++ b/algorithm/src/mip_matching/utils.py @@ -0,0 +1,41 @@ +from mip_matching.Applicant import Applicant +from mip_matching.Committee import Committee +from mip_matching.TimeInterval import TimeInterval + +from datetime import time, date, datetime, timedelta + + +def group_by_committee(meetings: list[tuple[Applicant, Committee, TimeInterval]]) -> dict[Committee, list[tuple[Applicant, Committee, TimeInterval]]]: + result = {} + + for applicant, committee, interval in meetings: + if committee not in result: + result[committee] = [] + + result[committee].append((applicant, committee, interval)) + + return result + + +def measure_clustering(meetings: list[tuple[Applicant, Committee, TimeInterval]]) -> int: + grouped_meetings = group_by_committee(meetings) + + holes = 0 + + for _, committee_meetings in grouped_meetings.items(): + committee_meetings.sort(key=lambda meeting: meeting[2].end) + + previous_interval: TimeInterval = committee_meetings[0][2] + for _, _, interval in committee_meetings[1:]: + if not previous_interval.is_within_distance(interval, timedelta(minutes=1)): + holes += 1 + previous_interval = interval + + return holes + + +def subtract_time(minuend: time, subtrahend: time) -> timedelta: + minuend_date = datetime.combine(date.min, minuend) + subtrahend_date = datetime.combine(date.min, subtrahend) + + return minuend_date - subtrahend_date diff --git a/algorithm/tests/CommitteeTest.py b/algorithm/tests/CommitteeTest.py index 4d63ac24..8ffebd52 100644 --- a/algorithm/tests/CommitteeTest.py +++ b/algorithm/tests/CommitteeTest.py @@ -1,21 +1,14 @@ -from __future__ import annotations -from datetime import datetime, timedelta -import unittest -from mip_matching.TimeInterval import TimeInterval -from mip_matching.Committee import Committee +# from __future__ import annotations +# from datetime import datetime, timedelta +# import unittest +# from mip_matching.TimeInterval import TimeInterval +# from mip_matching.Committee import Committee -class ApplicantTest(unittest.TestCase): - def setUp(self) -> None: - self.committee = Committee( - "TestKom", interview_length=timedelta(minutes=30)) - self.committee.add_intervals_with_capacities({ - TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 9, 30)): 1, - TimeInterval(datetime(2024, 8, 24, 8, 30), datetime(2024, 8, 24, 9, 30)): 1 - }) +# class ApplicantTest(unittest.TestCase): +# def setUp(self) -> None: +# self.committee = Committee( +# "TestKom", interview_length=timedelta(minutes=30)) + - def test_capacity_stacking(self) -> None: - self.assertEqual(1, self.committee.get_capacity( - TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 8, 30)))) - self.assertEqual(2, self.committee.get_capacity( - TimeInterval(datetime(2024, 8, 24, 8, 30), datetime(2024, 8, 24, 9, 0)))) + \ No newline at end of file diff --git a/algorithm/tests/mip_test.py b/algorithm/tests/mip_test.py index a8b3a9f8..5c66870f 100644 --- a/algorithm/tests/mip_test.py +++ b/algorithm/tests/mip_test.py @@ -1,6 +1,5 @@ from __future__ import annotations from datetime import datetime, timedelta, date, time -# import ..algorithm.mip_matching.core.Applicant.py as applicant from mip_matching.TimeInterval import TimeInterval from mip_matching.Committee import Committee @@ -12,11 +11,14 @@ import unittest import random +from itertools import combinations + +from mip_matching.types import Matching def print_matchings(committees: list[Committee], intervals: list[TimeInterval], - matchings: list[tuple[Applicant, Committee, TimeInterval]]): + matchings: list[Matching]): print("Tid".ljust(15), end="|") print("|".join(str(com).ljust(8) for com in committees)) @@ -26,7 +28,7 @@ def print_matchings(committees: list[Committee], for committee in committees: name = "" cands = [a.name for a, c, - i in matchings if interval == i and c == committee] + i, r in matchings if interval == i and c == committee] name = cands[0] if len(cands) > 0 else "" print(name.rjust(8), end="|") @@ -36,17 +38,17 @@ def print_matchings(committees: list[Committee], class MipTest(unittest.TestCase): - def check_constraints(self, matchings: list[tuple[Applicant, Committee, TimeInterval]]): + def check_constraints(self, matchings: list[Matching]): """Checks if the constraints are satisfied in the provided matchings. TODO: Add more constraint tests.""" self.assertEqual(len(matchings), len(set((applicant, interval) - for applicant, _, interval in matchings)), + for applicant, _, interval, _ in matchings)), "Constraint \"Applicant can only have one meeting during each TimeInterval\" failed.") load_per_committee_per_slot: dict[Committee, dict[TimeInterval, int]] = { } - for _, committee, interval in matchings: + for _, committee, interval, _ in matchings: if committee not in load_per_committee_per_slot: load_per_committee_per_slot[committee] = {} @@ -59,20 +61,36 @@ def check_constraints(self, matchings: list[tuple[Applicant, Committee, TimeInte self.assertGreaterEqual(committee.get_capacity(interval), load, f"Constraint \"Number of interviews per slot per committee cannot exceed capacity\" failed for Committee {committee} and interval {interval}") + # Overlapping interviews per applicant + interviews_per_applicant: dict[Applicant, + set[Matching]] = {} + for interview in matchings: + applicant = interview[0] + if applicant not in interviews_per_applicant: + interviews_per_applicant[applicant] = set() + + interviews_per_applicant[applicant].add(interview) + + for applicant, interviews in interviews_per_applicant.items(): + for interview_a, interview_b in combinations(interviews, r=2): + self.assertFalse(interview_a[2].intersects(interview_b[2]), f"Constraint \"Applicant cannot have time-overlapping interviews\" failed for { + applicant}'s interviews with {interview_a[1]} ({interview_a[1]}) and {interview_b[1]} ({interview_b[2]})") + def test_fixed_small(self): """Small, fixed test with all capacities set to one""" appkom = Committee(name="Appkom") - appkom.add_intervals_with_capacities( - {TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 9, 15)): 1}) + + appkom.add_interview_slot( + TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 9, 15)), "AppkomRom") oil = Committee(name="OIL") - oil.add_intervals_with_capacities( - {TimeInterval(datetime(2024, 8, 24, 9, 0), datetime(2024, 8, 24, 9, 30)): 1}) + oil.add_interview_slot( + TimeInterval(datetime(2024, 8, 24, 9, 0), datetime(2024, 8, 24, 9, 30)), "OilRom") prokom = Committee(name="Prokom") - prokom.add_intervals_with_capacities({TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 8, 45)): 1, - TimeInterval(datetime(2024, 8, 24, 9, 0), datetime(2024, 8, 24, 9, 30)): 1}) + prokom.add_interview_slot(TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 8, 45)), "ProkomRom") + prokom.add_interview_slot(TimeInterval(datetime(2024, 8, 24, 9, 0), datetime(2024, 8, 24, 9, 30)), "ProkomRom") committees: set[Committee] = {appkom, oil, prokom} @@ -202,8 +220,9 @@ def get_random_interval(interval_date: date, interval_length_min: timedelta, int for _ in range(ANTALL_INTERVALL_FORSØK_KOMITE): interval_date = fake.date_between_dates(START_DATE, END_DATE) - committee.add_intervals_with_capacities({get_random_interval(interval_date, INTERVALLENGDE_PER_KOMTIE_MIN, INTERVALLENGDE_PER_KOMTIE_MAKS): random.randint( - KAPASITET_PER_INTERVALL_MIN, KAPASITET_PER_INTERVALL_MAKS)}) + for _ in range(random.randint(KAPASITET_PER_INTERVALL_MIN, KAPASITET_PER_INTERVALL_MAKS)): + committee.add_interview_slot(get_random_interval(interval_date, INTERVALLENGDE_PER_KOMTIE_MIN, INTERVALLENGDE_PER_KOMTIE_MAKS), + room=str(random.getrandbits(128))) # Lar hver søker søke på tilfeldige komiteer committees_list = list(committees) @@ -282,8 +301,9 @@ def get_random_interval(interval_date: date) -> TimeInterval: for _ in range(ANTALL_INTERVALL_FORSØK_KOMITE): interval_date = fake.date_between_dates(START_DATE, END_DATE) - committee.add_intervals_with_capacities({get_random_interval(interval_date): random.randint( - KAPASITET_PER_INTERVALL_MIN, KAPASITET_PER_INTERVALL_MAKS)}) + for _ in range(random.randint(KAPASITET_PER_INTERVALL_MIN, KAPASITET_PER_INTERVALL_MAKS)): + committee.add_interview_slot(get_random_interval(interval_date), + room=str(random.getrandbits(128))) # Lar hver søker søke på tilfeldige komiteer committees_list = list(committees) @@ -350,8 +370,10 @@ def randomized_test(self, # Gir intervaller til hver komité. for committee in committees: - committee.add_intervals_with_capacities({slot: 1 for slot in random.sample( - SLOTS, random.randint(ANTALL_SLOTS_PER_KOMITE_MIN, ANTALL_SLOTS_PER_KOMITE_MAKS))}) + + for slot in random.sample( + SLOTS, random.randint(ANTALL_SLOTS_PER_KOMITE_MIN, ANTALL_SLOTS_PER_KOMITE_MAKS)): + committee.add_interview_slot(slot, str(random.getrandbits(128))) # Lar hver søker søke på tilfeldige komiteer committees_list = list(committees) diff --git a/components/CommitteeAboutCard.tsx b/components/CommitteeAboutCard.tsx index 502719b3..6699e2b7 100644 --- a/components/CommitteeAboutCard.tsx +++ b/components/CommitteeAboutCard.tsx @@ -15,19 +15,21 @@ const CommitteeAboutCard = ({ return (
{email}
{application_description || "Ingen opptaksbeskrivelse"}
diff --git a/components/DropdownMenu.tsx b/components/DropdownMenu.tsx
index a53bd761..ed0aa321 100644
--- a/components/DropdownMenu.tsx
+++ b/components/DropdownMenu.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useEffect, useRef } from "react";
import ThemeToggle from "./ThemeToggle";
import Link from "next/link";
@@ -25,6 +25,8 @@ const DropdownMenu = ({
handleLogout,
toggleDropdown,
}: Props) => {
+ const menuRef = useRef