diff --git a/api/api/serializers.py b/api/api/serializers.py index ef30f00b..9a4ca97c 100644 --- a/api/api/serializers.py +++ b/api/api/serializers.py @@ -1,19 +1,29 @@ from rest_framework.serializers import ModelSerializer from api.models import Department, Discipline, Class + class DepartmentSerializer(ModelSerializer): class Meta: model = Department fields = '__all__' + + class ClassSerializer(ModelSerializer): class Meta: model = Class fields = '__all__' -class DisciplineSerializer(ModelSerializer): - department = DepartmentSerializer() - classes = ClassSerializer(many=True) - + +class DisciplineSerializerSchedule(ModelSerializer): class Meta: model = Discipline - fields = '__all__' \ No newline at end of file + fields = '__all__' + + +class DisciplineSerializer(DisciplineSerializerSchedule): + department = DepartmentSerializer() + classes = ClassSerializer(many=True) + + +class ClassSerializerSchedule(ClassSerializer): + discipline = DisciplineSerializerSchedule() diff --git a/api/api/tests/test_schedule_api.py b/api/api/tests/test_schedule_api.py new file mode 100644 index 00000000..a2517c38 --- /dev/null +++ b/api/api/tests/test_schedule_api.py @@ -0,0 +1,124 @@ +from rest_framework.test import APITestCase, APIRequestFactory +from utils.db_handler import get_or_create_department, get_or_create_discipline, create_class +from random import randint +import json + +class TestScheduleAPI(APITestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.content_type = 'application/json' + self.api_url = '/courses/schedule/' + self.department = get_or_create_department( + code='518', year='2023', period='2') + self.discipline = get_or_create_discipline( + name='CÁLCULO 1', code='MAT518', department=self.department) + self.class_1 = create_class(teachers=['RICARDO FRAGELLI'], classroom='S9', schedule='46M34', days=[ + 'Quarta-Feira 10:00 às 11:50', 'Sexta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline) + self.class_2 = create_class(teachers=['VINICIUS RISPOLI'], classroom='S1', schedule='24M34', days=[ + 'Segunda-Feira 10:00 às 11:50', 'Quarta-Feira 10:00 às 11:50'], _class="2", special_dates=[], discipline=self.discipline) + self.discipline_2 = get_or_create_discipline( + name='CÁLCULO 2', code='MAT519', department=self.department) + self.class_3 = create_class(teachers=['LUIZA YOKO'], classroom='S1', schedule='56M23', days=[ + 'Segunda-Feira 10:00 às 11:50', 'Quarta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_2) + self.class_4 = create_class(teachers=['Tatiana'], classroom='S1', schedule='7M1234', days=[ + 'Sábado 08:00 às 11:50'], _class="2", special_dates=[], discipline=self.discipline_2) + + def test_with_correct_parameters(self): + """ + Testa a geração de horários com todos os parâmetros corretos + Os parâmetros enviados permitem que pelo menos uma solução seja encontrada + """ + body = json.dumps({ + 'preference': [3, 2, 1], + 'classes': [self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 200) + self.assertTrue(len(response.data) > 0) + + def test_with_conflicting_classes(self): + """ + Testa a geração de horários com classes conflitantes + """ + body = json.dumps({ + 'preference': [3, 2, 1], + 'classes': [self.class_1.id, self.class_3.id] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 200) + self.assertFalse(len(response.data)) + + def test_with_invalid_class(self): + """ + Testa a geração de horários com uma classe inválida + """ + + classes_ids = [self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id] + random_id = randint(1, 10000) + + while(random_id in classes_ids): # pragma: no cover + random_id = randint(1, 10000) + + body = json.dumps({ + 'preference': [3, 2, 1], + 'classes': classes_ids + [random_id] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 400) + + def test_with_invalid_preference(self): + """ + Testa a geração de horários com uma preferência inválida + """ + body = json.dumps({ + 'preference': [3, 2, 1, 4], + 'classes': [self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 400) + + def test_with_invalid_preference_type(self): + """ + Testa a geração de horários com uma preferência inválida (tipo) + """ + body = json.dumps({ + 'preference': [3, 2, '1'], + 'classes': [self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 400) + + def test_with_no_classes(self): + """ + Testa a geração de horários sem classes + """ + body = json.dumps({ + 'classes': [] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 400) + + def test_with_no_preference(self): + """ + Testa a geração de horários sem preferência + """ + body = json.dumps({ + 'classes': [self.class_1.id] + }) + + response = self.client.post(self.api_url, body, content_type=self.content_type) + + self.assertEqual(response.status_code, 200) + self.assertTrue(len(response.data) > 0) \ No newline at end of file diff --git a/api/api/urls.py b/api/api/urls.py index 42e281f2..23e9530a 100644 --- a/api/api/urls.py +++ b/api/api/urls.py @@ -5,5 +5,6 @@ urlpatterns = [ path('', views.Search.as_view(), name="search"), - path('year-period/', views.YearPeriod.as_view(), name="year-period") + path('year-period/', views.YearPeriod.as_view(), name="year-period"), + path('schedule/', views.Schedule.as_view(), name="schedule") ] diff --git a/api/api/views.py b/api/api/views.py index 2ab4cb10..bdc7ba66 100644 --- a/api/api/views.py +++ b/api/api/views.py @@ -10,11 +10,13 @@ from drf_yasg import openapi from .swagger import Errors from api import serializers +from utils.schedule_generator import ScheduleGenerator MAXIMUM_RETURNED_DISCIPLINES = 8 ERROR_MESSAGE = "no valid argument found for 'search', 'year' or 'period'" MINIMUM_SEARCH_LENGTH = 4 ERROR_MESSAGE_SEARCH_LENGTH = f"search must have at least {MINIMUM_SEARCH_LENGTH} characters" +MAXIMUM_RETURNED_SCHEDULES = 5 class Search(APIView): @@ -123,3 +125,83 @@ def get(self, request: request.Request, *args, **kwargs) -> response.Response: } return response.Response(data, status.HTTP_200_OK) + + +class Schedule(APIView): + @swagger_auto_schema( + operation_description="Gera possíveis horários de acordo com as aulas escolhidas com preferência de turno", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + title="body", + required=['classes'], + properties={ + 'classes': openapi.Schema( + description="Lista de ids de aulas escolhidas", + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + description="Id da aula", + type=openapi.TYPE_INTEGER + ) + ), + 'preference': openapi.Schema( + description="Lista de preferências (manhã, tarde, noite)", + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + description="Define o peso de cada turno", + type=openapi.TYPE_INTEGER, + enum=[1, 2, 3] + ) + ) + } + ), + responses = { + 200: openapi.Response('OK', serializers.ClassSerializerSchedule(many=True)), + **Errors([400]).retrieve_erros() + } + ) + def post(self, request: request.Request, *args, **kwargs) -> response.Response: + """ + View para gerar horários. + Funcionamento: Recebe uma lista de ids de classes e uma lista de preferências + e verifica se as classes e preferências são válidas. + Caso sejam válidas, gera os horários e retorna uma lista de horários. + """ + + classes_id = request.data.get('classes', None) + preference = request.data.get('preference', None) + preference_valid = preference is not None and isinstance(preference, list) and all( + isinstance(x, int) for x in preference) and len(preference) == 3 + classes_valid = classes_id is not None and isinstance( + classes_id, list) and all(isinstance(x, int) for x in classes_id) and len(classes_id) > 0 + + if preference is not None and not preference_valid: + """Retorna um erro caso a preferência não seja uma lista de 3 inteiros""" + return response.Response( + { + "errors": "preference must be a list of 3 integers" + }, status.HTTP_400_BAD_REQUEST) + + if not classes_valid: + """Retorna um erro caso a lista de ids de classes não seja enviada""" + return response.Response( + { + "errors": "classes is required and must be a list of integers with at least one element" + }, status.HTTP_400_BAD_REQUEST) + + try: + schedule_generator = ScheduleGenerator(classes_id, preference) + schedules = schedule_generator.generate() + except Exception as error: + """Retorna um erro caso ocorra algum erro ao criar o gerador de horários""" + return response.Response( + { + "errors": str(error) + }, status.HTTP_400_BAD_REQUEST) + + data = [] + + for schedule in schedules[:MAXIMUM_RETURNED_SCHEDULES]: + data.append( + list(map(lambda x: serializers.ClassSerializerSchedule(x).data, schedule))) + + return response.Response(data, status.HTTP_200_OK) diff --git a/api/utils/db_handler.py b/api/utils/db_handler.py index a73922f2..c7638bb9 100644 --- a/api/utils/db_handler.py +++ b/api/utils/db_handler.py @@ -50,3 +50,6 @@ def filter_disciplines_by_year_and_period(year: str, period: str, disciplines: D """Filtra as disciplinas pelo ano e período.""" return disciplines.filter(department__year=year, department__period=period) +def get_class_by_id(id: int, classes: Class = Class.objects) -> Class: + """Filtra as turmas pelo id.""" + return classes.get(id=id) diff --git a/api/utils/schedule_generator.py b/api/utils/schedule_generator.py new file mode 100644 index 00000000..eafc14d4 --- /dev/null +++ b/api/utils/schedule_generator.py @@ -0,0 +1,184 @@ +from itertools import product +from collections import defaultdict +from .db_handler import get_class_by_id +from re import search +from api.models import Class + +MAXIMUM_CLASSES_FOR_DISCIPLINE = 4 +MINIMUM_PREFERENCE_RANGE = 1 +MAXIMUM_PREFERENCE_RANGE = 3 +MAXIMUM_DISCIPLINES = 11 + +LIMIT_ERROR_MESSAGE = f"you can only send {MAXIMUM_DISCIPLINES} disciplines and {MAXIMUM_CLASSES_FOR_DISCIPLINE} classes for each discipline." +PREFERENCE_RANGE_ERROR = f"preference must be a list of integers with range [{MINIMUM_PREFERENCE_RANGE}, {MAXIMUM_PREFERENCE_RANGE}]" + +def check(function): + """ + Decorator para verificar se a classe é válida antes de executar a função + """ + + def wrapper(self, *args, **kwargs): + if not self.is_valid(): + return None + return function(self, *args, **kwargs) + + return wrapper + + +class ScheduleGenerator: + """Classe que representa um gerador de horários.""" + available_letters = "MTN" + + def __init__(self, classes_id: list[int], preference: list = None): + self.schedule_info = defaultdict(lambda: None) + self.preference = preference + self.generated = False + self._validate_preference() + self._get_and_validate_classes(classes_id=set(classes_id)) + self._make_disciplines_list() + self._validate_parameters_length() + + def _validate_preference(self) -> None: + self.valid = self.preference is None or all(isinstance(x, int) and MINIMUM_PREFERENCE_RANGE <= x <= MAXIMUM_PREFERENCE_RANGE for x in self.preference) + + if not self.valid: + raise ValueError(PREFERENCE_RANGE_ERROR) + + @check + def _get_and_validate_classes(self, classes_id: set[int]) -> None: + self.disciplines = defaultdict(list) + self.classes = dict() + self.schedules = [] + + if not len(classes_id): + self.valid = False + return + + for class_id in classes_id: + try: + _class = get_class_by_id(id=class_id) + + self.classes[class_id] = _class + self.disciplines[_class.discipline].append(class_id) + self._add_schedule_code(_class.schedule) + except Class.DoesNotExist: + self.valid = False + raise ValueError(f"class with id {class_id} does not exist.") + + @check + def _validate_parameters_length(self) -> None: + if len(self.disciplines) > MAXIMUM_DISCIPLINES: # pragma: no cover + self.valid = False + + for classes in self.disciplines.values(): + if len(classes) > MAXIMUM_CLASSES_FOR_DISCIPLINE: + self.valid = False + break + + if not self.valid: + raise ValueError(LIMIT_ERROR_MESSAGE) + + def is_valid(self) -> bool: + return self.valid + + def _get_priority(self, days: list, turn: list[str], letter: str): + """ + Calcula a prioridade de uma disciplina ser escolhida para a grade de horários. + Quanto mais cedo for o horário, maior será a prioridade, independentemente do turno. + Quanto mais aulas na semana a disciplina tiver, maior será a prioridade. + """ + turn_priority = 5 * (len(turn)) - sum(map(int, turn)) + days_quantity = len(days) + + priority = self.preference[self.available_letters.find( + letter)] * (turn_priority + days_quantity) + + return priority + + def _add_schedule_code(self, schedules: str) -> None: + if self.schedule_info[schedules] is not None: + return + + regex = f"[{self.available_letters}]" + schedules_list = schedules.split() + + """Cria um dicionário com a prioridade e os horários + em produto cartesiano de uma disciplina""" + schedules_dict = { + "priority": 0, + "times": set() + } + + for schedule in schedules_list: + match = search(regex, schedule) + schedule = list(schedule) + + days = schedule[:match.start()] + turn = schedule[match.start() + 1:] + letter = match.group() + + values = [days, [letter], turn] + code_product = product(*values) + + if self.preference is not None: + schedules_dict["priority"] += self._get_priority( + days, turn, letter) + + schedules_dict["times"] = schedules_dict["times"].union( + set(code_product)) + + self.schedule_info[schedules] = schedules_dict + + @check + def _make_disciplines_list(self) -> None: + self.disciplines_list = [] + + for classes in self.disciplines.values(): + self.disciplines_list.append(classes) + + def _is_valid_schedule(self, schedule: tuple) -> bool: + codes_counter = 0 + schedule_codes = set() + + for class_id in schedule: + _class = self.classes[class_id] + + schedule_code = self.schedule_info[_class.schedule]["times"] + codes_counter += len(schedule_code) + schedule_codes = schedule_codes.union(schedule_code) + + if codes_counter > len(schedule_codes): + """Caso o contador seja maior do que a união dos produtos cartesianos com o horário pretendido, + o horário não é válido, pois há horários que se sobrepõem""" + return False + + return True + + def _add_schedule(self, schedule: tuple) -> None: + parsed_schedule = [] + + for class_id in schedule: + _class = self.classes[class_id] + parsed_schedule.append(_class) + + self.schedules.append(parsed_schedule) + + @check + def generate(self) -> list | None: + if self.generated: + return self.schedules + + self.generated = True + possible_schedules = product(*self.disciplines_list) + + for schedule in possible_schedules: + if self._is_valid_schedule(schedule): + self._add_schedule(schedule) + + return self.sort_by_priority() + + def sort_by_priority(self): + self.schedules.sort(key=lambda priority: sum(map( + lambda _class: self.schedule_info[_class.schedule]["priority"], priority)), reverse=True) + + return self.schedules diff --git a/api/utils/tests/test_database_handler.py b/api/utils/tests/test_database_handler.py index ab59f062..189a02ca 100644 --- a/api/utils/tests/test_database_handler.py +++ b/api/utils/tests/test_database_handler.py @@ -178,4 +178,33 @@ def test_get_best_similarities_by_name(self): self.assertTrue(disciplines.count() == 3) self.assertTrue(discipline in disciplines) self.assertTrue(discipline_2 in disciplines) - self.assertTrue(discipline_3 in disciplines) \ No newline at end of file + self.assertTrue(discipline_3 in disciplines) + + def test_get_class_by_id(self): + department = dbh.get_or_create_department( + code = 'MAT', + year = '2027', + period = '1' + ) + + discipline = dbh.get_or_create_discipline( + name = 'Cálculo 1', + code = 'MAT0026', + department = department + ) + + _class = dbh.create_class( + teachers = ['Luiza Yoko'], + classroom = 'S9', + schedule = '46M34', + days = ['Quarta-Feira 10:00 às 11:50', 'Sexta-Feira 10:00 às 11:50'], + _class = "1", + special_dates=[], + discipline = discipline + ) + + class_from_db = dbh.get_class_by_id( + id = _class.id + ) + + self.assertTrue(class_from_db == _class) \ No newline at end of file diff --git a/api/utils/tests/test_schedule_generator.py b/api/utils/tests/test_schedule_generator.py new file mode 100644 index 00000000..cba459ca --- /dev/null +++ b/api/utils/tests/test_schedule_generator.py @@ -0,0 +1,156 @@ +from rest_framework.test import APITestCase +from utils import db_handler as dbh +from utils.schedule_generator import ScheduleGenerator, LIMIT_ERROR_MESSAGE, PREFERENCE_RANGE_ERROR +from random import randint + +class TestSchedule(APITestCase): + def setUp(self): + self.department = dbh.get_or_create_department( + code = 'CIC', + year = '2030', + period = '2' + ) + self.discipline_1 = dbh.get_or_create_discipline( + name = 'Programação Competitiva', + code = 'CIC1234', + department = self.department + ) + self.class_1 = dbh.create_class( + teachers = ['Edson Alves'], + classroom = 'I6', + schedule = '46T34', + days=['Quarta-Feira 16:00 às 17:50', 'Sexta-Feira 16:00 às 17:50'], + _class = "1", + special_dates= [], + discipline = self.discipline_1 + ) + self.class_2 = dbh.create_class( + teachers = ['Edson Alves'], + classroom = 'MOCAP', + schedule = '35T12', + days = ['Terça-Feira 14:00 às 15:50', 'Quinta-Feira 14:00 às 15:50'], + _class = "2", + special_dates = [], + discipline = self.discipline_1 + ) + self.class_3 = dbh.create_class( + teachers = ['VINICIUS RUELA'], + classroom = 'PJC', + schedule = '24T45', + days = ['Segunda-Feira 16:00 às 17:50', 'Quarta-Feira 16:00 às 17:50'], + _class = "3", + special_dates=[], + discipline = self.discipline_1 + ) + self.class_4 = dbh.create_class( + teachers = ['VINICIUS RUELA'], + classroom = 'PJC', + schedule = '35T12', + days=['Terça-Feira 14:00 às 15:50', 'Quinta-Feira 14:00 às 15:50'], + _class = "4", + special_dates=[], + discipline = self.discipline_1 + ) + self.class_5 = dbh.create_class( + teachers = ["A definir"], + classroom = 'A definir', + schedule = '7M1234', + days=['Sábado 08:00 às 11:50'], + _class = "5", + special_dates=[], + discipline = self.discipline_1 + ) + self.discipline_2 = dbh.get_or_create_discipline( + name = 'Estrutura de Dados', + code = 'CIC1000', + department = self.department + ) + self.class_6 = dbh.create_class( + teachers = ['Fabiana'], + classroom = 'MOCAP', + schedule = '35T12', + days=['Terça-Feira 14:00 às 15:50', 'Quinta-Feira 14:00 às 15:50'], + _class = "1", + special_dates=[], + discipline = self.discipline_2 + ) + + def test_with_correct_parameters(self): + """ + Testa a geração de horários com todos os parâmetros corretos + Os parâmetros enviados permitem que pelo menos uma solução seja encontrada + """ + + schedule_generator = ScheduleGenerator(classes_id=[self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id], preference=[3, 2, 1]) + schedules = schedule_generator.generate() + + self.assertEqual(len(schedules), 4) + + def test_with_higher_classes_limit(self): + """ + Testa a geração de horários com um limite de classes maior que o número de classes permitidas para uma matéria + """ + + try: + schedule_generator = ScheduleGenerator(classes_id=[self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id, self.class_5.id], preference=[3, 2, 1]) + except Exception as error: + self.assertEqual(str(error), LIMIT_ERROR_MESSAGE) + + def test_with_conflicting_classes(self): + """ + Testa a geração de horários com classes conflitantes + """ + + schedule_generator = ScheduleGenerator(classes_id=[self.class_4.id, self.class_6.id]) + schedules = schedule_generator.generate() + + self.assertFalse(len(schedules)) + + def test_with_empty_classes(self): + """ + Testa a geração de horários com uma lista de classes vazia + """ + + schedule_generator = ScheduleGenerator(classes_id=[]) + schedules = schedule_generator.generate() + + self.assertIsNone(schedules) + + def test_with_invalid_class(self): + """ + Testa a geração de horários com uma classe inválida + """ + + classes_ids = [self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id] + random_id = randint(1, 10000) + + while(random_id in classes_ids): # pragma: no cover + random_id = randint(1, 10000) + + try: + schedule_generator = ScheduleGenerator(classes_id=classes_ids + [random_id]) + except Exception as error: + self.assertEqual(str(error), f"class with id {random_id} does not exist.") + + def test_make_twice(self): + """ + Testa a geração de horários duas vezes + """ + + schedule_generator = ScheduleGenerator(classes_id=[self.class_1.id, self.class_2.id, self.class_3.id, self.class_4.id], preference=[3, 2, 1]) + + schedules = schedule_generator.generate() + self.assertEqual(len(schedules), 4) + + schedules = schedule_generator.generate() + self.assertEqual(len(schedules), 4) + + def test_with_invalid_preference(self): + """ + Testa a geração de horários com preferência inválida + """ + + try: + schedule_generator = ScheduleGenerator(classes_id=[self.class_1.id], preference=[1, 2, '3']) + except Exception as error: + self.assertEqual(str(error), PREFERENCE_RANGE_ERROR) \ No newline at end of file