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

api: add search filter by class schedule and department #234

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
8 changes: 6 additions & 2 deletions api/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ class DisciplineSerializer(DisciplineSerializerSchedule):

def get_classes(self, discipline: Discipline):
teacher_name = self.context.get('teacher_name')
schedule = self.context.get('schedule')
classes = discipline.classes.all() if hasattr(
discipline, 'classes') else Class.objects.none()
if teacher_name:
classes = dbh.filter_classes_by_teacher(
name=teacher_name, classes=classes)

if schedule:
classes = dbh.filter_classes_by_schedule(
schedule=schedule, classes=classes)
return ClassSerializer(classes, many=True).data


Expand All @@ -47,6 +50,7 @@ class Meta:
model = Schedule
exclude = ['user']


class GenerateSchedulesSerializer(serializers.Serializer):
message = serializers.CharField(max_length=200)
schedules = ClassSerializerSchedule(many=True)
schedules = ClassSerializerSchedule(many=True)
137 changes: 126 additions & 11 deletions api/api/tests/test_search_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,26 @@ def setUp(self) -> None:
name='CÁLCULO 1', code='MAT518', department=self.department)
self.discipline_2 = get_or_create_discipline(
name='CÁLCULO 2', code='MAT519', department=self.department)
self._class_1 = create_class(teachers=['RICARDO FRAGELLI'], classroom='MOCAP', schedule='46M34',
days=['Quarta-Feira 10:00 às 11:50', 'Sexta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_1)
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="1", special_dates=[], discipline=self.discipline_2)
self._class_3 = create_class(teachers=['RICARDO RAMOS FRAGELLI'], classroom='MOCAP', schedule='235M34',
days=['Segunda-Feira 10:00 às 11:50', 'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_2)
self._class_4 = create_class(teachers=['VINICIUS CARVALHO RISPOLI'], classroom='S1', schedule='35M34', days=[
'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_2)
self._class_5 = create_class(teachers=['RICARDO JUNIOR'], classroom='S1', schedule='35M34', days=[
'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_1)
self.discipline_3 = get_or_create_discipline(
name='ÁLGEBRA LINEAR', code='MAT520', department=self.department)
self._class_1 = create_class(
teachers=['RICARDO FRAGELLI'], classroom='MOCAP', schedule='46M34',
days=['Quarta-Feira 10:00 às 11:50', 'Sexta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_1)
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="1", special_dates=[], discipline=self.discipline_2)
self._class_3 = create_class(
teachers=['RICARDO RAMOS FRAGELLI'], classroom='MOCAP', schedule='235M34',
days=['Segunda-Feira 10:00 às 11:50', 'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_2)
self._class_4 = create_class(
teachers=['VINICIUS CARVALHO RISPOLI'], classroom='S1', schedule='35M34', days=[
'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_2)
self._class_5 = create_class(
teachers=['RICARDO JUNIOR'], classroom='S1', schedule='35M34', days=[
'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_1)
self._class_6 = create_class(
teachers=['LUIZA YOKO'], classroom='S1', schedule='35M34', days=[
'Terça-Feira 10:00 às 11:50', 'Quinta-Feira 10:00 às 11:50'], _class="1", special_dates=[], discipline=self.discipline_3)

def test_with_incomplete_correct_search(self):
"""
Expand All @@ -37,7 +47,6 @@ def test_with_incomplete_correct_search(self):
response_for_disciplines = self.client.get(
'/courses/?search=calculo&year=2023&period=2')
content = json.loads(response_for_disciplines.content)

# Testes da disciplina 1
self.assertEqual(response_for_disciplines.status_code, 200)
self.assertEqual(len(content), 2)
Expand Down Expand Up @@ -427,3 +436,109 @@ def test_correct_search_with_teacher_last_name_with_more_than_one_discipline(sel
self.assertEqual(content[1]['name'], self.discipline_2.name)
self.assertEqual(content[1]['classes'][0]
['teachers'], self._class_3.teachers)

def test_correct_search_with_class_schedule_and_department_code(self):
"""
Testa a busca por disciplinas com horário de aula e código do departamento.
É esperado que mais de uma disciplina seja retornada, pois o horário de aula é o mesmo para mais de uma disciplina.
Testes:
- Status code (200 OK)
- Quantidade de disciplinas retornadas
- Quantidade de turmas retornadas em cada disciplina
- Código do departamento
- Nome da disciplina
- Professores da disciplina
"""
schedule = '35M34' # Horário das aulas
department_code = '518' # Código do departamento de matemática
response_for_disciplines = self.client.get(
f'/courses/?search=&year=2023&period=2&department_code={department_code}&schedule={schedule}')
content = json.loads(response_for_disciplines.content)
# Teste da resposta da API
self.assertEqual(response_for_disciplines.status_code, 200)
# As 3 disciplinas existentes no setUp devem ser retornadas
self.assertEqual(len(content), 3)
# Testes das disciplinas retornadas
self.assertEqual(content[0]['department']
['code'], self.department.code)
# Testes da discipiina 1
self.assertEqual(content[0]['name'], self.discipline_1.name)
# Verifica a quantidade de turmas retornadas
self.assertEqual(len(content[0]['classes']), 1)
# Teste do professor da 1ª turma do horário 35M34
self.assertEqual(content[0]['classes'][0]
['teachers'], self._class_5.teachers)
# Testes da 2ª disciplina retornada
self.assertEqual(content[1]['department']
['code'], self.department.code)
# Verifica o nome da disciplina 2
self.assertEqual(content[1]['name'], self.discipline_2.name)
# Verifica a quantidade de turmas retornadas
self.assertEqual(len(content[1]['classes']), 2)
self.assertEqual(content[1]['classes'][0]
['teachers'], self._class_3.teachers)
self.assertEqual(content[1]['classes'][1]
['teachers'], self._class_4.teachers)
# Testes da 3ª disciplina retornada
self.assertEqual(content[2]['department']
['code'], self.department.code)
# Verifica o nome da disciplina 3
self.assertEqual(content[2]['name'], self.discipline_3.name)
# Verifica a quantidade de turmas retornadas
self.assertEqual(len(content[2]['classes']), 1)
self.assertEqual(content[2]['classes'][0]
['teachers'], self._class_6.teachers)

def test_correct_search_with_class_name_and_schedule(self):
""" Testa a busca por disciplinas com nome da matéria e horário de aula.
Testes:
- Status code (200 OK)
- Quantidade de disciplinas retornadas
- Quantidade de turmas retornadas em cada disciplina
- Código do departamento
- Nome da disciplina
- Professores da disciplina
"""
schedule = '35M34'
name = 'calculo'
response_for_disciplines = self.client.get(
f'/courses/?search={name}&year=2023&period=2&schedule={schedule}')
content = json.loads(response_for_disciplines.content)
self.assertEqual(response_for_disciplines.status_code, 200)

# Testa se retornou apenas as duas disciplinas de Cálculo presentes no setUp
self.assertEqual(len(content), 2)
self.assertEqual(content[0]['department']
['code'], self.department.code)
self.assertEqual(content[0]['name'], self.discipline_1.name)
# Testa se para a disciplina 1 foi retornado apenas a turma com o schedule especificado.
self.assertEqual(len(content[0]['classes']), 1)
self.assertEqual(content[0]['classes'][0]
['teachers'], self._class_5.teachers)

self.assertEqual(content[1]['department']
['code'], self.department.code)
self.assertEqual(content[1]['name'], self.discipline_2.name)

# Testa se para a disciplina 2 foi retornado as 2 turmas com o schedule especificado.
self.assertEqual(len(content[1]['classes']), 2)
self.assertEqual(content[1]['classes'][0]
['teachers'], self._class_3.teachers)
self.assertEqual(content[1]['classes'][1]
['teachers'], self._class_4.teachers)

def test_correct_search_with_class_name_and_schedule_but_no_classes_to_return(self):
"""
Testa a busca por disciplinas com nome da matéria e horário de aula,
mas não há turmas com o horário especificado.
Testes:
- Status code (200 OK)
- Quantidade de disciplinas retornadas
"""
schedule = '2N34'
name = 'cálculo'
response_for_disciplines = self.client.get(
f'/courses/?search={name}&year=2023&period=2&schedule={schedule}')
content = json.loads(response_for_disciplines.content)
self.assertEqual(response_for_disciplines.status_code, 200)
self.assertEqual(len(content), 0)
71 changes: 46 additions & 25 deletions api/api/views/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ..models import Discipline
from ..models import Discipline, Class

from django.db.models.query import QuerySet

Expand All @@ -10,7 +10,7 @@

from utils.sessions import get_current_year_and_period, get_next_period
from utils.schedule_generator import ScheduleGenerator
from utils.db_handler import get_best_similarities_by_name, filter_disciplines_by_teacher, filter_disciplines_by_year_and_period, filter_disciplines_by_code
from utils.db_handler import get_best_similarities_by_name, filter_disciplines_by_teacher, filter_disciplines_by_year_and_period, filter_disciplines_by_code, filter_disciplines_by_schedule_and_department_code
from utils.search import SearchTool

from .. import serializers
Expand All @@ -21,7 +21,7 @@
from traceback import print_exception

MAXIMUM_RETURNED_DISCIPLINES = 15
ERROR_MESSAGE = "no valid argument found for 'search', 'year' or 'period'"
ERROR_MESSAGE = "Bad search parameters or missing parameters"
MINIMUM_SEARCH_LENGTH = 4
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eu acredito que nesse caso, as mensagens de erro devem ser mais descritivas.
Ao realizar uma pesquisa, tive certa dificuldade em saber o que estava fazendo de errado.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Olá, modificarei as mensagens para torná-las mais descritivas.

ERROR_MESSAGE_SEARCH_LENGTH = f"search must have at least {MINIMUM_SEARCH_LENGTH} characters"
MAXIMUM_RETURNED_SCHEDULES = 5
Expand All @@ -38,11 +38,11 @@ def treat_string(self, string: str | None) -> str | None:
def filter_disciplines(self, request: request.Request, name: str) -> QuerySet[Discipline]:
search_handler = SearchTool(Discipline)
search_fields = ['unicode_name', 'code']

result = search_handler.filter_by_search_result(
request = request,
search_str = name,
search_fields = search_fields
request=request,
search_str=name,
search_fields=search_fields
)

return result
Expand All @@ -51,10 +51,8 @@ def retrieve_disciplines_by_similarity(self, request: request.Request, name: str
disciplines = self.filter_disciplines(request, name)

disciplines = get_best_similarities_by_name(name, disciplines)

if not disciplines.count():
disciplines = filter_disciplines_by_code(code=name[0])

for term in name[1:]:
disciplines &= filter_disciplines_by_code(code=term)

Expand All @@ -70,12 +68,15 @@ def get_disciplines_and_search_flag(self, request, name):
search_by_teacher = True
return disciplines, search_by_teacher

def get_serialized_data(self, filter_params: dict, search_by_teacher: bool, name: str) -> list:
def get_serialized_data(self, filter_params: dict, search_by_teacher: bool, name: str, schedule=None, search_by_schedule=False) -> list:
filtered_disciplines = filter_disciplines_by_year_and_period(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function get_serialized_data has 5 arguments (exceeds 4 allowed). Consider refactoring.

**filter_params)
if search_by_teacher:
data = serializers.DisciplineSerializer(
filtered_disciplines, many=True, context={'teacher_name': name}).data
elif search_by_schedule:
data = serializers.DisciplineSerializer(
filtered_disciplines, many=True, context={'schedule': schedule}).data
else:
data = serializers.DisciplineSerializer(
filtered_disciplines, many=True).data
Expand All @@ -85,9 +86,12 @@ def get_request_parameters(self, request):
name = self.treat_string(request.GET.get('search', None))
year = self.treat_string(request.GET.get('year', None))
period = self.treat_string(request.GET.get('period', None))
return name, year, period
department_code = self.treat_string(
request.GET.get('department_code', None))
schedule = self.treat_string(request.GET.get('schedule', None))
return name, year, period, schedule, department_code

@swagger_auto_schema(
@ swagger_auto_schema(
operation_description="Busca disciplinas por nome ou código. O ano e período são obrigatórios.",
security=[],
manual_parameters=[
Expand All @@ -104,29 +108,46 @@ def get_request_parameters(self, request):
}
)
def get(self, request: request.Request, *args, **kwargs) -> response.Response:
name, year, period = self.get_request_parameters(request)

if not all((name, year, period)):
name, year, period, schedule, department_code = self.get_request_parameters(
request)
if not all((year, period)):
return handle_400_error(ERROR_MESSAGE)
disciplines, search_by_teacher, search_by_schedule = None, False, False
if name:
if len(name) < MINIMUM_SEARCH_LENGTH:
return handle_400_error(ERROR_MESSAGE_SEARCH_LENGTH)
disciplines, search_by_teacher = self.get_disciplines_and_search_flag(
request, name)
if schedule:
search_by_schedule = True
elif schedule and department_code:
disciplines = filter_disciplines_by_schedule_and_department_code(
schedule=schedule, department_code=department_code)
search_by_schedule = True
else:
Comment on lines +126 to +130
Copy link
Collaborator

@caio-felipee caio-felipee Aug 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O search_by_schedule = True aparece duas vezes. Acho que poderia remover esses trechos do escopo local das estruturas de condição e adicionar o atributo da variável apenas uma vez em um escopo mais geral. O que acha?

return handle_400_error(ERROR_MESSAGE)

if len(name) < MINIMUM_SEARCH_LENGTH:
return handle_400_error(ERROR_MESSAGE_SEARCH_LENGTH)

disciplines, search_by_teacher = self.get_disciplines_and_search_flag(
request, name)

data = self.get_serialized_data(
filter_params={'year': year, 'period': period,
'disciplines': disciplines},
search_by_teacher=search_by_teacher,
name=name
search_by_schedule=search_by_schedule,
name=name,
schedule=schedule
)

data_aux = []
for i in range(len(data)):
if data[i]['classes'] == []:
data_aux.append(data[i])
for i in data_aux:
data.remove(i)
return response.Response(data[:MAXIMUM_RETURNED_DISCIPLINES], status.HTTP_200_OK)
Comment on lines +143 to +148
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Não entendi o propósito dessas linhas de código. Poderia elaborar um pouco sobre?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Olá, Caio! O propósito deste trecho de código é filtrar as disciplinas que por ventura tenham sido encontradas pelo nome, porém devido ao critério de horário, não possua nenhuma turma associada. Isso pode ocorrer caso o estudante pesquise por exemplo:

  • Cálculo 1
  • 234N23
    e não exista nenhuma turma com esse horário. A ideia seria não retornar o objeto de Disciplines com a lista de classes vazia, pois pode comprometer a experiência do usuário.

Caso a pesquisa acima fosse modificada para

  • Calc
  • 234N23
    existe a possibilidade de outras matérias possuírem turmas neste horário e, caso a matéria de Cálculo 1 não fosse removida do data, o usuário iria receber uma lista de disciplinas onde uma delas não possui turmas.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entendi. Essa abordagem não é muito performática por conta das frequentes buscas lineares para remoção, e também das remoções (que será linear).

Acho que seria melhor algo desse tipo:

data = list(filter(lambda value: len(value) > 0, data))



class YearPeriod(APIView):

@swagger_auto_schema(
@ 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.",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Não utilizamos esse padrão de separação de decorators.

security=[],
responses={
Expand Down Expand Up @@ -159,7 +180,7 @@ def get(self, request: request.Request, *args, **kwargs) -> response.Response:


class GenerateSchedule(APIView):
@swagger_auto_schema(
@ 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(
Expand Down Expand Up @@ -245,7 +266,7 @@ def post(self, request: request.Request, *args, **kwargs) -> response.Response:
for schedule in schedules[:MAXIMUM_RETURNED_SCHEDULES]:
data.append(
list(map(lambda x: serializers.ClassSerializerSchedule(x).data, schedule)))

return response.Response({
'message': message,
'schedules': data
Expand Down
11 changes: 11 additions & 0 deletions api/utils/db_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ 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)


@handle_cache_before_delete
def delete_classes_from_discipline(discipline: Discipline) -> QuerySet:
"""Deleta todas as turmas de uma disciplina."""
Expand Down Expand Up @@ -70,11 +71,21 @@ def filter_disciplines_by_teacher(name: str) -> QuerySet:
return search_disciplines


def filter_disciplines_by_schedule_and_department_code(schedule: str, department_code: str) -> QuerySet:
"""Filtra as disciplinas pelo horário."""
return Discipline.objects.filter(Q(classes__schedule__icontains=schedule) & Q(department__code=department_code)).distinct("id")
Comment on lines +74 to +76
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Não entendi muito bem o uso do filter_disciplines_by_schedule_and_department_code em conjunto com o filter_classes_by_schedule. Esse Q(classes__schedule__icontains=schedule) não filtra as aulas através do código? Logo em seguida, é utilizada a outra função. Algum dos dois são inutilizados, não?

Copy link
Collaborator Author

@GabrielCastelo-31 GabrielCastelo-31 Aug 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Olá, Caio! A função filter_disciplines_by_schedule_and_department_code() serve para encontrar disciplinas que possuam alguma turma com o horário especificado. Uma vez feito isso, antes de retornar a disciplina para o front-end, é necessário filtrar, dentre as turmas do objeto Discipline em questão, as turmas que coincidem com o horário especificado.

Algo parecido é feito na busca por professor, uma vez que encontramos disciplinas com turmas com o professor especificado, nós filtramos, dentre as turmas daquela disciplina, somente as turmas daquele professor.

Essa filtragem de turmas é feita dentro serializer após obter os objetos disciplines em questão.

class DisciplineSerializer(DisciplineSerializerSchedule):
    classes = serializers.SerializerMethodField()

    def get_classes(self, discipline: Discipline):
        teacher_name = self.context.get('teacher_name')
        schedule = self.context.get('schedule')
        classes = discipline.classes.all() if hasattr(
            discipline, 'classes') else Class.objects.none()
        if teacher_name:
            classes = dbh.filter_classes_by_teacher(
                name=teacher_name, classes=classes)
        if schedule:
            classes = dbh.filter_classes_by_schedule(
                schedule=schedule, classes=classes)
        return ClassSerializer(classes, many=True).data

Não conheço uma maneira de filtrar os objetos classes relacionados ao objeto discipline diretamente na filtragem de objetos Discipline utilizando o ORM padrão do Djano. Talvez seja interessante utilizar uma query em SQL que já realize a filtragem completa.

O que acha?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Algo parecido é feito na busca por professor, uma vez que encontramos disciplinas com turmas com o professor especificado, nós filtramos, dentre as turmas daquela disciplina, somente as turmas daquele professor.

Entendi. Se já temos um sistema dessa forma, vamos manter então (pelo menos por enquanto).
Nunca tinha reparado, não me parece ser tão interessante seguir dessa maneira. Não estamos verificando esses atributos duas vezes?

Talvez seja interessante utilizar uma query em SQL que já realize a filtragem completa.

O que acha?

Por enquanto, vamos manter do jeito em que está mesmo. Para utilizar uma query em SQL, temos que verificar as opções que o framework entrega pra gente (se é anti SQL Injection e tudo mais).

Para essa thread, só basta a resolução do problema reportado que é o mesmo do filter_classes_by_schedule. Valeu!



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_classes_by_schedule(schedule: str, classes: BaseManager[Class] = Class.objects) -> QuerySet:
"""Filtra as turmas pelo horário."""
return classes.filter(schedule__icontains=schedule)
Comment on lines +84 to +86
Copy link
Collaborator

@caio-felipee caio-felipee Aug 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A proposta por realizar um filtro através do horário é boa, mas acho que não é eficiente utilizando essa função. Pelo o que eu entendi, ela vai encontrar padrões que contém trechos diretos. Por exemplo:

Imagine que temos uma lista de matérias distintas, com os seguintes horários:

schedules = ['24M34', '46M34']

Se eu realizo uma query para receber matérias que possuem o horário 4M34, receberei apenas a primeira matéria, sendo que a segunda também possui o mesmo padrão.

Essa pesquisa é funcional, mas se limita a uma substring de horário.

EDIT: A função icontains é equivalente:

SELECT classes WHERE schedule ILIKE '%4M34%';

Se a query fosse desse tipo (abaixo), o problema seria resolvido:

SELECT classes WHERE schedule ILIKE '%4%M%3%4%';

Ou seja: Com a query 4M34, pode ter qualquer coisa antes do primeiro 4 e depois também. Logo após isso, vem o M e também pode ter qualquer coisa entre o 3 e 4.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excelente observação, Caio! Concordo com as sugestões e irei modificar o código para implementá-las.



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)
Expand Down
Loading