diff --git a/Makefile b/Makefile index d84d0734..5285e03a 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,11 @@ # Configuration config: copy-env setup-env config-mock entrypoint-chmod -copy-env: +copy-env: cp ./api/.env.example ./api/.env cp ./web/.env.example ./web/.env.local -setup-env: +setup-env: bash scripts/env.sh config-mock: @@ -24,7 +24,7 @@ install: ## Docker ## start: - sudo docker compose up -d + sudo docker compose up -d start-b: sudo docker compose up --build -d @@ -33,7 +33,7 @@ stop: sudo docker compose down stop-v: - sudo docker compose down -v + sudo docker compose down -v ## Django Shortcuts ## @@ -42,7 +42,7 @@ stop-v: test: python3 scripts/test.py --clean -testfull: +testfull: python3 scripts/test.py sudo docker exec django-api coverage html python3 scripts/report.py @@ -57,3 +57,9 @@ makemigrations: migrate: sudo docker exec django-api python3 manage.py migrate + +# Database Operations +updatedb-all: + sudo docker exec django-api python3 manage.py updatedb -a +deletedb-all: + sudo docker exec django-api python3 manage.py updatedb -d -a diff --git a/README.md b/README.md index ed933df7..1922d474 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,52 @@ -# Sua Grade UnB - -O Sua Grade UnB é um projeto em desenvolvimento da matéria **Métodos de Desenvolvimento de Software**, a qual tem como objetivo auxiliar os alunos da Universidade de Brasília a montarem suas grades horárias de maneira fácil e intuitiva. +# [Sua Grade UnB](https://suagradeunb.com.br/) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![codecov](https://codecov.io/gh/unb-mds/2023-2-Squad11/branch/main/graph/badge.svg?token=ZQZQZQZQZQ)](https://codecov.io/gh/unb-mds/2023-2-Squad11) +[![GitHub issues](https://img.shields.io/github/issues/unb-mds/2023-2-Squad11)]() +[![GitHub contributors](https://img.shields.io/github/contributors/unb-mds/2023-2-Squad11)]() +[![GitHub stars](https://img.shields.io/github/stars/unb-mds/2023-2-Squad11)]() +[![Hit Counter](https://views.whatilearened.today/views/github/unb-mds/2023-2-Squad11.svg)](https://views.whatilearened.today/views/github/unb-mds/2023-2-Squad11.svg) +
+ +[![Python version](https://img.shields.io/badge/python-3.11.6-blue)](https://www.python.org/downloads/release/python-3116/) +[![Django version](https://img.shields.io/badge/django-4.2.5-blue)](https://www.djangoproject.com/download/) +[![Node version](https://img.shields.io/badge/node-20.9.0-blue)](https://nodejs.org/en/download/) +[![npm version](https://img.shields.io/badge/npm-10.2.3-blue)](https://nodejs.org/en/download/) +[![Docker version](https://img.shields.io/badge/docker-24.0.7-blue)](https://docs.docker.com/engine/install/) +[![Docker Compose version](https://img.shields.io/badge/docker_compose-2.21.0-blue)](https://docs.docker.com/compose/install/) + +O Sua Grade UnB é um projeto da matéria **Métodos de Desenvolvimento de Software**, a qual tem como objetivo auxiliar os alunos da Universidade de Brasília a montarem suas grades horárias de maneira fácil e intuitiva. + +Com apenas alguns cliques, o aluno poderá montar sua grade horária de acordo com as matérias que deseja cursar. Além disso, o sistema auxiliará o aluno ao resolver os conflitos de horários entre as matérias escolhidas, retornando as melhores opções de horários de acordo com suas preferências. + +O projeto é software livre e está sob a licença [MIT](./LICENSE). + +## 📝 Sumário + +- [Sua Grade UnB](#sua-grade-unb) + - [📝 Sumário](#-sumário) + - [👥 Equipe](#-equipe) + - [✨ Início](#-início) + - [📋 Pré-requisitos](#-pré-requisitos) + - [💻 Ambiente](#-ambiente) + - [📁 Dependências do projeto](#-dependências-do-projeto) + - [💾 Execução](#-execução) + - [✅ Autenticação do Google OAuth](#-autenticação-do-google-oauth) + - [🖱️ Acesso aos serviços](#-acesso-aos-serviços) + - [📍 Migrations](#-migrations) + - [📚 Documentação](#-documentação) + - [📎 Extra](#-extra) + +## 👥 Equipe + +| Nome | GitHub | +| :--- | :----: | +| Arthur Ribeiro e Sousa | [@artrsousa1](https://github.com/artrsousa1) | +| Caio Falcão Habibe Costa | [@CaioHabibe](https://github.com/CaioHabibe) | +| Caio Felipe Rocha Rodrigues| [@caio-felipee](https://github.com/caio-felipee) | +| Gabriel Henrique Castelo Costa | [@GabrielCastelo-31](https://github.com/GabrielCastelo-31) | +| Henrique Camelo Quenino | [@henriquecq](https://github.com/henriquecq) | +| Mateus Vieira Rocha da Silva | [@mateusvrs](https://github.com/mateusvrs) | ## ✨ Início @@ -10,7 +56,7 @@ Você pode clonar o repositório do projeto com o seguinte comando: git clone https://github.com/unb-mds/2023-2-Squad11.git ``` -### Dependências globais +### 📋 Pré-requisitos Para rodar o projeto, você precisa instalar as dependências globais, que são: @@ -19,7 +65,7 @@ Para rodar o projeto, você precisa instalar as dependências globais, que são: - Node v20.9.0 e NPM v10.1.0 (ou superior) - Docker Engine v24.0.6 e Docker Compose v2.21.0 (ou superior) -### Ambiente +### 💻 Ambiente Para configurar o ambiente, você pode rodar o seguinte script: @@ -27,7 +73,7 @@ Para configurar o ambiente, você pode rodar o seguinte script: make config ``` -### Dependências do projeto +### 📁 Dependências do projeto Para instalar as dependências do projeto, você pode rodar os seguintes comando: @@ -42,7 +88,7 @@ source env/bin/activate make install ``` -### Execução +### 💾 Execução Para executar o projeto, você pode rodar o seguinte comando: @@ -50,7 +96,7 @@ Para executar o projeto, você pode rodar o seguinte comando: docker compose up ``` -**Observações do Docker:** +#### Observações do Docker ```bash # Se você quiser rodar em segundo plano @@ -63,14 +109,69 @@ docker compose up --build docker compose down -v ``` -### Migrations +### ✅ Autenticação do Google OAuth + +Para que o login com o Google funcione, é necessário trocar o `your_client_id` no arquivo `web/.env.local` pelo **Client ID** do projeto no Google Cloud. + +1. Crie um projeto no [Google Cloud](https://console.cloud.google.com/). +2. Vá para a página de [Credenciais](https://console.cloud.google.com/apis/credentials) do projeto. +3. Clique em **Criar credenciais** e selecione **ID do cliente OAuth**. +4. Selecione **Aplicativo da Web**. +5. Adicione `http://localhost:3000` como **Origens JavaScript autorizadas** e **URIs de redirecionamento autorizadas**. +6. Copie o **Client ID** e cole no arquivo `web/.env.local` no lugar de `your_client_id`. + +Após isto: + +1. Vá para a página de [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). +2. Selecione **Usuários externos** e clique em **Criar**. +3. Preencha os campos obrigatórios e clique em **Salvar e continuar**. +4. Na seção **Usuários de Teste** adicione o seu e-mail e clique em **Adicionar**. +5. Clique em **Salvar e continuar**. + +Adicionando serviços: + +1. Entre na aba **APIs e Serviços**. +2. Clique em **Ativar APIs e Serviços**. +3. Ative os seguintes serviços: + - IAM Service Account Credentials API + - Identity and Access Management (IAM) API + + +### 🖱️ Acesso aos serviços + +| Serviço | URL | +| :--- | :----: | +| Frontend | [http://localhost:3000](http://localhost:3000) | +| Backend | [http://localhost:8000](http://localhost:8000) | + +### 📍 Migrations + +Migration é um recurso do Django que permite que você altere o modelo de dados do seu projeto. Portanto, sempre que você alterar o modelo de dados, você deve criar uma nova migration. Para criar possíveis novas migrations, você pode rodar o seguinte comando: ```bash -python3 ./api/manage.py makemigrations --settings=core.settings.dev +# Crie as migrations +make makemigrations + +# Execute as migrations +make migrate ``` ## 📚 Documentação A documentação do projeto pode ser encontrada clicando [aqui](https://unb-mds.github.io/2023-2-Squad11/). + +## 📎 Extra + +### Story Map e Activity Flow + +- Para acessar o Story Map e o Activity Flow, clique [aqui](https://miro.com/app/board/uXjVNYnku7s=/?share_link_id=596015837126). + +### Arquitetura + +- Para acessar a arquitetura do projeto, clique [aqui](https://www.figma.com/file/ZhAq8LRcclpWHYi4XnUySw/Sua-Grade-UnB---System-Design?type=whiteboard&node-id=0%3A1&t=k46HHNk4NotrkTpX-1). + +### Protótipo + +- Para acessar o protótipo do projeto, clique [aqui](https://www.figma.com/proto/o5Ffh1fWmmQz7KcDGuHrVP/Sua-grade-UNB?type=design&node-id=16-2775&scaling=scale-down&page-id=0%3A1&mode=design&t=L5JwoVdZsjyLBGdb-1). \ No newline at end of file diff --git a/api/api/management/commands/updatedb.py b/api/api/management/commands/updatedb.py index 812afbeb..7e59bcd6 100644 --- a/api/api/management/commands/updatedb.py +++ b/api/api/management/commands/updatedb.py @@ -15,8 +15,8 @@ def add_arguments(self, parser: CommandParser) -> None: """Adiciona os argumentos do comando.""" parser.add_argument('-a', '-all', action='store_true', dest='all', default=False, help="Atualiza o banco de dados com as disciplinas dos períodos atual e seguinte.") - - parser.add_argument('-p', '--period', action='store', default=None, + + parser.add_argument('-p', '--period', action='store', default=None, choices=[".".join(sessions.get_current_year_and_period()), ".".join(sessions.get_next_period())], dest='period', help="Atualiza o banco de dados com as disciplinas do período especificado.") @@ -35,19 +35,19 @@ def handle(self, *args: Any, **options: Any): print("Nenhum período foi especificado.") print("Utilize o comando 'updatedb -h' para mais informações.") return - - # Obtem o ano e o período atual e o ano e o período seguinte + + # Obtem o ano e o período anterior ao período atual previous_period_year, previous_period = sessions.get_previous_period() - # Apaga as disciplinas do período interior + # Apaga as disciplinas do período anterior delete_all_departments_using_year_and_period( year=previous_period_year, period=previous_period) - + if options["delete"]: for year, period in choices: self.delete_period(year=year, period=period) return - + departments_ids = web_scraping.get_list_of_departments() if departments_ids is None: @@ -55,7 +55,7 @@ def handle(self, *args: Any, **options: Any): return print("Atualizando o banco de dados...") - + for year, period in choices: start_time = time() self.update_departments(departments_ids=departments_ids, year=year, period=period) @@ -68,14 +68,14 @@ def update_departments(self, departments_ids: list, year: str, period: str) -> N department_id=department_id, current_year=year, current_period=period) department = get_or_create_department( code=department_id, year=year, period=period) - + # Para cada disciplina do período atual, deleta as turmas previamente cadastradas e cadastra novas turmas no banco de dados for discipline_code in disciplines_list: classes_info = disciplines_list[discipline_code] # Cria ou pega a disciplina discipline = get_or_create_discipline( name=classes_info[0]["name"], code=discipline_code, department=department) - + # Deleta as turmas previamente cadastradas delete_classes_from_discipline(discipline=discipline) @@ -104,5 +104,5 @@ def display_success_update_message(self, operation: str, start_time: float) -> N def display_success_delete_message(self, operation: str, start_time: float) -> None: print("Operação de remoção do banco de dados realizada com sucesso.") - print(f"Sucesso em {operation}") - print(f"Tempo de execução: {(time() - start_time):.1f}s\n") \ No newline at end of file + print(f"Sucesso em {operation}") + print(f"Tempo de execução: {(time() - start_time):.1f}s\n") diff --git a/api/api/migrations/0004_alter_class_special_dates.py b/api/api/migrations/0004_alter_class_special_dates.py new file mode 100644 index 00000000..485e9bef --- /dev/null +++ b/api/api/migrations/0004_alter_class_special_dates.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.5 on 2023-11-27 15:05 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_remove_class_workload_class_special_dates_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='class', + name='special_dates', + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), size=3), default=list, size=None), + ), + ] diff --git a/api/api/migrations/0004_auto_20231126_1740.py b/api/api/migrations/0004_auto_20231126_1740.py new file mode 100644 index 00000000..30b689f7 --- /dev/null +++ b/api/api/migrations/0004_auto_20231126_1740.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2023-11-26 20:40 + +from django.db import migrations +from django.contrib.postgres.operations import CreateExtension + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_remove_class_workload_class_special_dates_and_more'), + ] + + operations = [ + CreateExtension("pg_trgm"), + migrations.RunSQL( + "CREATE TEXT SEARCH CONFIGURATION portuguese_unaccent( COPY = portuguese );" + ), + migrations.RunSQL( + "ALTER TEXT SEARCH CONFIGURATION portuguese_unaccent " + + "ALTER MAPPING FOR hword, hword_part, word " + + "WITH unaccent, portuguese_stem;" + ) + ] diff --git a/api/api/migrations/0005_discipline_unicode_name.py b/api/api/migrations/0005_discipline_unicode_name.py new file mode 100644 index 00000000..2954b79b --- /dev/null +++ b/api/api/migrations/0005_discipline_unicode_name.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-11-27 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_auto_20231126_1740'), + ] + + operations = [ + migrations.AddField( + model_name='discipline', + name='unicode_name', + field=models.CharField(default='', max_length=128), + ), + ] diff --git a/api/api/migrations/0006_merge_20231127_2255.py b/api/api/migrations/0006_merge_20231127_2255.py new file mode 100644 index 00000000..0b240194 --- /dev/null +++ b/api/api/migrations/0006_merge_20231127_2255.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.5 on 2023-11-28 01:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_alter_class_special_dates'), + ('api', '0005_discipline_unicode_name'), + ] + + operations = [ + ] diff --git a/api/api/models.py b/api/api/models.py index ce73a062..b3fd1148 100644 --- a/api/api/models.py +++ b/api/api/models.py @@ -1,4 +1,5 @@ from django.db import models +from unidecode import unidecode from django.contrib.postgres.fields import ArrayField class Department(models.Model): @@ -17,15 +18,21 @@ def __str__(self): class Discipline(models.Model): """Classe que representa uma disciplina. name:str -> Nome da disciplina + unicode_name:str -> Nome da disciplina normalizado code:str -> Código da disciplina department:Department -> Departamento da disciplina """ 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') 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. @@ -42,7 +49,13 @@ class Class(models.Model): days = ArrayField(models.CharField(max_length=64)) _class = models.CharField(max_length=64) discipline = models.ForeignKey(Discipline, on_delete=models.CASCADE, related_name='classes') - special_dates = ArrayField(models.CharField(max_length=256), default=list) + special_dates = ArrayField( + ArrayField( + models.CharField(max_length=256), + size=3, + ), + default=list + ) def __str__(self): return self._class diff --git a/api/api/swagger.py b/api/api/swagger.py new file mode 100644 index 00000000..445dfb62 --- /dev/null +++ b/api/api/swagger.py @@ -0,0 +1,40 @@ +from drf_yasg import openapi +from requests import status_codes + + +class Errors(): + + def __init__(self, erros: list[int]) -> None: + self.erros = erros + + def get_schema(self) -> openapi.Schema: + return openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'errors': openapi.Schema( + type=openapi.TYPE_STRING, + description='Mensagem de erro' + ), + } + ) + + def add_error(self, swagger_erros: dict[openapi.Response], error: int, key_error: KeyError): + check_error = str(error).startswith('4') or str(error).startswith('5') + if check_error: + message = status_codes._codes[error][0].upper() + swagger_erros[error] = openapi.Response(message, self.get_schema()) + else: # pragma: no cover + raise key_error + + def retrieve_erros(self) -> dict[openapi.Response]: + swagger_erros = dict() + + for error in self.erros: + key_error = KeyError(f'Code {error} is not a valid HTTP error.') + + try: + self.add_error(swagger_erros, error, key_error) + except KeyError: # pragma: no cover + raise key_error + + return swagger_erros diff --git a/api/api/templates/404.html b/api/api/templates/404.html new file mode 100644 index 00000000..cc634da8 --- /dev/null +++ b/api/api/templates/404.html @@ -0,0 +1,46 @@ + + + + + + + Page Not Found + + + +

+ 404 - Page Not Found +

+

Sorry, the page you requested does not exist.

+

Click here to go back to the home page.

+ + + + + \ No newline at end of file diff --git a/api/api/tests/test_discipline_models.py b/api/api/tests/test_discipline_models.py index 0a4d5b7e..c59a7e23 100644 --- a/api/api/tests/test_discipline_models.py +++ b/api/api/tests/test_discipline_models.py @@ -1,6 +1,7 @@ from django.test import TestCase from api.models import Department, Discipline, Class + class DisciplineModelsTest(TestCase): def setUp(self): self.department = Department.objects.create( @@ -50,4 +51,4 @@ def test_str_method_of_class(self): self.assertEqual(str(self._class), self._class._class) def test_str_method_of_department(self): - self.assertEqual(str(self.department), self.department.code) \ No newline at end of file + self.assertEqual(str(self.department), self.department.code) diff --git a/api/api/tests/test_search_api.py b/api/api/tests/test_search_api.py index c28c1582..cc039a6b 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 +from api.views import ERROR_MESSAGE, ERROR_MESSAGE_SEARCH_LENGTH import json class TestSearchAPI(APITestCase): @@ -16,42 +16,6 @@ def setUp(self) -> None: 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) - def test_with_complete_correct_search(self): - """ - Testa a busca por disciplinas com o nome completo e todos os parâmetros corretos - - Testes: - - Status code (200 OK) - - Quantidade de disciplinas retornadas - - Código do departamento - - Nome da disciplina - - Professores da disciplina - """ - response_for_discipline_1 = self.client.get( - '/courses/?search=calculo+1&year=2023&period=2') - response_for_discipline_2 = self.client.get( - '/courses/?search=calculo+2&year=2023&period=2') - content_1 = json.loads(response_for_discipline_1.content) - content_2 = json.loads(response_for_discipline_2.content) - - # Testes da disciplina 1 - self.assertEqual(response_for_discipline_1.status_code, 200) - self.assertEqual(len(content_1), 1) - self.assertEqual(content_1[0]['department'] - ['code'], self.department.code) - self.assertEqual(content_1[0]['name'], self.discipline_1.name) - self.assertEqual(content_1[0]['classes'][0] - ['teachers'], self._class_1.teachers) - - # Testes da disciplina 2 - self.assertEqual(response_for_discipline_2.status_code, 200) - self.assertEqual(len(content_2), 1) - self.assertEqual(content_2[0]['department'] - ['code'], self.department.code) - self.assertEqual(content_2[0]['name'], self.discipline_2.name) - self.assertEqual(content_2[0]['classes'][0] - ['teachers'], self._class_2.teachers) - def test_with_incomplete_correct_search(self): """ Testa a busca por disciplinas com nome incompleto e todos os parâmetros corretos @@ -104,29 +68,6 @@ def test_with_code_search(self): # Testes da disciplina 2 self.assertEqual(response_for_discipline_2.status_code, 200) self.assertEqual(len(content_2), 1) - - def test_with_code_search_spaced(self): - """ - Testa a busca por disciplinas através do código da matéria com espaços - Testes: - - Status code (200 OK) - - Quantidade de disciplinas retornadas - """ - - response_for_discipline_1 = self.client.get( - '/courses/?search=MAT+518&year=2023&period=2') - response_for_discipline_2 = self.client.get( - '/courses/?search=MAT+519&year=2023&period=2') - content_1 = json.loads(response_for_discipline_1.content) - content_2 = json.loads(response_for_discipline_2.content) - - # Testes da disciplina 1 - self.assertEqual(response_for_discipline_1.status_code, 200) - self.assertEqual(len(content_1), 1) - - # Testes da disciplina 2 - self.assertEqual(response_for_discipline_2.status_code, 200) - self.assertEqual(len(content_2), 1) def test_with_bad_url_search_missing_year(self): """ @@ -250,4 +191,18 @@ def test_with_only_spaces(self): self.assertEqual(response_3.status_code, 400) self.assertEqual(len(content_3), 1) - self.assertEqual(content_3['errors'], ERROR_MESSAGE) \ No newline at end of file + self.assertEqual(content_3['errors'], ERROR_MESSAGE) + + def test_with_insufficient_search_length(self): + """ + Testa a busca por disciplinas com menos de 4 caracteres no parâmetro de busca + Testes: + - Status code (400 BAD REQUEST) + """ + + response_1 = self.client.get('/courses/?search=cal&year=2023&period=2') + content_1 = json.loads(response_1.content) + + self.assertEqual(response_1.status_code, 400) + self.assertEqual(len(content_1), 1) + self.assertEqual(content_1['errors'], ERROR_MESSAGE_SEARCH_LENGTH) \ No newline at end of file diff --git a/api/api/tests/test_year_period_api.py b/api/api/tests/test_year_period_api.py new file mode 100644 index 00000000..87d8e071 --- /dev/null +++ b/api/api/tests/test_year_period_api.py @@ -0,0 +1,25 @@ +from rest_framework.test import APITestCase +from utils import sessions as sns + + +class TestYearPeriodAPI(APITestCase): + + def test_year_period(self): + """ + Testa se a API retorna o ano/periodo atual e o próximo ano/periodo + + Testes: + - Status code (200 OK) + - Dados retornados (ano/periodo atual e próximo ano/periodo) + """ + + response = self.client.get('/courses/year-period/') + year, period = sns.get_current_year_and_period() + next_year, next_period = sns.get_next_period() + + expected_data = { + 'year/period': [f'{year}/{period}', f'{next_year}/{next_period}'], + } + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, expected_data) diff --git a/api/api/urls.py b/api/api/urls.py index b613f9ac..42e281f2 100644 --- a/api/api/urls.py +++ b/api/api/urls.py @@ -4,5 +4,6 @@ app_name = 'api' urlpatterns = [ - path('', views.Search.as_view(), name="search") -] \ No newline at end of file + path('', views.Search.as_view(), name="search"), + path('year-period/', views.YearPeriod.as_view(), name="year-period") +] diff --git a/api/api/views.py b/api/api/views.py index 0aa07fa9..2ab4cb10 100644 --- a/api/api/views.py +++ b/api/api/views.py @@ -1,46 +1,125 @@ -from utils.db_handler import filter_disciplines_by_name, filter_disciplines_by_code, filter_disciplines_by_year_and_period +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 .serializers import DisciplineSerializer -from rest_framework.response import Response -from rest_framework.request import Request -from rest_framework import status +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 -MAXIMUM_RETURNED_DISCIPLINES = 5 +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" class Search(APIView): + def treat_string(self, string: str | None) -> str | None: if string is not None: string = string.strip() return string - - def get(self, request: Request, *args, **kwargs) -> Response: + + def filter_disciplines(self, request: request.Request, name: str) -> QuerySet[Discipline]: + unicode_name = unidecode(name).casefold() + + model_handler = admin.ModelAdmin(Discipline, admin.site) + model_handler.search_fields = ['unicode_name', 'code'] + + disciplines = Discipline.objects.all() + disciplines, _ = model_handler.get_search_results( + request, disciplines, unicode_name) + + return disciplines + + @swagger_auto_schema( + operation_description="Busca disciplinas por nome ou código. O ano e período são obrigatórios.", + manual_parameters=[ + openapi.Parameter('search', openapi.IN_QUERY, + description="Termo de pesquisa (Nome/Código)", type=openapi.TYPE_STRING), + openapi.Parameter('year', openapi.IN_QUERY, + description="Ano", type=openapi.TYPE_INTEGER), + openapi.Parameter('period', openapi.IN_QUERY, + description="Período ", type=openapi.TYPE_INTEGER), + ], + responses={ + 200: openapi.Response('OK', serializers.DisciplineSerializer), + **Errors([400]).retrieve_erros() + } + ) + def get(self, request: request.Request, *args, **kwargs) -> response.Response: 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)) - if name is None or len(name) == 0 or year is None or len(year) == 0 or period is None or len(period) == 0: - return Response( + name_verified = name is not None and len(name) > 0 + year_verified = year is not None and len(year) > 0 + 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) - name = name.split() - disciplines = filter_disciplines_by_name(name=name[0]) + if len(name) < MINIMUM_SEARCH_LENGTH: + return response.Response( + { + "errors": ERROR_MESSAGE_SEARCH_LENGTH + }, status.HTTP_400_BAD_REQUEST) - for term in name[1:]: - disciplines &= filter_disciplines_by_name(name=term) + disciplines = self.filter_disciplines(request, name) + disciplines = dbh.get_best_similarities_by_name(name, disciplines) if not disciplines.count(): - disciplines = filter_disciplines_by_code(code=name[0]) + disciplines = dbh.filter_disciplines_by_code(code=name[0]) for term in name[1:]: - disciplines &= filter_disciplines_by_code(code=term) - - filtered_disciplines = filter_disciplines_by_year_and_period( + disciplines &= dbh.filter_disciplines_by_code(code=term) + + disciplines = dbh.filter_disciplines_by_code(name) + + filtered_disciplines = dbh.filter_disciplines_by_year_and_period( year=year, period=period, disciplines=disciplines) - data = DisciplineSerializer(filtered_disciplines, many=True).data + data = serializers.DisciplineSerializer( + filtered_disciplines, many=True).data + + return response.Response(data[:MAXIMUM_RETURNED_DISCIPLINES], status.HTTP_200_OK) + + +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.", + responses={ + 200: openapi.Response('OK', openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'year/period': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_STRING + ) + ) + } + ), examples={ + 'application/json': { + 'year/period': ['2020/1', '2020/2'] + } + }) + } + ) + def get(self, request: request.Request, *args, **kwargs) -> response.Response: + year, period = get_current_year_and_period() + next_year, next_period = get_next_period() + + data = { + 'year/period': [f'{year}/{period}', f'{next_year}/{next_period}'], + } - return Response(data[:MAXIMUM_RETURNED_DISCIPLINES], status.HTTP_200_OK) + return response.Response(data, status.HTTP_200_OK) diff --git a/api/core/settings/base.py b/api/core/settings/base.py index 72e6afeb..968e54b4 100644 --- a/api/core/settings/base.py +++ b/api/core/settings/base.py @@ -43,6 +43,7 @@ 'django.contrib.staticfiles', 'django.contrib.postgres', 'corsheaders', + 'drf_yasg', 'api', 'users', 'utils', @@ -102,7 +103,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'api/templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ diff --git a/api/core/urls.py b/api/core/urls.py index 4a3bed89..21691b04 100644 --- a/api/core/urls.py +++ b/api/core/urls.py @@ -16,10 +16,47 @@ """ from django.contrib import admin from django.urls import path, include +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi import sys +schema_view = get_schema_view( + openapi.Info( + title="Sua Grade UnB - API", + default_version='v1', + description=""" +
+ O Sua Grade UnB é um projeto da matéria Métodos de Desenvolvimento de Software, a qual tem como objetivo auxiliar os alunos da Universidade de Brasília a montarem suas grades horárias de maneira fácil e intuitiva.
+ Contribuidores:
+ +
+ Mais especificações sobre o projeto por completo podem ser encontradas aqui. +
+ """, + contact=openapi.Contact(email="suagradeunb@gmail.com"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + urlpatterns = [ + # Admin path('admin/', admin.site.urls), + + # Documentation + path('', schema_view.with_ui('redoc', cache_timeout=0), name='redoc'), + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='swagger'), + + # Views path('users/', include('users.urls')), path('courses/', include('api.urls')) ] diff --git a/api/setup.cfg b/api/setup.cfg new file mode 100644 index 00000000..9ac5f27c --- /dev/null +++ b/api/setup.cfg @@ -0,0 +1,9 @@ +[coverage:run] +# coverage.py configuration: +# https://coverage.readthedocs.io/en/latest/ + +# 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 = + manage.py + users/admin.py \ No newline at end of file diff --git a/api/users/backends/google.py b/api/users/backends/google.py index e54a5b81..31b32a4a 100644 --- a/api/users/backends/google.py +++ b/api/users/backends/google.py @@ -18,7 +18,7 @@ def get_user_data(cls, access_token: str) -> dict | None: try: response = Response() - response.status_code = status.HTTP_400_BAD_REQUEST + response.status_code = status.HTTP_400_BAD_REQUEST response.headers = {'Content-Type': 'application/json'} if access_token != config('GOOGLE_OAUTH2_MOCK_TOKEN'): @@ -37,23 +37,23 @@ def get_user_data(cls, access_token: str) -> dict | None: return user_data else: return None - except requests.exceptions.RequestException as e: + except requests.exceptions.RequestException as e: # pragma: no cover return None @staticmethod def do_auth(user_data: dict) -> User | None: user, created = User.objects.get_or_create( - first_name=user_data['given_name'], - email=user_data['email'], - ) + email=user_data['email']) + + if user_data.get('given_name'): + user.first_name = user_data['given_name'] if user_data.get('family_name'): user.last_name = user_data['family_name'] - + if user_data.get('picture'): user.picture_url = user_data['picture'] - if created: - user.save() + user.save() return user diff --git a/api/users/tests/__init__.py b/api/users/tests/__init__.py index de9d1eb2..e69de29b 100644 --- a/api/users/tests/__init__.py +++ b/api/users/tests/__init__.py @@ -1,3 +0,0 @@ -from users.tests.register import * -from users.tests.logout import * -from users.tests.login import * diff --git a/api/users/tests/login.py b/api/users/tests/login.py deleted file mode 100644 index 3912a18d..00000000 --- a/api/users/tests/login.py +++ /dev/null @@ -1,157 +0,0 @@ -from rest_framework.test import APITestCase -from django.http import HttpResponse -from rest_framework_simplejwt.serializers import TokenObtainPairSerializer -from users.models import User -from datetime import timedelta -from django.urls import reverse -from rest_framework import status -from http.cookies import SimpleCookie -from decouple import config -import time -import jwt - - -class UserSessionLoginTests(APITestCase): - """ - Classe específica para testar a rota de login de usuários. - - ``` - from users.views import RefreshJWTView - ``` - """ - - def setUp(self) -> None: - """ - Método para criar um usuário para os testes. - """ - - 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() - - def make_login_post_request(self, cookie_enable: bool = True, cookie_expired: bool = False, cookie_value: str | None = None) -> HttpResponse: - """ - Método para fazer uma requisição POST para a rota de login de usuários. - - Args: - cookie_enable (bool): Habilita o uso de cookies. - cookie_expired (bool): Habilita o cookie expirado. - cookie_value (str | None): Valor do cookie. - - Returns: - response (HttpResponse): Resposta do servidor. - """ - - if cookie_enable: - refresh_token = TokenObtainPairSerializer.get_token(self.user) - - if cookie_expired: - refresh_token.set_exp(lifetime=timedelta(days=0)) - - self.client.cookies = SimpleCookie( - {'refresh': refresh_token if not cookie_value else cookie_value} - ) - - url = reverse('users:login') - return self.client.post(url, {}, format='json') - - def test_user_login_with_invalid_token(self) -> None: - """ - Testa o login de um usuário com um token inválido. - - Testes: - - Status code (401 UNAUTHORIZED). - """ - - response = self.make_login_post_request(cookie_value='wrong_token') - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_user_login_with_valid_token(self) -> None: - """ - Testa o login de um usuário com um token válido. - - Testes: - - Status code (200 OK). - - Nome do usuário. - - Sobrenome do usuário. - - E-mail do usuário. - """ - - response = self.make_login_post_request() - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['first_name'], self.user.first_name) - self.assertEqual(response.data['last_name'], self.user.last_name) - self.assertEqual(response.data['picture_url'], self.user.picture_url) - self.assertEqual(response.data['email'], self.user.email) - - def test_user_login_access_token(self) -> None: - """ - Testa access token do usuário com um token válido. - - Testes: - - Access token no corpo da resposta. - - Tempo de vida do access token. - """ - - response = self.make_login_post_request() - - access_token = response.data.get('access') - decoded_token = jwt.decode( - access_token, - config('DJANGO_SECRET_KEY'), - algorithms=["HS256"], - ) - - def check_in_range(decoded_token): - current_time = int(time.time()) - seconds_access_token = 1 * 24 * 60 * 60 # 1 day in seconds - expected_expiration = current_time + seconds_access_token - - real_expiration = decoded_token.get('exp') - - lower = real_expiration <= expected_expiration + 10 - higher = expected_expiration - 10 <= real_expiration - - return lower and higher - - self.assertTrue(check_in_range(decoded_token)) - self.assertTrue('access' in response.data) - - def test_user_login_without_refresh_token(self) -> None: - """ - Testa o login de um usuário sem o refresh token. - - Testes: - - Código de erro. - - Mensagem de erro. - - Status code (401 UNAUTHORIZED). - """ - - response = self.make_login_post_request(cookie_enable=False) - - self.assertEqual(response.data.get('detail').code, 'not_authenticated') - self.assertEqual(response.data.get('detail'), 'Refresh cookie error.') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_user_login_with_expired_cookie(self) -> None: - """ - Testa o login de um usuário com um cookie expirado. - - Testes: - - Código de erro. - - Mensagem de erro. - - Status code (401 UNAUTHORIZED). - """ - - response = self.make_login_post_request(cookie_expired=True) - - self.assertEqual(response.data.get('code'), 'token_not_valid') - self.assertEqual(response.data.get('detail'), - 'Token is invalid or expired') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/api/users/tests/register.py b/api/users/tests/register.py deleted file mode 100644 index 4598297e..00000000 --- a/api/users/tests/register.py +++ /dev/null @@ -1,161 +0,0 @@ -from rest_framework.test import APITestCase -from django.http import HttpResponse -from decouple import config -from rest_framework import status -from django.urls import reverse -from users.models import User - - -class UserSessionRegisterTests(APITestCase): - """ - Classe específica para testar a rota de registro de usuários. - - ``` - from users.views import Register - ``` - """ - - def make_register_post_request(self, access_token: str | None = None, provider: str | None = None) -> HttpResponse: - """ - Método para fazer uma requisição POST para a rota de registro de usuários. - - Args: - access_token (str | None): Token de acesso do usuário. - provider (str | None): Provedor de autenticação do usuário. - - Returns: - response (HttpResponse): Resposta do servidor. - - Observações: - - Se o token de acesso for None, será usado um token mock. - """ - - if access_token == None: - access_token = config('GOOGLE_OAUTH2_MOCK_TOKEN') - - url = reverse('users:register', kwargs={'oauth2': provider}) - info = { - 'access_token': access_token - } - - return self.client.post(url, info, format='json') - - def test_google_register_with_invalid_token(self) -> None: - """ - Testa o registro de um usuário com um token inválido. - - Testes: - - Mensagem de erro. - - Status code (400 BAD REQUEST). - """ - - response = self.make_register_post_request( - access_token='wrong_token', - provider='google' - ) - - self.assertEqual(response.data.get('errors'), 'Invalid token') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_google_register_with_empty_token(self) -> None: - """ - Testa o registro de um usuário com um token vazio. - - Testes: - - Mensagem de erro. - - Status code (400 BAD REQUEST). - """ - - response = self.make_register_post_request( - access_token='', - provider='google' - ) - - self.assertEqual(response.data.get('errors'), 'Invalid token') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_google_register_with_valid_token(self) -> None: - """ - Testa o registro de um usuário com um token válido. - - Testes: - - Usuário criado ou acessado. - - Nome do usuário. - - Sobrenome do usuário. - - E-mail do usuário. - - Status code (200 OK). - """ - - response = self.make_register_post_request( - provider='google' - ) - - users = User.objects.all() - self.assertEqual(len(users), 1) - - created_user = users.get(email='user@email.com') - self.assertEqual(created_user.first_name, 'given_name') - self.assertEqual(created_user.last_name, 'family_name') - self.assertEqual(created_user.picture_url, 'https://photo.aqui.com') - self.assertEqual(created_user.email, 'user@email.com') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_register_with_invalid_provider(self) -> None: - """ - Testa o registro de um usuário com um provedor inválido. - - Testes: - - Mensagem de erro. - - Status code (400 BAD REQUEST). - """ - - provider = 'wrong_provider' - response = self.make_register_post_request( - access_token='token', - provider=provider - ) - - erros = response.data.get('errors') - self.assertEqual(erros, f'Invalid provider {provider}') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_google_register_refresh_cookie_when_valid_token(self) -> None: - """ - Testa o refresh cookie quando o token de registro de usuário é válido. - - Testes: - - Refresh cookie. - - Tempo de vida do cookie. - - HttpOnly. - - Secure. - - Samesite. - """ - - response = self.make_register_post_request( - provider='google' - ) - - refresh_cookie = response.cookies.get('refresh') - max_age = 30 * 24 * 60 * 60 # 30 days in seconds - self.assertFalse("refresh" in response.data) - self.assertEqual(refresh_cookie.get('max-age'), max_age) - self.assertTrue(refresh_cookie.get('httponly')) - self.assertTrue(refresh_cookie.get('secure')) - self.assertEqual(refresh_cookie.get('samesite'), "Lax") - - def test_google_register_refresh_cookie_when_invalid_token(self) -> None: - """ - Testa o refresh cookie quando o token de registro de usuário é inválido. - - Testes: - - Refresh cookie não existe. - - Refresh cookie não está no corpo da resposta. - """ - - response = self.make_register_post_request( - access_token='wrong_token', - provider='google' - ) - - self.assertFalse("refresh" in response.cookies) - self.assertFalse("refresh" in response.data) diff --git a/api/users/tests/test_session_login.py b/api/users/tests/test_session_login.py index bb101d86..3d981c98 100644 --- a/api/users/tests/test_session_login.py +++ b/api/users/tests/test_session_login.py @@ -68,7 +68,7 @@ def test_user_login_with_invalid_token(self) -> None: response = self.make_login_post_request(cookie_value='wrong_token') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_user_login_with_valid_token(self) -> None: """ @@ -150,7 +150,5 @@ def test_user_login_with_expired_cookie(self) -> None: response = self.make_login_post_request(cookie_expired=True) - self.assertEqual(response.data.get('code'), 'token_not_valid') - self.assertEqual(response.data.get('detail'), 'Token is invalid or expired') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - + self.assertEqual(response.data.get('errors'), 'Token is invalid or expired') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/users/tests/logout.py b/api/users/tests/test_session_logout.py similarity index 95% rename from api/users/tests/logout.py rename to api/users/tests/test_session_logout.py index d8162cbf..60159e8c 100644 --- a/api/users/tests/logout.py +++ b/api/users/tests/test_session_logout.py @@ -42,7 +42,7 @@ def test_logout_user_with_valid_token(self): def test_logout_user_with_invalid_refresh_token(self): response = self.make_logout_post_request(cookie_value='wrong_token') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_logout_user_without_refresh_token(self): response = self.make_logout_post_request(cookie_enable=False) diff --git a/api/users/views.py b/api/users/views.py index afac32ff..ff1f771c 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -1,21 +1,71 @@ -from rest_framework import status -from rest_framework import exceptions -from rest_framework.response import Response -from rest_framework.request import Request +from rest_framework import status, exceptions, response, request +from rest_framework_simplejwt import exceptions as jwt_exceptions from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView from users.backends.utils import get_backend from users.simplejwt.decorators import move_refresh_token_to_cookie +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from api.swagger import Errors class Register(TokenObtainPairView): + GOOGLE_HELP_URL = "https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow?hl=pt-br" + + @swagger_auto_schema( + operation_description="""Registra um novo usuário no sistema, ou retorna os dados caso o mesmo já exista. + O header da resposta acompanha o token de `refresh` nos cookies no seguinte formato: + + headers = { + "Set-Cookie": "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=" + } + """, + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'access_token': openapi.Schema( + type=openapi.TYPE_STRING, + description=f"""Token de acesso provido pelo provedor \n de autenticação Google. + Os passos para obter o token podem ser encontrados [aqui]({GOOGLE_HELP_URL})""" + ), + } + ), + responses={ + 200: openapi.Response('OK', openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'access': openapi.Schema( + type=openapi.TYPE_STRING, + description='Token de acesso JWT' + ), + 'first_name': openapi.Schema( + type=openapi.TYPE_STRING, + description='Primeiro nome do usuário' + ), + 'last_name': openapi.Schema( + type=openapi.TYPE_STRING, + description='Último nome do usuário' + ), + 'picture_url': openapi.Schema( + type=openapi.TYPE_STRING, + description='URL da foto do usuário' + ), + 'email': openapi.Schema( + type=openapi.TYPE_STRING, + description='Email do usuário' + ), + } + )), + **Errors([400]).retrieve_erros() + } + ) @move_refresh_token_to_cookie - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: request.Request, *args, **kwargs) -> response.Response: token = request.data.get('access_token') backend = get_backend(kwargs['oauth2']) if not backend: - return Response( + return response.Response( { 'errors': f'Invalid provider {kwargs["oauth2"]}' }, status=status.HTTP_400_BAD_REQUEST) @@ -35,9 +85,9 @@ def post(self, request: Request, *args, **kwargs) -> Response: 'email': user.email, } - return Response(data, status.HTTP_200_OK) + return response.Response(data, status.HTTP_200_OK) - return Response( + return response.Response( { 'errors': 'Invalid token' }, status.HTTP_400_BAD_REQUEST) @@ -54,16 +104,79 @@ def handle(self, request): return request -class RefreshJWTView(TokenRefreshView, HandleRefreshMixin): +class HandlePostErrorMixin(): + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except jwt_exceptions.TokenError: + return response.Response({ + "errors": "Token is invalid or expired" + }, status=status.HTTP_400_BAD_REQUEST) + + return response.Response(serializer.validated_data, status=status.HTTP_200_OK) + + +class RefreshJWTView(HandlePostErrorMixin, HandleRefreshMixin, TokenRefreshView): + + @swagger_auto_schema( + operation_description="""Atualiza o token de acesso JWT, caso o token de `refresh` esteja presente nos **request cookies**. + O header da resposta acompanha o novo token de `refresh` nos cookies no seguinte formato: + + // Request + headers = { + Cookie: "refresh=" + } + + // Response + headers = { + "Set-Cookie": "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=" + } + """, + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + ), + responses={ + 200: openapi.Response('OK', openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'access': openapi.Schema( + type=openapi.TYPE_STRING, + description='Token de acesso JWT' + ), + } + )), + **Errors([400]).retrieve_erros() + } + ) @move_refresh_token_to_cookie def post(self, request, *args, **kwargs): request = self.handle(request) return super().post(request, *args, **kwargs) -class BlacklistJWTView(TokenBlacklistView, HandleRefreshMixin): +class BlacklistJWTView(HandlePostErrorMixin, HandleRefreshMixin, TokenBlacklistView): + @swagger_auto_schema( + operation_description="""Revoga o Token de acesso JWT, caso o token de `refresh` esteja presente nos **request cookies**. + + // Request + headers = { + Cookie: "refresh=" + } + """, + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + ), + responses={ + 200: openapi.Response('OK', openapi.Schema( + type=openapi.TYPE_OBJECT, + )), + **Errors([400]).retrieve_erros() + } + ) def post(self, request, *args, **kwargs): request = self.handle(request) return super().post(request, *args, **kwargs) diff --git a/api/utils/db_handler.py b/api/utils/db_handler.py index 19e72a19..a73922f2 100644 --- a/api/utils/db_handler.py +++ b/api/utils/db_handler.py @@ -1,6 +1,7 @@ from api.models import Discipline, Department, Class from django.db.models.query import QuerySet - +from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramStrictWordSimilarity +from django.db.models import Q """ Este módulo lida com as operações de banco de dados.""" @@ -28,9 +29,18 @@ 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 filter_disciplines_by_name(name: str, disciplines: Discipline = Discipline.objects) -> QuerySet: +def get_best_similarities_by_name(name: str, disciplines: Discipline = Discipline.objects, config="portuguese_unaccent") -> QuerySet: """Filtra as disciplinas pelo nome.""" - return disciplines.filter(name__unaccent__icontains=name) + vector = SearchVector("unicode_name", config=config) + query = SearchQuery(name, config=config) + values = disciplines.annotate( + search=vector, + similarity=TrigramStrictWordSimilarity(name, "unicode_name") + ).filter( + Q(search=query) | Q(similarity__gt=0) + ).all().order_by("-similarity") + + return values def filter_disciplines_by_code(code: str, disciplines: Discipline = Discipline.objects) -> QuerySet: """Filtra as disciplinas pelo código.""" diff --git a/api/utils/management/commands/updatemock.py b/api/utils/management/commands/updatemock.py index 1d085dc5..7049b2c8 100644 --- a/api/utils/management/commands/updatemock.py +++ b/api/utils/management/commands/updatemock.py @@ -4,6 +4,7 @@ from utils import sessions as sns, web_scraping as wbp from django.core.management.base import BaseCommand from pathlib import Path +import re import json import os @@ -26,12 +27,16 @@ def handle(self, *args: Any, **options: Any): current_year, current_period = sns.get_current_year_and_period() departments = wbp.get_list_of_departments() department = choice(departments) - + with open(current_path / f"mock/sigaa.html", "a") as mock_file: - discipline_scraper = wbp.DisciplineWebScraper(department, current_year, current_period) + discipline_scraper = wbp.DisciplineWebScraper( + department, current_year, current_period) response = discipline_scraper.get_response_from_disciplines_post_request() - mock_file.write(self.response_decode(response)) - + + striped_response = self.multiple_replace( + self.response_decode(response)) + mock_file.write(striped_response) + with open(current_path / "mock/infos.json", "a") as info_file: data = { "year": current_year, @@ -44,6 +49,15 @@ def handle(self, *args: Any, **options: Any): print('Não foi possível atualizar o mock!') print('Error:', error) + def multiple_replace(self, text): + replacement_dict = { + '\n': '', + '\t': '', + '\r': '', + } + pattern = re.compile('|'.join(map(re.escape, replacement_dict.keys()))) + return pattern.sub(lambda match: replacement_dict[match.group(0)], text) + def response_decode(self, response: Response) -> str: encoding = response.encoding if response.encoding else 'utf-8' return response.content.decode(encoding) diff --git a/api/utils/mock/infos.json b/api/utils/mock/infos.json index 76758a71..d774671b 100644 --- a/api/utils/mock/infos.json +++ b/api/utils/mock/infos.json @@ -1 +1 @@ -{"year": "2023", "period": "2", "department": "853"} \ No newline at end of file +{"year": "2023", "period": "2", "department": "1499"} \ No newline at end of file diff --git a/api/utils/mock/sigaa.html b/api/utils/mock/sigaa.html index 0b7c8668..ad06b691 100644 --- a/api/utils/mock/sigaa.html +++ b/api/utils/mock/sigaa.html @@ -1,1090 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SIGAA - Sistema Integrado de Gestão de Atividades Acadêmicas - - - - - - - - - - - - - - - - -
- - -
- -
- -
- Universidade de Brasília - - Brasília, - 07 de Novembro de - 2023 - - - - -
-
-
- - -
- - - - - - - - - - - - - - -

Ensino > Consulta de Turmas

- - - -
- - Caro(a) usuário(a), -

Esta página permite consultar as turmas - oferecidas pela instituição.

-

Utilize o formulário abaixo para filtrar as turmas de acordo com - os critérios desejados.

- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
Informe os critérios de busca das turmas
Nível de Ensino: - -
Unidade: - -
Ano - Período: - - - -
-   - -
- -
- - - - -
- - :Visualizar Detalhes do Componente Curricular
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
turmas encontrada(s)
CódigoAno-PeríodoDocenteHorárioQtde Vagas OfertadasQtde Vagas OcupadasLocal
- - - - - - - CPPAGR0020 - GESTÃO DA QUALIDADE NA AGROINDÚSTRIA - -
012023.2 - - - JEAN LOUIS LE GUERROUE (30h) -
- -
5T23 (25/08/2023 - 23/12/2023) - - - - - - 304 ASS271/10
- - - - - CPPAGR0023 - O AGRONEGÓCIO E O CONSUMIDOR - -
012023.2 - - - VANIA FERREIRA ROQUE SPECHT (30h) -
- -
3M34 (25/08/2023 - 23/12/2023) - - - - - - 205 ASS271/10
- - - - - CPPAGR0038 - GESTÃO DE PROGRAMAS E PROJETOS - -
012023.2 - - - JOSE MARCIO CARVALHO (30h) -
- -
5T45 (25/08/2023 - 23/12/2023) - - - - - - 204 ASS271/10
- - - - - CPPAGR0039 - AGRICULTURA ORGÂNICA E AGRONEGÓCIO - -
012023.2 - - - JOAO PAULO GUIMARAES SOARES (15h) -
- - ANA MARIA RESENDE JUNQUEIRA (15h) -
- -
6M34 (25/08/2023 - 23/12/2023) - - - - - - 209 ASS271/10
- - - - - CPPAGR2288 - LOGÍSTICA E GESTÃO DE CADEIA DE SUPRIMENTO NO AGRONEGÓCIO - -
012023.2 - - - FABRICIO OLIVEIRA LEITAO (30h) -
- -
5M34 (25/08/2023 - 23/12/2023) - - - - - - 204 ASS271/10
- - - - - CPPAGR3934 - INTRODUÇÃO AO AGRONEGÓCIO - -
012023.2 - - - GABRIEL DA SILVA MEDINA (15h) -
- - JAIM JOSE DA SILVA JUNIOR (15h) -
- -
6M12 (25/08/2023 - 25/12/2023) - - - - - - 2016 ASS 271/10
- - - - - CPPAGR3935 - ANÁLISE ECONÔMICA - -
012023.2 - - - LUIZ HONORATO DA SILVA JUNIOR (30h) -
- -
3T6 3N123 (25/08/2023 - 23/12/2023) - - - - - - 206 ASS271/10
- - - - - CPPAGR3936 - MÉTODOS E TÉCNICAS DE PESQUISA - -
012023.2 - - - MAURO EDUARDO DEL GROSSI (15h) -
- - PATRICIA GUARNIERI DOS SANTOS (15h) -
- -
4T23 (25/08/2023 - 23/12/2023) - - - - - - 2016 ASS271/10
- - - - - CPPAGR3939 - ESTÁGIO DE DOCÊNCIA - -
012023.2 - - - MARLON VINICIUS BRISOLA (30h) -
- -
6T6 6N1 (25/08/2023 - 23/12/2023) - - - - - - 21 A combinar com o professor.
022023.2 - - - MAURO EDUARDO DEL GROSSI (30h) -
- -
6T6 6N1 (25/08/2023 - 23/12/2023) - - - - - - 22 A combinar com o professor.
032023.2 - - - ANA MARIA RESENDE JUNQUEIRA (30h) -
- -
6T6 6N1 (25/08/2023 - 23/12/2023) - - - - - - 20 A combinar com o professor.
042023.2 - - - JEAN LOUIS LE GUERROUE (30h) -
- -
6T6 6N1 (25/08/2023 - 23/12/2023) - - - - - - 21 A combinar com o professor.
052023.2 - - - VANIA FERREIRA ROQUE SPECHT (30h) -
- -
6T6 6N1 (25/08/2023 - 23/12/2023) - - - - - - 21 A combinar com o professor.
062023.2 - - - SILVIA ARAUJO DOS REIS (30h) -
- -
7M12 (25/08/2023 - 25/12/2023) - - - - - - 10 A combinar com o professor da disciplina.
14 turmas encontrada(s)
-
- - - -
-
-
- Campos de preenchimento obrigatório. -
-
- -
- - -
-
- -
-

- SIGAA | Secretaria de Tecnologia da Informação - STI - (61) 3107-0102 | Copyright © 2006-2023 - UFRN - app40_Prod.sigaa34 - - v4.9.10.38 - -

-
- - -
-
- - - - - - - - - - - - - - - - - - - +SIGAA - Sistema Integrado de Gestão de Atividades Acadêmicas
Universidade de Brasília Brasília, 27 de Novembro de 2023

Ensino > Consulta de Turmas

Caro(a) usuário(a),

Esta página permite consultar as turmasoferecidas pela instituição.

Utilize o formulário abaixo para filtrar as turmas de acordo comos critérios desejados.

Informe os critérios de busca das turmas
Nível de Ensino:
Unidade:
Ano - Período: -
 

:Visualizar Detalhes do Componente Curricular
turmas encontrada(s)
CódigoAno-PeríodoDocenteHorárioQtde Vagas OfertadasQtde Vagas OcupadasLocal
PPGPS0030 - ANÁLISE DE POLÍTICAS SOCIAIS
012023.2HAYESKA COSTA BARROSO (60h)
5M1234 2517 A definir
PPGPS0032 - TÓPICOS ESPECIAIS EM POLÍTICA SOCIAL
012023.2CAMILA POTYARA PEREIRA (60h)
2M1234 2T2345 (30/10/2023 - 30/10/2023), 3M1234 (31/10/2023 - 31/10/2023), 4T2345 4N1234 (01/11/2023 - 01/11/2023), 6M1234 6T2345 (03/11/2023 - 03/11/2023), 2M1234 2T2345 (06/11/2023 - 06/11/2023), 4T2345 4N1234 (08/11/2023 - 08/11/2023), 5N1234 (09/11/2023 - 09/11/2023), 6M1234 6T2345 (10/11/2023 - 10/11/2023) 307 Pós-Graduação em Políticas Sociais
PPGPS0033 - SEGURIDADE SOCIAL E SAÚDE
012023.2MARLENE TEIXEIRA RODRIGUES (30h)
ANDREIA DE OLIVEIRA (30h)
3T2345 (25/08/2023 - 23/12/2023) 207 A definir
PPGPS0156 - SEMINÁRIO DE TESE 1 EM POLÍTICA SOCIAL
022023.2REGINALDO GHIRALDELLI (60h)
6M1234 (25/08/2023 - 27/12/2023) 31 A definir
032023.2CAMILA POTYARA PEREIRA (60h)
6M1234 (25/08/2023 - 27/12/2023) 21 A definir
042023.2MARIA LUCIA LOPES DA SILVA (60h)
6T2345 (25/08/2023 - 27/12/2023) 11 A definir
052023.2JANAINA LOPES DO NASCIMENTO DUARTE (60h)
5M1234 (25/08/2023 - 27/12/2023) 11 A definir
PPGPS0157 - SEMINÁRIO DE TESE 2 EM POLÍTICA SOCIAL
012023.2REGINALDO GHIRALDELLI (60h)
5M1234 (25/08/2023 - 27/12/2023) 11 A definir
PPGPS0161 - NUCLEAÇÃO EM GRUPOS DE PESQUISA - NPG 1
012023.2ANDREIA DE OLIVEIRA (15h)
6T6 (25/08/2023 - 27/12/2023) 51 A definir
022023.2KENIA AUGUSTA FIGUEIREDO (15h)
6T6 (25/08/2023 - 27/12/2023) 50 A definir
032023.2LUCELIA LUIZ PEREIRA (15h)
4N1 (25/08/2023 - 27/12/2023) 22 A definir
042023.2MARIA ELAENE RODRIGUES ALVES (15h)
7T6 (25/08/2023 - 27/12/2023) 22 A definir
PPGPS0163 - NUCLEAÇÃO EM GRUPOS DE PESQUISA - NPG 2
012023.2MARLENE TEIXEIRA RODRIGUES (15h)
6M3 (25/08/2023 - 27/12/2023) 50 A definir
022023.2LUCELIA LUIZ PEREIRA (15h)
5N1 (25/08/2023 - 27/12/2023) 22 A definir
PPGPS0165 - NUCLEAÇÃO EM GRUPOS DE PESQUISA - NPG 3
012023.2CRISTIANO GUEDES DE SOUZA (15h)
6T6 (25/08/2023 - 27/12/2023) 31 A definir
022023.2EVILASIO DA SILVA SALVADOR (15h)
6T2 (25/08/2023 - 27/12/2023) 51 A definir
032023.2NEWTON NARCISO GOMES JUNIOR (15h)
3T2 (25/08/2023 - 27/12/2023) 51 A definir
052023.2LUCELIA LUIZ PEREIRA (15h)
4N2 (25/08/2023 - 27/12/2023) 11 A definir
PPGPS0167 - NUCLEAÇÃO EM GRUPOS DE PESQUISA - NPG 4
012023.2JANAINA LOPES DO NASCIMENTO DUARTE (15h)
6T2 (25/08/2023 - 27/12/2023) 32 A definir
022023.2MARIA LUCIA PINTO LEAL (15h)
6T2 (25/08/2023 - 27/12/2023) 21 A definir
032023.2ANDREIA DE OLIVEIRA (15h)
7T6 (25/08/2023 - 27/12/2023) 22 A definir
PPGPS2475 - ESTADO E POLÍTICA SOCIAL NO BRASIL
012023.2NEWTON NARCISO GOMES JUNIOR (60h)
5T2345 (25/08/2023 - 23/12/2023) 2015 A definir
PPGPS2939 - CAPITALISMO, TRABALHO E QUESTÃO SOCIAL
012023.2REGINALDO GHIRALDELLI (60h)
4M1234 (25/08/2023 - 23/12/2023) 3131 A definir
PPGPS2989 - SEMINÁRIO DE PESQUISA E DISSERTAÇÃO
012023.2ANDREIA DE OLIVEIRA (60h)
7T2345 (25/08/2023 - 27/12/2023) 32 A definir
022023.2CAMILA POTYARA PEREIRA (60h)
6T2345 (25/08/2023 - 27/12/2023) 30 A definir
032023.2CRISTIANO GUEDES DE SOUZA (60h)
5T2345 (25/08/2023 - 27/12/2023) 21 A definir
042023.2HAYESKA COSTA BARROSO (60h)
6T2345 (25/08/2023 - 27/12/2023) 20 A definir
062023.2KENIA AUGUSTA FIGUEIREDO (60h)
6T2345 (25/08/2023 - 27/12/2023) 21 A definir
072023.2LEONARDO RODRIGUES DE OLIVEIRA ORTEGAL (60h)
6T2345 (25/08/2023 - 27/12/2023) 51 A definir
082023.2LILIAM DOS REIS SOUZA SANTOS (60h)
6T2345 (25/08/2023 - 27/12/2023) 22 A definir
092023.2MARIA ELAENE RODRIGUES ALVES (60h)
6T2345 (25/08/2023 - 27/12/2023) 22 A definir
102023.2MARLENE TEIXEIRA RODRIGUES (60h)
6T2345 (25/08/2023 - 27/12/2023) 21 A definir
112023.2MICHELLY FERREIRA MONTEIRO ELIAS (60h)
6T2345 (25/08/2023 - 27/12/2023) 20 A definir
122023.2NEWTON NARCISO GOMES JUNIOR (60h)
6T2345 (25/08/2023 - 27/12/2023) 51 A definir
132023.2REGINALDO GHIRALDELLI (60h)
6T2345 (25/08/2023 - 27/12/2023) 31 A definir
142023.2SILVIA CRISTINA YANNOULAS (60h)
6T2345 (25/08/2023 - 27/12/2023) 21 A definir
152023.2THAIS KRISTOSCH IMPERATORI (60h)
6T2345 (25/08/2023 - 27/12/2023) 21 A definir
162023.2MARILEIA GOIN (60h)
6T2345 (25/08/2023 - 27/12/2023) 11 A definir
172023.2JANAINA LOPES DO NASCIMENTO DUARTE (60h)
6T3456 (25/08/2023 - 27/12/2023) 11 A definir
PPGPS2991 - PRÁTICA DOCENTE EM POLÍTICA SOCIAL
012023.2NEWTON NARCISO GOMES JUNIOR (60h)
35M34 (25/08/2023 - 27/12/2023) 11 A definir
022023.2CAMILA POTYARA PEREIRA (60h)
6N1234 (25/08/2023 - 27/12/2023) 22 A definir
042023.2REGINALDO GHIRALDELLI (60h)
2N1234 (25/08/2023 - 27/12/2023) 11 A definir
PPGPS3830 - RELAÇÕES DE SEXO/GÊNREO, RAÇA/ETNIA E SEXUALIDADES
012023.2MARIA ELAENE RODRIGUES ALVES (60h)
3N1234 (25/08/2023 - 23/12/2023) 3028 A definir
43 turmas encontrada(s)

Campos de preenchimento obrigatório.


SIGAA | Secretaria de Tecnologia da Informação - STI - (61) 3107-0102 | Copyright © 2006-2023 - UFRN - app39_Prod.sigaa33 v4.9.10.41

\ No newline at end of file diff --git a/api/utils/sessions.py b/api/utils/sessions.py index 3653fff7..944d47a5 100644 --- a/api/utils/sessions.py +++ b/api/utils/sessions.py @@ -34,7 +34,7 @@ def get_response(session: Session) -> Response: """Obtem o cookie da sessão de requisição necessário para acessar a pagina de turmas e retorna um cookie jar.""" -def get_session_cookie(session: Session) -> cookies.RequestsCookieJar: +def get_session_cookie(session: Session) -> cookies.RequestsCookieJar: # pragma: no cover response = get_response(session) # Get the response from the request session cookie = response.cookies.get_dict() # Get the cookie from the response cookie_jar = cookies.RequestsCookieJar() # Create a cookie jar diff --git a/api/utils/tests/test_database_handler.py b/api/utils/tests/test_database_handler.py index 4b5b61b8..ab59f062 100644 --- a/api/utils/tests/test_database_handler.py +++ b/api/utils/tests/test_database_handler.py @@ -105,26 +105,6 @@ def test_delete_all_departments_using_year_and_period(self): self.assertFalse(len(Department.objects.all())) - def test_filter_disciplines_by_name(self): - department = dbh.get_or_create_department( - code = 'CFH', - year = '2023', - period = '2' - ) - - discipline = dbh.get_or_create_discipline( - name = 'Aprendizado de organização de faltas', - code = 'CFH1234', - department = department - ) - - disciplines = dbh.filter_disciplines_by_name( - name = 'Aprendizado de organização de faltas' - ) - - self.assertTrue(len(disciplines)) - self.assertTrue(discipline in disciplines) - def test_filter_disciplines_by_code(self): department = dbh.get_or_create_department( code = 'FGA', @@ -165,3 +145,37 @@ def test_filter_disciplines_by_year_and_period(self): self.assertTrue(len(disciplines)) self.assertTrue(discipline in disciplines) + + def test_get_best_similarities_by_name(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 + ) + + discipline_2 = dbh.get_or_create_discipline( + name = 'Cálculo 2', + code = 'MAT0027', + department = department + ) + + discipline_3 = dbh.get_or_create_discipline( + name = 'Cálculo 3', + code = 'MAT0028', + department = department + ) + + disciplines = dbh.get_best_similarities_by_name( + name = 'CALCUL' + ) + + 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 diff --git a/api/utils/web_scraping.py b/api/utils/web_scraping.py index 7c6d7db5..3e5cb255 100644 --- a/api/utils/web_scraping.py +++ b/api/utils/web_scraping.py @@ -1,8 +1,8 @@ from .sessions import URL, HEADERS, create_request_session, get_session_cookie, get_response from bs4 import BeautifulSoup from collections import defaultdict -from typing import List, Optional -from re import findall +from typing import List, Optional, Iterator +from re import findall, finditer import requests.utils import requests @@ -48,11 +48,13 @@ def get_list_of_departments(response=get_response(create_request_session())) -> def get_department_disciplines(department_id: str, current_year: str, current_period: str, url=URL, session=None, cookie=None) -> defaultdict[str, List[dict]]: """Obtem as disciplinas de um departamento""" - discipline_scraper = DisciplineWebScraper(department_id, current_year, current_period, url, session, cookie) + discipline_scraper = DisciplineWebScraper( + department_id, current_year, current_period, url, session, cookie) disciplines = discipline_scraper.get_disciplines() return disciplines + class DisciplineWebScraper: # Classe que faz o web scraping das disciplinas def __init__(self, department: str, year: str, period: str, url=URL, session=None, cookie=None): @@ -72,12 +74,12 @@ def __init__(self, department: str, year: str, period: str, url=URL, session=Non "javax.faces.ViewState": "j_id1" } - if session is None: + if session is None: # pragma: no cover self.session = create_request_session() # Create a request session else: self.session = session - if cookie is None: + if cookie is None: # pragma: no cover self.cookie = get_session_cookie(self.session) else: self.cookie = cookie @@ -92,10 +94,10 @@ def get_response_from_disciplines_post_request(self) -> requests.Response: ) return response - + def get_teachers(self, data: list) -> list: teachers = [] - + for teacher in data: teacher = teacher.replace("\n", "").replace( "\r", "").replace("\t", "") @@ -106,37 +108,93 @@ def get_teachers(self, data: list) -> list: teachers.append(content[0].strip()) - if len(teachers) == 0: + if len(teachers) == 0: # pragma: no cover teachers.append("A definir") - + return teachers - def get_schedules(self, data: str) -> list: + def get_schedules_and_intervals(self, data: str) -> list[list[str], list[tuple[int, int]]]: regex = "\d+[MTN]\d+" - occurrences = findall(regex, data) - - return occurrences - - def get_special_dates(self, data: str) -> list: + occurrences = finditer(regex, data) + values = [[], []] + + for value in occurrences: + values[0].append(value.group()) + values[1].append((value.start(), value.end())) + + return values + + def check_start(self, *args, **kwargs) -> bool: + start_index = kwargs.get("start_index") + last_included = kwargs.get("last_included") + + end_interval = kwargs.get("interval")[1] + already_included = kwargs["index"] + 1 > last_included + value_start_check = kwargs.get("value").start() > end_interval + + return start_index is None and value_start_check and already_included + + def check_end(self, *args, **kwargs) -> bool: + start_interval = kwargs.get("interval")[0] + value_start_check = kwargs.get("value").start() < start_interval + + return value_start_check + + def get_start_index(self, intervals, last_included, value) -> Optional[int]: + start_index = None + + for index, interval in enumerate(intervals): + if self.check_start(start_index=start_index, last_included=last_included, interval=interval, index=index, value=value): + start_index = index + 1 + + return start_index + + def get_end_index(self, intervals, value) -> int: + for index, interval in enumerate(intervals): + if self.check_end(interval=interval, index=index, value=value): + return index + else: + return len(intervals) + + def get_start_and_end(self, value: Iterator, intervals: list[tuple[int, int]], last_included: int) -> tuple[int, int]: + start_index = self.get_start_index(intervals, last_included, value) + end_index = self.get_end_index(intervals, value) + return start_index, end_index + + def get_values_from_special_dates(self, occurrences: Iterator, intervals: list[tuple[int, int]]) -> list[list[str, int, int]]: + last_included = -1 + values = [] + + for value in occurrences: + date = value.group() + start, end = self.get_start_and_end( + value, intervals, last_included) + last_included = end + values.append([date, start, end]) + + return values + + def get_special_dates(self, data: str, intervals: list[tuple[int, int]]) -> list[list[str, int, int]]: date_format = "\d{2}\/\d{2}\/\d{4}" - regex = f"\(({date_format}\s\-\s{date_format})\)" - occurrences = findall(regex, data) - - return occurrences - + regex = f"{date_format}\s\-\s{date_format}" + occurrences = finditer(regex, data) + values = self.get_values_from_special_dates(occurrences, intervals) + + return values + def get_week_days(self, data: str) -> list: hours_format = "\d+\:\d+" regex = f"[A-Z]\w?[a-z|ç]+\-?[a-z]*\s{hours_format}\sàs\s{hours_format}" occurrences = findall(regex, data) - + return occurrences - + def make_disciplines(self, rows: str) -> None: if rows is None or not len(rows): return None - + aux_title_and_code = "" - + for discipline in rows: if discipline.find("span", attrs={"class": "tituloDisciplina"}) is not None: title = discipline.find( @@ -162,20 +220,22 @@ def make_disciplines(self, rows: str) -> None: teachers_with_workload = discipline.find( "td", attrs={"class": "nome"}).get_text().strip().strip().split(')') schedule_context = tables_data[3].get_text().strip() - + class_code = tables_data[0].get_text().strip() classroom = tables_data[7].get_text().strip() - schedule = self.get_schedules(schedule_context) - special_dates = self.get_special_dates(schedule_context) + schedules_and_intervals = self.get_schedules_and_intervals( + schedule_context) + special_dates = self.get_special_dates( + schedule_context, schedules_and_intervals[1]) days = self.get_week_days(schedule_context) teachers = self.get_teachers(teachers_with_workload) - + self.disciplines[code].append({ "name": name, "class_code": class_code, "teachers": teachers, "classroom": classroom, - "schedule": " ".join(schedule), + "schedule": " ".join(schedules_and_intervals[0]), "special_dates": special_dates, "days": days }) diff --git a/docs/api.md b/docs/api.md index 2372bf61..04383c80 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,7 +1,7 @@ --- hide: - navigation ---- +--- # Definição de rotas @@ -20,9 +20,10 @@ O request deve conter um token de autenticação do Google. ```js linenums="1" body = { - "access_token": "token" -} + access_token: "token", +}; ``` + - `"access_token"`: O token de acesso do Google OAuth2. **Response:** @@ -32,41 +33,43 @@ A resposta conterá informações de autenticação bem-sucedida, incluindo um t **Success (200 OK)** ```js linenums="1" -headers = { - "Content-Type": "application/json", - "Set-Cookie": "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=" -}, -body = { - "access": "token", - "first_name": "name", - "last_name": "name", - "email": "email" -} +(headers = { + "Content-Type": "application/json", + "Set-Cookie": + "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=", +}), + (body = { + access: "token", + first_name: "name", + last_name: "name", + email: "email", + picture_url: "picture_url", + }); ``` **Error (400 BAD REQUEST)** ```js linenums="1" body = { - "errors": "descriptive error message" -} + errors: "descriptive error message", +}; ``` -## Login +## Login **Método HTTP:** `POST`
**Rota:** `/users/login` -Esta rota atualiza o *refresh-token* do usuário e retorna um novo *access-token*. +Esta rota atualiza o _refresh-token_ do usuário e retorna um novo _access-token_. **Request:** -O request deve conter um *refresh-token*. +O request deve conter um _refresh-token_. ```js linenums="1" headers = { - "Cookie": "refresh=" -} + Cookie: "refresh=", +}; ``` **Response:** @@ -74,15 +77,17 @@ headers = { **Success (200 OK)** ```js linenums="1" -headers = { - "Set-Cookie": "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=" -}, -body = { - "access": "token", - "first_name": "name", - "last_name": "name", - "email": "email" -} +(headers = { + "Set-Cookie": + "refresh=; Secure; HttpOnly; SameSite=Lax; Expires=", +}), + (body = { + access: "token", + first_name: "name", + last_name: "name", + email: "email", + picture_url: "picture_url", + }); ``` ## Logout @@ -96,43 +101,26 @@ Esta rota permite ao usuário fazer logout de sua conta no site. ```js linenums="1" headers = { - "Cookie": "refresh=", - "Authorization": "Bearer " -} + Cookie: "refresh=", +}; ``` **Response:** **Suceess (200 OK)** -```js linenums="1" -body = { - "message": "Successfully logged out." -} -``` - **Error (400 BAD REQUEST)** -```js linenums="1" -body = { - "errors": "descriptive error message" -} -``` - **Error (401 UNAUTHORIZED)** -```js linenums="1" -body = { - "errors": "access token not provided or invalid" -} -``` +**OBSERVAÇÃO:** As respostas não contém conteúdo. ## Busca por Matéria **Método HTTP:** `GET`
-**Rota:** `/courses/?search=` +**Rota:** `/courses/?search=&year=&period=` -Esta rota permite ao usuário pesquisar e encontrar informações detalhadas sobre matérias potenciais que podem se relacionar com o termo de busca *(máximo 5)*. A busca deve ser pelo nome da matéria. +Esta rota permite ao usuário pesquisar e encontrar informações detalhadas sobre matérias potenciais que podem se relacionar com o termo de busca _(máximo 8)_. A busca deve ser pelo nome ou código da disciplina. **Response:** @@ -141,70 +129,76 @@ A resposta incluirá informações detalhadas sobre as matérias potenciais que **Success (200 OK):** ```js linenums="1" -body = { - "courses": [ - { - "name": "Tópicos Especiais em Programação", - "code": "FGA0053", - "options": [ - { - "teachers": ["Edson Alves da Costa Junior"], - "schedule": "2M34", - "days": ["Segunda e Terça - 10:00 a 12:00"], - "classroom": "FGA - S1", - "workload": 60, - "class": 1 - } - ] - } - ] -} +body = [ + { + id: 20696, + department: { + id: 962, + code: "673", + year: "2023", + period: "2", + }, + classes: [ + { + id: 91560, + teachers: ["EDSON ALVES DA COSTA JUNIOR"], + classroom: "FGA - I6", + schedule: "35T6 35N1", + days: ["Terça-feira 18:00 às 19:50", "Quinta-feira 18:00 às 19:50"], + _class: "01", + special_dates: [], + discipline: 20696, + }, + { + id: 91561, + teachers: ["EDSON ALVES DA COSTA JUNIOR"], + classroom: "FGA - MOCAP", + schedule: "6T2345", + days: ["Sexta-feira 14:00 às 17:50"], + _class: "02", + special_dates: [], + discipline: 20696, + }, + ], + name: "TÓPICOS ESPECIAIS EM PROGRAMAÇÃO", + code: "FGA0053", + }, +]; ``` **Error (400 BAD REQUEST):** ```js linenums="1" body = { - "errors": "descriptive error message" -} + errors: "no valid argument found for 'search', 'year' or 'period'", +}; +``` + +ou + +```js linenums="1" +body = { + errors: "search must have at least 4 characters", +}; ``` ## Montagem de Grade **Método HTTP:** `POST`
-**Rota:** `/schedules/automatic/create` +**Rota:** `/courses/schedule` -Esta rota permite ao usuário criar uma grade de matérias, selecionando manualmente as matérias e horários ou deixando o sistema escolher automaticamente por preferencias determinadas pelo usuário. Ao utilizar esta rota, o usuário receberá três opções de grade. +Esta rota permite ao usuário criar uma grade de matérias deixando o sistema escolher automaticamente por preferencias determinadas pelo usuário. Ao utilizar esta rota, o usuário receberá três opções de grade. **Request:** ```js linenums="1" body = { - "preference": "M|T|N", - "courses": [ - { - "name": "Tópicos Especiais em Engenharia de Software", - "code": "ESW101", - "options": [ - { - "teachers": ["Edson Alves da Costa Junior"], - "schedule": "2M34" - }, - { - "teachers": ["BRUNO CESAR RIBAS"], - "schedule": "2T23" - } - ] - }, - { - "name": "Fisica 1", - "code": "FIS101" - } - ] -} + preference: "M|T|N", + classes: [ class_id: int, ... ] +}; ``` -- Caso as opções de matéria possuam horários ou professores ou ambos, o sistema dará preferência para escolha dessas opções. Caso contrário, o sistema escolherá automaticamente. +- O campo _classes_ recebe um array de inteiros contendo os ids das turmas selecionadas. **Response:** @@ -238,38 +232,37 @@ body = { ```js linenums="1" body = { - "errors": "descriptive error message" -} + errors: "descriptive error message", +}; ``` ## Salvar Grade **Método HTTP:** `POST`
-**Rota:** `/schedules/save` +**Rota:** `/courses/schedules/save` Esta rota permite ao usuário salvar uma grade de matérias, caso deseje utilizá-la novamente no futuro. **Request:** ```js linenums="1" -header = { - "Authorization": "Bearer " -}, -body = { - "id": 123, - "courses": [ - { - "FGA0053": { - "teachers": ["Edson Alves da Costa Junior"], - "schedule": "2M34" - }, - "FGA0030": { - "teachers": ["BRUNO CESAR RIBAS"], - "schedule": "2T23" - } - } - ] -} +(header = { + Authorization: "Bearer ", +}), + (body = [ + [ + { + FGA0053: { + teachers: ["Edson Alves da Costa Junior"], + schedule: "2M34", + }, + FGA0030: { + teachers: ["BRUNO CESAR RIBAS"], + schedule: "2T23", + }, + }, + ], ... + ]); ``` **Response:** @@ -280,30 +273,30 @@ A resposta confirmará a criação bem-sucedida da grade de matérias. ```js linenums="1" body = { - "message": "Grade salva com sucesso." -} + message: "Grade salva com sucesso.", +}; ``` **Error (400 BAD REQUEST):** ```js linenums="1" body = { - "errors": "descriptive error message" -} + errors: "descriptive error message", +}; ``` **Error (401 UNAUTHORIZED):** ```js linenums="1" body = { - "errors": "access token not provided or invalid" -} + errors: "access token not provided or invalid", +}; ``` ## Consulta da Grade de Matérias **Método HTTP:** `GET`
-**Rota:** `/schedules` +**Rota:** `/courses/schedules` Esta rota permite ao usuário visualizar as grades de matérias criadas e salvas por ele. @@ -311,8 +304,8 @@ Esta rota permite ao usuário visualizar as grades de matérias criadas e salvas ```js linenums="1" headers = { - "Authorization": "Bearer " -} + Authorization: "Bearer ", +}; ``` **Response:** @@ -323,58 +316,58 @@ A resposta incluirá uma lista de grades de matérias salvas pelo usuário. ```js linenums="1" body = { - "schedules": [ + schedules: [ + { + id: 123, + courses: [ { - "id": 123, - "courses": [ - { - "FGA0053": { - "teachers": ["Edson Alves da Costa Junior"], - "schedule": "2M34" - }, - "FGA0030": { - "teachers": ["BRUNO CESAR RIBAS"], - "schedule": "2T23" - } - } - ] - } - ] -} + FGA0053: { + teachers: ["Edson Alves da Costa Junior"], + schedule: "2M34", + }, + FGA0030: { + teachers: ["BRUNO CESAR RIBAS"], + schedule: "2T23", + }, + }, + ], + }, + ], +}; ``` **Error (400 BAD REQUEST):** ```js linenums="1" body = { - "errors": "descriptive error message" -} + errors: "descriptive error message", +}; ``` **Error (401 UNAUTHORIZED):** ```js linenums="1" body = { - "errors": "access token not provided or invalid" -} + errors: "access token not provided or invalid", +}; ``` ## Deleção de Grade Salva **Método HTTP:** `DELETE`
-**Rota:** `/schedules/delete` +**Rota:** `/courses/schedules` Esta rota permite ao usuário excluir uma grade de matérias salva anteriormente. **Request:** ```js linenums="1" -headers = { - "Authorization": "Bearer " -}, -body = { - "id": 123 -} +(headers = { + Authorization: "Bearer ", +}), + (body = { + id: 123, + }); ``` **Response:** @@ -385,22 +378,22 @@ A resposta confirmará a exclusão bem-sucedida da grade de matérias. ```js linenums="1" body = { - "message": "Successfully deleted." -} + message: "Successfully deleted.", +}; ``` **Error (400 BAD REQUEST):** ```js linenums="1" body = { - "errors": "descriptive error message" -} + errors: "descriptive error message", +}; ``` **Error (401 UNAUTHORIZED):** ```js linenums="1" body = { - "errors": "access token not provided or invalid" -} -``` \ No newline at end of file + errors: "access token not provided or invalid", +}; +``` diff --git a/requirements.txt b/requirements.txt index f48d376e..788fd78a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ django-allauth==0.57.0 django-cors-headers==4.3.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.0 +drf-yasg==1.21.7 ghp-import==2.1.0 google-api-core==2.12.0 google-api-python-client==2.102.0 @@ -24,6 +25,7 @@ google-auth-httplib2==0.1.1 googleapis-common-protos==1.60.0 httplib2==0.22.0 idna==3.4 +inflection==0.5.1 Jinja2==3.1.2 Markdown==3.4.4 MarkupSafe==2.1.3 @@ -60,6 +62,7 @@ six==1.16.0 soupsieve==2.5 sqlparse==0.4.4 typing_extensions==4.8.0 +Unidecode==1.3.7 uritemplate==4.1.1 urllib3==2.0.5 watchdog==3.0.0