diff --git a/api/api/admin.py b/api/api/admin.py index a1a55714..035d2d07 100644 --- a/api/api/admin.py +++ b/api/api/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin -from .models import Department, Discipline, Class +from .models import Department, Discipline, Class, Schedule + +from utils.json_pretty import json_prettify @admin.register(Department) @@ -21,3 +23,16 @@ class ClassAdmin(admin.ModelAdmin): list_display = ['discipline', 'classroom', 'schedule'] search_fields = ['discipline__name'] ordering = ['discipline__name'] + + +@admin.register(Schedule) +class ScheduleAdmin(admin.ModelAdmin): + list_display = ['id', 'user'] + exclude = ('classes', ) + readonly_fields = ('classes_pretty', ) + ordering = ['id'] + + def classes_pretty(self, obj): + return json_prettify(obj.classes) + + classes_pretty.short_description = 'Classes' diff --git a/api/api/migrations/0007_schedule.py b/api/api/migrations/0007_schedule.py new file mode 100644 index 00000000..5faa1927 --- /dev/null +++ b/api/api/migrations/0007_schedule.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-12-06 23:40 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0006_merge_20231127_2255'), + ] + + operations = [ + migrations.CreateModel( + name='Schedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('classes', models.JSONField(default=list)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/api/api/models.py b/api/api/models.py index b3fd1148..84d8f86c 100644 --- a/api/api/models.py +++ b/api/api/models.py @@ -1,6 +1,8 @@ from django.db import models from unidecode import unidecode from django.contrib.postgres.fields import ArrayField +from users.models import User + class Department(models.Model): """Classe que representa um departamento. @@ -15,6 +17,7 @@ class Department(models.Model): def __str__(self): return self.code + class Discipline(models.Model): """Classe que representa uma disciplina. name:str -> Nome da disciplina @@ -25,15 +28,17 @@ class Discipline(models.Model): name = models.CharField(max_length=128) unicode_name = models.CharField(max_length=128, default='') code = models.CharField(max_length=64) - department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='disciplines') + department = models.ForeignKey( + Department, on_delete=models.CASCADE, related_name='disciplines') def __str__(self): return self.name - + def save(self, *args, **kwargs): self.unicode_name = unidecode(self.name).casefold() super(Discipline, self).save(*args, **kwargs) + class Class(models.Model): """Classe que representa uma turma. teachers:list -> Lista de professores da turma @@ -48,7 +53,9 @@ class Class(models.Model): schedule = models.CharField(max_length=512) days = ArrayField(models.CharField(max_length=64)) _class = models.CharField(max_length=64) - discipline = models.ForeignKey(Discipline, on_delete=models.CASCADE, related_name='classes') + discipline = models.ForeignKey( + Discipline, on_delete=models.CASCADE, related_name='classes') + special_dates = ArrayField( ArrayField( models.CharField(max_length=256), @@ -59,3 +66,16 @@ class Class(models.Model): def __str__(self): return self._class + + +class Schedule(models.Model): + """Classe que representa um horário. + user:User -> Usuário do horário + classes:list -> Lista de turmas do horário + """ + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='schedules') + classes = models.JSONField(default=list) + + def __str__(self): + return f'Class: {self.id} - User: {self.user.email}' diff --git a/api/api/serializers.py b/api/api/serializers.py index 9a4ca97c..c7f9a5b0 100644 --- a/api/api/serializers.py +++ b/api/api/serializers.py @@ -15,13 +15,14 @@ class Meta: class DisciplineSerializerSchedule(ModelSerializer): + department = DepartmentSerializer() + class Meta: model = Discipline fields = '__all__' class DisciplineSerializer(DisciplineSerializerSchedule): - department = DepartmentSerializer() classes = ClassSerializer(many=True) diff --git a/api/api/tests/test_error_request_body_schedule_save.py b/api/api/tests/test_error_request_body_schedule_save.py new file mode 100644 index 00000000..69eef966 --- /dev/null +++ b/api/api/tests/test_error_request_body_schedule_save.py @@ -0,0 +1,169 @@ +class ErrorRequestBodyScheduleSave(): + + def test_save_incorrect_schedule_with_empty_request_body(self): + """ + Testa o salvamento de uma grade horária com um corpo de requisição vazio. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request() + + error_msg = 'the request body must not be empty' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_non_list_request_body(self): + """ + Testa o salvamento de uma grade horária com um corpo de requisição que não é uma lista. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule="""{ + "hey": "there" + }""") + + error_msg = 'the request body must be a list of classes' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_non_dict_class(self): + """ + Testa o salvamento de uma grade horária com uma turma que não é um dicionário. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule=[1, 2, 3]) + + error_msg = 'each class must be a object structure' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_no_discipline_key(self): + """ + Testa o salvamento de uma grade horária com uma turma que não tem a chave "discipline". + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule="""[{ + "schedule": "35T23", + "class": "1", + "teachers": ["EDSON ALVES DA COSTA JUNIOR"], + "classroom": "FGA - I8", + "days": ["Terça-feira 14:00 às 15:50", "Quinta-feira 14:00 às 15:50"], + "special_dates": [] + + }]""") + + error_msg = 'the class must have the discipline key' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_non_dict_discipline(self): + """ + Testa o salvamento de uma grade horária com uma disciplina que não é um dicionário. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule="""[{ + "discipline": 1, + "schedule": "35T23", + "class": "1", + "teachers": ["EDSON ALVES DA COSTA JUNIOR"], + "classroom": "FGA - I8", + "days": ["Terça-feira 14:00 às 15:50", "Quinta-feira 14:00 às 15:50"], + "special_dates": [] + + }]""") + + error_msg = 'the discipline must be a object structure' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_no_discipline_name_key(self): + """ + Testa o salvamento de uma grade horária com uma disciplina que não tem a chave "name". + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule="""[{ + "discipline": {}, + "schedule": "35T23", + "class": "1", + "teachers": ["EDSON ALVES DA COSTA JUNIOR"], + "classroom": "FGA - I8", + "days": ["Terça-feira 14:00 às 15:50", "Quinta-feira 14:00 às 15:50"], + "special_dates": [] + + }]""") + + error_msg = 'the discipline must have the name key' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_no_dict_discipline_department(self): + """ + Testa o salvamento de uma grade horária com uma disciplina que não tem a chave "department". + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule="""[{ + "discipline": { + "name": "CÁLCULO 1", + "code": "MAT0025", + "department": [] + }, + "schedule": "35T23", + "class": "1", + "teachers": ["EDSON ALVES DA COSTA JUNIOR"], + "classroom": "FGA - I8", + "days": ["Terça-feira 14:00 às 15:50", "Quinta-feira 14:00 às 15:50"], + "special_dates": [] + + }]""") + + error_msg = 'the department must be a object structure' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_no_dict_discipline_department_year(self): + """ + Testa o salvamento de uma grade horária com uma disciplina que não tem a chave "year". + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + response = self.make_post_request(schedule="""[{ + "discipline": { + "name": "CÁLCULO 1", + "code": "MAT0025", + "department": { + "period": "2" + } + }, + "schedule": "35T23", + "class": "1", + "teachers": ["EDSON ALVES DA COSTA JUNIOR"], + "classroom": "FGA - I8", + "days": ["Terça-feira 14:00 às 15:50", "Quinta-feira 14:00 às 15:50"], + "special_dates": [] + + }]""") + + error_msg = 'the department must have the year key' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) diff --git a/api/api/tests/test_schedule_models.py b/api/api/tests/test_schedule_models.py new file mode 100644 index 00000000..b89f1920 --- /dev/null +++ b/api/api/tests/test_schedule_models.py @@ -0,0 +1,48 @@ +from django.test import TestCase + +from api.models import Schedule + +from users.models import User + +import json + + +class ScheduleModelsTest(TestCase): + + def setUp(self): + self.user, _ = User.objects.get_or_create( + first_name="test", + last_name="banana", + picture_url="https://photo.aqui.com", + email="uiui@pichuruco.com", + ) + self.user.save() + + mock_json = json.dumps([ + {'class': 1}, + {'class': 2}, + ]) + self.schedule = Schedule.objects.create( + user=self.user, + classes=mock_json + ) + + def test_create_schedule(self): + """ + Testa se o schedule foi criado corretamente + + Tests: + - Se o usuário é o mesmo que o entrega na criação + - Se as classes são as mesmas que as entregues na criação + """ + self.assertEqual(self.schedule.user, self.user) + self.assertEqual(self.schedule.classes, json.dumps([ + {'class': 1}, + {'class': 2}, + ])) + + def test_str_method_of_schedule(self): + """ + Testa se o método __str__ de Schedule retorna o json correto + """ + self.assertEqual(str(self.schedule), f'Class: {self.schedule.id} - User: {self.user.email}') diff --git a/api/api/tests/test_schedule_save.py b/api/api/tests/test_schedule_save.py new file mode 100644 index 00000000..bbfe7a5b --- /dev/null +++ b/api/api/tests/test_schedule_save.py @@ -0,0 +1,251 @@ +from rest_framework.test import APITestCase +from rest_framework.reverse import reverse + +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from utils import db_handler as dbh + +from api.serializers import ClassSerializerSchedule +from api.models import Class + +from users.models import User + +from api.tests.test_error_request_body_schedule_save import ErrorRequestBodyScheduleSave + +import json + + +class TestScheduleSaveAPI(APITestCase, ErrorRequestBodyScheduleSave): + + def setDepartmentInfos(self): + self.department_infos = [ + ('518', '2023', '2'), ('673', '2023', '2'), + ('518', '2024', '1'), ('673', '2024', '1') + ] + + def setDisciplineInfos(self): + self.discipline_infos = [ + ('CÁLCULO 1', 'MAT0025', + self.departments['department_0_2023_2']), + ('CÁLCULO 1', 'MAT0025', + self.departments['department_2_2024_1']), + ('COMPILADORES 1', 'FGA0003', + self.departments['department_1_2023_2']), + ('COMPILADORES 1', 'FGA0003', + self.departments['department_3_2024_1']) + ] + + def setClassInfos(self): + self.class_infos = [ + (['EDSON ALVES DA COSTA JUNIOR'], 'FGA - I8', '35T23', + ['Terça-feira 14:00 às 15:50', 'Quinta-feira 14:00 às 15:50'], + '1', [], self.disciplines['discipline_FGA0003_2023_2']), + (['LUIS FILOMENO DE JESUS FERNANDES'], 'FGA - Sala I6', '46M34', + ['Quarta-feira 10:00 às 11:50', 'Sexta-feira 10:00 às 11:50'], + '2', [], self.disciplines['discipline_FGA0003_2023_2']), + (['EDSON ALVES DA COSTA JUNIOR'], 'FGA - I8', '35T23', + ['Terça-feira 14:00 às 15:50', 'Quinta-feira 14:00 às 15:50'], + '1', [], self.disciplines['discipline_FGA0003_2024_1']), + (['LUIS FILOMENO DE JESUS FERNANDES'], 'FGA - Sala I6', '46M34', + ['Quarta-feira 10:00 às 11:50', 'Sexta-feira 10:00 às 11:50'], + '2', [], self.disciplines['discipline_FGA0003_2024_1']), + (['RICARDO RAMOS FRAGELLI'], 'FGA - I9', '246M34', + ['Segunda-feira 10:00 às 11:50', 'Quarta-feira 10:00 às 11:50', + 'Sexta-feira 10:00 às 11:50'], '24', [], + self.disciplines['discipline_MAT0025_2023_2']), + (['RICARDO RAMOS FRAGELLI'], 'FGA - I9', '246M34', + ['Segunda-feira 10:00 às 11:50', 'Quarta-feira 10:00 às 11:50', + 'Sexta-feira 10:00 às 11:50'], '24', [], + self.disciplines['discipline_MAT0025_2024_1']), + (['RICARDO RAMOS FRAGELLI'], 'FGA - I7', '24M34 5T23', + ['Segunda-feira 10:00 às 11:50', 'Quarta-feira 10:00 às 11:50', + 'Quinta-feira 14:00 às 15:50'], '25', [], + self.disciplines['discipline_MAT0025_2024_1']), + ] + + def generate_schedule_structure(self, classes: list[Class]) -> list: + schedule_structure = [] + + for _class in classes: + serializer_data = ClassSerializerSchedule(_class).data + schedule_structure.append(serializer_data) + + return json.dumps(schedule_structure) + + def setUpDepartments(self): + self.departments = {} + self.setDepartmentInfos() + for i, infos in enumerate(self.department_infos): + code, year, period = infos + new_department = dbh.get_or_create_department( + code=code, year=year, period=period + ) + self.departments[f'department_{i}_{year}_{period}'] = new_department + + def setUpDisciplines(self): + self.disciplines = {} + self.setDisciplineInfos() + for name, code, department in self.discipline_infos: + year, period = department.year, department.period + new_discipline = dbh.get_or_create_discipline( + name=name, code=code, department=department + ) + self.disciplines[f'discipline_{code}_{year}_{period}'] = new_discipline + + def setUpClasses(self): + self.classes = {} + self.setClassInfos() + for i, infos in enumerate(self.class_infos): + teachers, classroom, schedule, days, _class, special_dates, discipline = infos + year, period = discipline.department.year, discipline.department.period + + new_class = dbh.create_class( + teachers=teachers, classroom=classroom, schedule=schedule, + days=days, _class=_class, special_dates=special_dates, + discipline=discipline + ) + self.classes[f'class_{i}_{year}_{period}'] = new_class + + def setUp(self): + self.setUpDepartments() + self.setUpDisciplines() + self.setUpClasses() + + self.user, _ = User.objects.get_or_create( + first_name="test", + last_name="banana", + picture_url="https://photo.aqui.com", + email="uiui@pichuruco.com", + ) + self.user.save() + + tokens = TokenObtainPairSerializer.get_token(self.user) + self.access_token = tokens.access_token + + self.url = reverse('api:save-schedule') + self.content_type = 'application/json' + + def make_post_request(self, auth: str = 'correct_token', schedule: str = '[]'): + headers = {'Authorization': f'Bearer {auth}1'} + + if auth == 'correct_token': + headers = {'Authorization': f'Bearer {self.access_token}'} + + return self.client.post( + self.url, schedule, headers=headers, + content_type=self.content_type + ) + + def test_save_correct_schedule(self): + """ + Testa o salvamento de uma grade horária correta com um usuário autenticado. + + Tests: + - Classes salvas no banco de dados + - Quantidade de classes salvas no banco de dados + - Status code (201 CREATED) + """ + schedule = self.generate_schedule_structure([ + self.classes['class_0_2023_2'], + self.classes['class_4_2023_2'] + ]) + + response = self.make_post_request(schedule=schedule) + + self.assertEqual(self.user.schedules.all()[0].classes, schedule) + self.assertEqual(len(self.user.schedules.all()), 1) + self.assertEqual(response.status_code, 201) + + def test_save_incorrect_schedule_with_different_year_period(self): + """ + Testa o salvamento de uma grade horária com turmas de anos e períodos diferentes. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + schedule = self.generate_schedule_structure([ + self.classes['class_0_2023_2'], + self.classes['class_5_2024_1'] + ]) + + response = self.make_post_request(schedule=schedule) + + error_msg = 'all classes must have the same year and period' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_non_existing_class(self): + """ + Testa o salvamento de uma grade horária com uma turma que não existe. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + schedule = self.generate_schedule_structure([ + self.classes['class_0_2023_2'], + Class( + teachers=['ADSON ALVES DA COSTA'], classroom='FGA - S10', schedule='35T23', + days=['Terça-feira 14:00 às 15:50', 'Quinta-feira 14:00 às 15:50'], _class='1', + special_dates=[], discipline=self.disciplines['discipline_FGA0003_2023_2'] + ) + ]) + + response = self.make_post_request(schedule=schedule) + + error_msg = 'the class FGA0003 does not exists with this params' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_incorrect_schedule_with_compatible_classes(self): + """ + Testa o salvamento de uma grade horária com turmas que não são compatíveis. + + Tests: + - Mensagem de erro + - Status code (400 BAD REQUEST) + """ + schedule = self.generate_schedule_structure([ + self.classes['class_2_2024_1'], + self.classes['class_6_2024_1'] + ]) + + response = self.make_post_request(schedule=schedule) + + error_msg = 'error while saving schedule, you may have chosen classes that are not compatible' + self.assertEqual(response.data.get('errors'), error_msg) + self.assertEqual(response.status_code, 400) + + def test_save_correct_schedule_without_auth(self): + """ + Testa o salvamento de uma grade horária sem um usuário autenticado. + + Tests: + - Status code (403 FORBIDDEN) + """ + schedule = self.generate_schedule_structure([ + self.classes['class_0_2023_2'], + self.classes['class_4_2023_2'] + ]) + + response = self.make_post_request(auth=False, schedule=schedule) + + self.assertEqual(response.status_code, 403) + + def test_save_correct_schedule_with_incorrect_auth_token(self): + """ + Testa o salvamento de uma grade horária com um token de autenticação incorreto. + + Tests: + - Status code (403 FORBIDDEN) + """ + schedule = self.generate_schedule_structure([ + self.classes['class_0_2023_2'], + self.classes['class_4_2023_2'] + ]) + + token = 'incorrect_token' + response = self.make_post_request(auth=token, schedule=schedule) + + self.assertEqual(response.status_code, 403) diff --git a/api/api/tests/test_search_api.py b/api/api/tests/test_search_api.py index cc039a6b..ca011d2e 100644 --- a/api/api/tests/test_search_api.py +++ b/api/api/tests/test_search_api.py @@ -1,6 +1,6 @@ from rest_framework.test import APITestCase from utils.db_handler import get_or_create_department, get_or_create_discipline, create_class -from api.views import ERROR_MESSAGE, ERROR_MESSAGE_SEARCH_LENGTH +from api.views.views import ERROR_MESSAGE, ERROR_MESSAGE_SEARCH_LENGTH import json class TestSearchAPI(APITestCase): diff --git a/api/api/urls.py b/api/api/urls.py index 23e9530a..4625065a 100644 --- a/api/api/urls.py +++ b/api/api/urls.py @@ -1,10 +1,11 @@ from django.urls import path -from api import views +from api.views import save_schedule, views app_name = 'api' urlpatterns = [ path('', views.Search.as_view(), name="search"), path('year-period/', views.YearPeriod.as_view(), name="year-period"), + path('schedule/save/', save_schedule.SaveSchedule.as_view(), name="save-schedule"), path('schedule/', views.Schedule.as_view(), name="schedule") ] diff --git a/api/api/views/save_schedule.py b/api/api/views/save_schedule.py new file mode 100644 index 00000000..37f85823 --- /dev/null +++ b/api/api/views/save_schedule.py @@ -0,0 +1,211 @@ +from utils.schedule_generator import ScheduleGenerator +from utils import db_handler as dbh + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from rest_framework import status, request, response + +from api.models import Class +from api.swagger import Errors +from api.views.utils import handle_400_error +from api import serializers + + +class SaveSchedule(APIView): + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description="Salva uma grade horária para o usuário logado.", + request_body=serializers.ClassSerializerSchedule(many=True), + security=[{'Bearer': []}], + responses={ + 201: openapi.Response('CREATED'), + **Errors([400, 401, 403]).retrieve_erros() + } + ) + def post(self, request: request.Request, *args, **kwargs) -> response.Response: + classes = request.data + + try: + validate_request_body_structure(classes) + except ValueError as e: + return handle_400_error(e.args[0]) + + unique_year_period = set() + current_db_classes_ids = [] + + classes_not_viability = check_classes_viability( + classes, + unique_year_period, + current_db_classes_ids + ) + if classes_not_viability: + return classes_not_viability + + try: + valid_schedule = validate_received_schedule(current_db_classes_ids) + except: + error_msg = "error while saving schedule, you may have chosen classes that are not compatible" + return handle_400_error(error_msg) + + user = request.user + answer = dbh.save_schedule(user, valid_schedule) + + return response.Response(status=status.HTTP_201_CREATED) if answer else handle_400_error("error while saving schedule") + + +def check_discipline_key_existence(key: str, discipline_key: str, **kwargs): + _class = kwargs.get('_class') + + if discipline_key not in _class[key].keys(): + raise ValueError(f"the discipline must have the {discipline_key} key") + + +def check_department_key_existence(department_keys: list[str], **kwargs): + _class = kwargs.get('_class') + + for department_key in department_keys: + if department_key not in _class['discipline']['department'].keys(): + raise ValueError( + f"the department must have the {department_key} key") + + +def check_department(discipline_key: str, **kwargs): + _class = kwargs.get('_class') + department_keys = kwargs.get('expected_department_keys') + + if discipline_key == 'department': + if not isinstance(_class['discipline']['department'], dict): + raise ValueError("the department must be a object structure") + + check_department_key_existence(department_keys, **kwargs) + + +def check_disciplines(key, **kwargs): + discipline_keys = kwargs.get('expected_discipline_keys') + + for discipline_key in discipline_keys: + check_discipline_key_existence(key, discipline_key, **kwargs) + check_department(discipline_key, **kwargs) + + +def check_class_key_existence(key: str, **kwargs): + _class = kwargs.get('_class') + + if key not in _class.keys(): + raise ValueError(f"the class must have the {key} key") + + +def check_if_discipline(key: str, **kwargs): + _class = kwargs.get('_class') + + if key == 'discipline': + if not isinstance(_class[key], dict): + raise ValueError("the discipline must be a object structure") + + check_disciplines(key, **kwargs) + + +def validate_class(**kwargs) -> response.Response | None: + expected_keys = kwargs.get('expected_keys') + + for key in expected_keys: + check_class_key_existence(key, **kwargs) + check_if_discipline(key, **kwargs) + + +def validate_request_body_structure(body: list[dict] | None) -> bool: + if not body: + raise ValueError("the request body must not be empty") + + if not isinstance(body, list): + raise ValueError("the request body must be a list of classes") + + for _class in body: + if not isinstance(_class, dict): + raise ValueError("each class must be a object structure") + + expected_keys = ['discipline', 'schedule', 'days', + 'special_dates', 'classroom', 'teachers'] + expected_discipline_keys = ['name', 'code', 'department'] + expected_department_keys = ['year', 'period'] + args = { + '_class': _class, + 'expected_keys': expected_keys, + 'expected_discipline_keys': expected_discipline_keys, + 'expected_department_keys': expected_department_keys + } + validate_class(**args) + + +def check_classes_viability(classes: list[dict], unique_year_period: set, current_db_classes_ids: list[int]): + for _class in classes: + key_args = retrieve_important_params_from_class(_class) + + year_period = retrieve_year_period_from_class(_class) + unique_year_period.add(year_period) + + db_class = dbh.get_class_by_params(**key_args) + if not db_class: + code = retrieve_discipline_code_from_class(_class) + error_msg = f"the class {code} does not exists with this params" + return handle_400_error(error_msg) + + current_db_classes_ids.append(db_class.id) + + if len(unique_year_period) > 1: + return handle_400_error("all classes must have the same year and period") + + +def retrieve_year_period_from_class(_class: dict) -> tuple: + department = _class.get('discipline').get('department') + year, period = department.get('year'), department.get('period') + + return year, period + + +def retrieve_discipline_code_from_class(_class: dict) -> str: + discipline = _class.get('discipline') + code = discipline.get('code') + + return code + + +def retrieve_important_params_from_class(_class: dict) -> dict: + discipline = _class.get('discipline') + name = discipline.get('name') + code = retrieve_discipline_code_from_class(_class) + + year, period = retrieve_year_period_from_class(_class) + + schedule, days = _class.get('schedule'), _class.get('days') + special_dates = _class.get('special_dates') + + classroom = _class.get('classroom') + teachers = _class.get('teachers') + + key_args = { + 'discipline__name': name, 'discipline__code': code, + 'discipline__department__year': year, + 'discipline__department__period': period, + 'schedule': schedule, 'days': days, + 'special_dates': special_dates, + 'classroom': classroom, + 'teachers': teachers + } + + return key_args + + +def validate_received_schedule(classes_id: list[int]) -> list[Class]: + schedule_generator = ScheduleGenerator(classes_id) + schedules = schedule_generator.generate() + + if len(schedules) != 1: + raise ValueError("the classes are not compatible") + + return schedules[0] diff --git a/api/api/views/utils.py b/api/api/views/utils.py new file mode 100644 index 00000000..516ad247 --- /dev/null +++ b/api/api/views/utils.py @@ -0,0 +1,8 @@ +from rest_framework import status, response + + +def handle_400_error(error_msg: str) -> response.Response: + return response.Response( + { + "errors": error_msg + }, status.HTTP_400_BAD_REQUEST) diff --git a/api/api/views.py b/api/api/views/views.py similarity index 93% rename from api/api/views.py rename to api/api/views/views.py index bdc7ba66..83307b8d 100644 --- a/api/api/views.py +++ b/api/api/views/views.py @@ -1,16 +1,23 @@ -from utils import db_handler as dbh -from .models import Discipline from unidecode import unidecode + from django.contrib import admin from django.db.models.query import QuerySet + from rest_framework.decorators import APIView -from utils.sessions import get_current_year_and_period, get_next_period from rest_framework import status, request, response + from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi -from .swagger import Errors -from api import serializers + +from utils.sessions import get_current_year_and_period, get_next_period from utils.schedule_generator import ScheduleGenerator +from utils import db_handler as dbh + +from api import serializers +from api.swagger import Errors +from api.models import Discipline +from api.views.utils import handle_400_error + MAXIMUM_RETURNED_DISCIPLINES = 8 ERROR_MESSAGE = "no valid argument found for 'search', 'year' or 'period'" @@ -39,8 +46,23 @@ def filter_disciplines(self, request: request.Request, name: str) -> QuerySet[Di return disciplines + def retrieve_disciplines_by_similarity(self, request: request.Request, name: str) -> QuerySet[Discipline]: + disciplines = self.filter_disciplines(request, name) + disciplines = dbh.get_best_similarities_by_name(name, disciplines) + + if not disciplines.count(): + disciplines = dbh.filter_disciplines_by_code(code=name[0]) + + for term in name[1:]: + disciplines &= dbh.filter_disciplines_by_code(code=term) + + disciplines = dbh.filter_disciplines_by_code(name) + + return disciplines + @swagger_auto_schema( operation_description="Busca disciplinas por nome ou código. O ano e período são obrigatórios.", + security=[], manual_parameters=[ openapi.Parameter('search', openapi.IN_QUERY, description="Termo de pesquisa (Nome/Código)", type=openapi.TYPE_STRING), @@ -64,27 +86,12 @@ def get(self, request: request.Request, *args, **kwargs) -> response.Response: period_verified = period is not None and len(period) > 0 if not name_verified or not year_verified or not period_verified: - return response.Response( - { - "errors": ERROR_MESSAGE - }, status.HTTP_400_BAD_REQUEST) + return handle_400_error(ERROR_MESSAGE) if len(name) < MINIMUM_SEARCH_LENGTH: - return response.Response( - { - "errors": ERROR_MESSAGE_SEARCH_LENGTH - }, status.HTTP_400_BAD_REQUEST) + return handle_400_error(ERROR_MESSAGE_SEARCH_LENGTH) - disciplines = self.filter_disciplines(request, name) - disciplines = dbh.get_best_similarities_by_name(name, disciplines) - - if not disciplines.count(): - disciplines = dbh.filter_disciplines_by_code(code=name[0]) - - for term in name[1:]: - disciplines &= dbh.filter_disciplines_by_code(code=term) - - disciplines = dbh.filter_disciplines_by_code(name) + disciplines = self.retrieve_disciplines_by_similarity(request, name) filtered_disciplines = dbh.filter_disciplines_by_year_and_period( year=year, period=period, disciplines=disciplines) @@ -98,6 +105,7 @@ class YearPeriod(APIView): @swagger_auto_schema( operation_description="Retorna o ano e período atual, e o próximo ano e período letivos válidos para pesquisa.", + security=[], responses={ 200: openapi.Response('OK', openapi.Schema( type=openapi.TYPE_OBJECT, @@ -130,11 +138,12 @@ def get(self, request: request.Request, *args, **kwargs) -> response.Response: class Schedule(APIView): @swagger_auto_schema( operation_description="Gera possíveis horários de acordo com as aulas escolhidas com preferência de turno", + security=[], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, title="body", required=['classes'], - properties={ + properties={ 'classes': openapi.Schema( description="Lista de ids de aulas escolhidas", type=openapi.TYPE_ARRAY, @@ -154,7 +163,7 @@ class Schedule(APIView): ) } ), - responses = { + responses={ 200: openapi.Response('OK', serializers.ClassSerializerSchedule(many=True)), **Errors([400]).retrieve_erros() } diff --git a/api/core/settings/base.py b/api/core/settings/base.py index 2defeaa7..6472e170 100644 --- a/api/core/settings/base.py +++ b/api/core/settings/base.py @@ -25,6 +25,8 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = config("DJANGO_SECRET_KEY") +APPEND_SLASH = False + # Application definition INSTALLED_APPS = [ @@ -80,6 +82,23 @@ "TOKEN_REFRESH_SERIALIZER": "users.simplejwt.serializers.RefreshJWTSerializer" } + +# SWAGGER + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Basic': { + 'type': 'basic' + }, + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header' + } + } +} + + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/api/setup.cfg b/api/setup.cfg index 9ac5f27c..12c57b68 100644 --- a/api/setup.cfg +++ b/api/setup.cfg @@ -5,5 +5,7 @@ # These files can't be tested, so we exclude them from coverage. # We also exclude the manage.py file, since it's not a part of the app. omit = + utils/json_pretty.py manage.py + api/admin.py users/admin.py \ No newline at end of file diff --git a/api/users/views.py b/api/users/views.py index ff1f771c..64004962 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -20,6 +20,7 @@ class Register(TokenObtainPairView): "Set-Cookie": "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=" } """, + security=[], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -135,6 +136,7 @@ class RefreshJWTView(HandlePostErrorMixin, HandleRefreshMixin, TokenRefreshView) "Set-Cookie": "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=" } """, + security=[], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, ), @@ -167,6 +169,7 @@ class BlacklistJWTView(HandlePostErrorMixin, HandleRefreshMixin, TokenBlacklistV Cookie: "refresh=" } """, + security=[], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, ), diff --git a/api/utils/db_handler.py b/api/utils/db_handler.py index c7638bb9..d59e2874 100644 --- a/api/utils/db_handler.py +++ b/api/utils/db_handler.py @@ -1,8 +1,17 @@ from api.models import Discipline, Department, Class +from api.serializers import ClassSerializerSchedule +from api.models import Schedule + +from users.models import User + from django.db.models.query import QuerySet -from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramStrictWordSimilarity +from django.contrib.postgres.search import SearchVector, SearchQuery, TrigramStrictWordSimilarity +from django.db.models.manager import BaseManager from django.db.models import Q -""" Este módulo lida com as operações de banco de dados.""" + +import json + +"""Este módulo lida com as operações de banco de dados.""" def get_or_create_department(code: str, year: str, period: str) -> Department: @@ -21,14 +30,17 @@ def create_class(teachers: list, classroom: str, schedule: str, return Class.objects.create(teachers=teachers, classroom=classroom, schedule=schedule, days=days, _class=_class, special_dates=special_dates, discipline=discipline) + def delete_classes_from_discipline(discipline: Discipline) -> None: """Deleta todas as turmas de uma disciplina.""" Class.objects.filter(discipline=discipline).delete() + def delete_all_departments_using_year_and_period(year: str, period: str) -> None: """Deleta um departamento de um periodo especifico.""" Department.objects.filter(year=year, period=period).delete() + def get_best_similarities_by_name(name: str, disciplines: Discipline = Discipline.objects, config="portuguese_unaccent") -> QuerySet: """Filtra as disciplinas pelo nome.""" vector = SearchVector("unicode_name", config=config) @@ -42,14 +54,39 @@ def get_best_similarities_by_name(name: str, disciplines: Discipline = Disciplin return values + def filter_disciplines_by_code(code: str, disciplines: Discipline = Discipline.objects) -> QuerySet: """Filtra as disciplinas pelo código.""" return disciplines.filter(code__icontains=code) + def filter_disciplines_by_year_and_period(year: str, period: str, disciplines: Discipline = Discipline.objects) -> QuerySet: """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: + +def get_class_by_id(id: int, classes: BaseManager[Class] = Class.objects) -> Class: """Filtra as turmas pelo id.""" return classes.get(id=id) + + +def get_class_by_params(classes: BaseManager[Class] = Class.objects, **kwargs) -> Class | None: + """Filtra as turmas pelos argumentos: nome, código, departamento, ...""" + try: + return classes.get(**kwargs) + except Class.DoesNotExist: + return None + + +def save_schedule(user: User, schedule_to_save: list[Class]) -> bool: + """Salva uma grade horária para um usuário.""" + + serializer_data = ClassSerializerSchedule(schedule_to_save, many=True).data + json_schedule = json.dumps(serializer_data) + + try: + Schedule.objects.get_or_create(user=user, classes=json_schedule) + except: # pragma: no cover + return False + + return True diff --git a/api/utils/json_pretty.py b/api/utils/json_pretty.py new file mode 100644 index 00000000..7439796f --- /dev/null +++ b/api/utils/json_pretty.py @@ -0,0 +1,30 @@ +import json +from django.utils.safestring import mark_safe +from pygments import highlight +from pygments.lexers import JsonLexer +from pygments.formatters import HtmlFormatter + + +def json_prettify(json_string): + """ + Adapted from: + https://www.pydanny.com/pretty-formatting-json-django-admin.html + """ + + formatter = HtmlFormatter(style='igor') + + json_data = json.loads(json_string) + json_text = highlight( + json.dumps(json_data, sort_keys=True, indent=2, + ensure_ascii=False).encode('utf-8'), + JsonLexer(), + formatter + ) + + json_text = json_text \ + .replace('{\n', '') \ + .replace('}\n', '') + + style = "" + + return mark_safe(style + json_text)