diff --git a/.env.example b/.env.example index ce79405..0b57ce2 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ POSTGRES_PASSWORD=autocensup POSTGRES_HOST=localhost POSTGRES_PORT=5432 -GENNERA_API_BASE_URL=http://gennera.solucaoti.net.br +GENNERA_LOCAL_BASE_URL=http://gennera.solucaoti.net.br API_TOKEN= GENNERA_LOCAL_AUTH_TOKEN= CENSUP_IES_CODIGO= diff --git a/ESPECIFICACOES.md b/ESPECIFICACOES.md index 07c4fbe..dd899d1 100644 --- a/ESPECIFICACOES.md +++ b/ESPECIFICACOES.md @@ -33,7 +33,7 @@ Variaveis principais: - `DJANGO_ALLOWED_HOSTS`: hosts permitidos. - `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`: conexao PostgreSQL. - `DATABASE_URL`: alternativa para configurar o banco por URL. -- `GENNERA_API_BASE_URL`: URL base da API Gennera. +- `GENNERA_LOCAL_BASE_URL`: URL base da API Gennera. - `API_TOKEN`: token Bearer usado nas chamadas a API Gennera. Comandos basicos: diff --git a/config/settings.py b/config/settings.py index fd68c8a..d3d6e0c 100644 --- a/config/settings.py +++ b/config/settings.py @@ -137,7 +137,7 @@ LOGIN_URL = "login" LOGIN_REDIRECT_URL = "home" LOGOUT_REDIRECT_URL = "login" -GENNERA_API_BASE_URL = os.getenv("GENNERA_API_BASE_URL", "http://gennera.solucaoti.net.br") +GENNERA_LOCAL_BASE_URL = os.getenv("GENNERA_LOCAL_BASE_URL", "http://gennera.solucaoti.net.br") GENNERA_API_TOKEN = os.getenv("API_TOKEN", "") GENNERA_LOCAL_AUTH_TOKEN = os.getenv("GENNERA_LOCAL_AUTH_TOKEN", "") diff --git a/core/importers.py b/core/importers.py index f90f299..3e8727e 100644 --- a/core/importers.py +++ b/core/importers.py @@ -104,6 +104,7 @@ class ImportVinculosApiResult: pessoas_without_vinculo: int = 0 created: int = 0 updated: int = 0 + situacao_nao_informada_corrigidos: int = 0 errors: list[ImportVinculoApiError] | None = None def __post_init__(self) -> None: @@ -488,6 +489,8 @@ def import_carga_horaria_from_gennera( atualizados = [] for vinculo in Vinculo.objects.filter(matricula=matricula).select_related("curso"): + if vinculo.atualizado_manualmente: + continue carga = carga_horaria_ate_ano(by_ano, vinculo.ano_censo_id) if carga is None or carga == vinculo.carga_horaria_integralizada: continue @@ -548,6 +551,53 @@ def import_pessoas_ingressantes_from_gennera( return result +def corrigir_situacao_nao_informada( + ano: int, + base_url: str, + token: str, + max_workers: int = 4, +) -> int: + vinculos = list( + Vinculo.objects.filter( + ano_censo=ano, + situacao=Vinculo.Situacao.NAO_INFORMADA, + atualizado_manualmente=False, + ) + .exclude(matricula="") + .select_related("curso") + ) + if not vinculos: + return 0 + + matriculas = {vinculo.matricula for vinculo in vinculos} + carga_por_matricula: dict[str, dict[int, int]] = {} + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_matricula = { + executor.submit(fetch_gennera_carga_horaria, base_url, token, matricula): matricula + for matricula in matriculas + } + for future in as_completed(future_to_matricula): + matricula = future_to_matricula[future] + try: + rows = future.result() + except Exception: + continue + carga_por_matricula[matricula] = carga_horaria_por_ano(rows) + + corrigidos = 0 + for vinculo in vinculos: + by_ano = carga_por_matricula.get(vinculo.matricula, {}) + carga = carga_horaria_ate_ano(by_ano, vinculo.ano_censo_id) + if carga is not None: + vinculo.carga_horaria_integralizada = carga + vinculo.situacao = Vinculo.Situacao.DESVINCULADO + vinculo.save(update_fields=["situacao", "carga_horaria_integralizada"]) + corrigidos += 1 + + return corrigidos + + def import_vinculos_from_gennera( ano: int, base_url: str, @@ -618,6 +668,9 @@ def import_vinculos_from_gennera( index, total_pessoas, pessoa.cpf, [vinculo for vinculo, _ in imported] ) + result.situacao_nao_informada_corrigidos = corrigir_situacao_nao_informada( + ano=ano, base_url=base_url, token=token, max_workers=max_workers + ) return result @@ -701,6 +754,9 @@ def import_gennera_vinculo_group( for item in rows ) existing = Vinculo.objects.filter(pessoa=pessoa, curso=curso, ano_censo=ano).first() + if existing is not None and existing.atualizado_manualmente: + return existing, False + situacao = gennera_vinculo_situacao( rows=rows, curso=curso, @@ -740,9 +796,11 @@ def import_gennera_vinculo_group( def mark_person_vinculos_as_desvinculado(pessoa: Pessoa, ano: int) -> int: - return Vinculo.objects.filter(pessoa=pessoa, ano_censo=ano).exclude( - situacao=Vinculo.Situacao.FORMADO - ).update(situacao=Vinculo.Situacao.DESVINCULADO) + return Vinculo.objects.filter( + pessoa=pessoa, ano_censo=ano, atualizado_manualmente=False + ).exclude(situacao=Vinculo.Situacao.FORMADO).update( + situacao=Vinculo.Situacao.DESVINCULADO + ) def import_alunos(lines: Iterable[str], ano_censo: int) -> ImportAlunosResult: @@ -839,7 +897,7 @@ def import_pessoa_record(fields: list[str]) -> tuple[Pessoa, bool]: cpf=cpf, defaults=defaults, ) - if not created: + if not created and not pessoa.atualizado_manualmente: for field, value in defaults.items(): if field in {"nome", "data_nascimento"}: continue @@ -930,6 +988,12 @@ def import_vinculo_record( "justificativa": parse_optional_int(fields[72]), } + existing = Vinculo.objects.filter( + pessoa=pessoa, curso=curso, ano_censo_id=ano_censo + ).first() + if existing is not None and existing.atualizado_manualmente: + return existing, False + with transaction.atomic(): vinculo, created = Vinculo.objects.update_or_create( pessoa=pessoa, @@ -997,12 +1061,13 @@ def export_vinculo_record(vinculo: Vinculo) -> str: if vinculo.situacao == Vinculo.Situacao.NAO_INFORMADA else str(vinculo.situacao) ) + polo = vinculo.polo or vinculo.curso.polo fields = [ "42", vinculo.matricula, _fmt_optional_int(vinculo.periodo_referencia), _fmt_fk_pk(vinculo.curso_id), - _fmt_fk_pk(vinculo.polo.codigo if vinculo.polo_id else None), + _fmt_fk_pk(polo.codigo if polo else None), _fmt_optional_int(vinculo.turno), situacao, _fmt_fk_pk(vinculo.curso_origem_id), diff --git a/core/management/commands/import_carga_horaria_censup.py b/core/management/commands/import_carga_horaria_censup.py index dfcc4af..a4bbcc9 100644 --- a/core/management/commands/import_carga_horaria_censup.py +++ b/core/management/commands/import_carga_horaria_censup.py @@ -39,7 +39,7 @@ class Command(BaseCommand): ) result = import_carga_horaria_from_gennera( - base_url=settings.GENNERA_API_BASE_URL, + base_url=settings.GENNERA_LOCAL_BASE_URL, token=token, page_size=page_size, max_workers=threads, diff --git a/core/management/commands/import_pessoas_ingressantes.py b/core/management/commands/import_pessoas_ingressantes.py index 2df22d4..e8ed2b4 100644 --- a/core/management/commands/import_pessoas_ingressantes.py +++ b/core/management/commands/import_pessoas_ingressantes.py @@ -26,7 +26,7 @@ class Command(BaseCommand): result = import_pessoas_ingressantes_from_gennera( primeiro_ano=primeiro_ano, - base_url=settings.GENNERA_API_BASE_URL, + base_url=settings.GENNERA_LOCAL_BASE_URL, token=token, page_size=page_size, progress_callback=report_progress, diff --git a/core/management/commands/import_vinculos_censup.py b/core/management/commands/import_vinculos_censup.py index a332897..5dc0411 100644 --- a/core/management/commands/import_vinculos_censup.py +++ b/core/management/commands/import_vinculos_censup.py @@ -41,7 +41,7 @@ class Command(BaseCommand): result = import_vinculos_from_gennera( ano=ano, - base_url=settings.GENNERA_API_BASE_URL, + base_url=settings.GENNERA_LOCAL_BASE_URL, token=token, fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size), progress_callback=report_progress, @@ -55,6 +55,8 @@ class Command(BaseCommand): f"{result.pessoas_without_vinculo} sem vinculo no ano, " f"{result.created} vinculos criados, " f"{result.updated} vinculos atualizados, " + f"{result.situacao_nao_informada_corrigidos} corrigidos de " + "situacao nao informada para desvinculado, " f"{len(result.errors)} com erro." ) ) diff --git a/core/migrations/0013_pessoa_atualizado_manualmente_and_more.py b/core/migrations/0013_pessoa_atualizado_manualmente_and_more.py new file mode 100644 index 0000000..c725b4a --- /dev/null +++ b/core/migrations/0013_pessoa_atualizado_manualmente_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-07-05 15:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_alter_pessoa_data_nascimento'), + ] + + operations = [ + migrations.AddField( + model_name='pessoa', + name='atualizado_manualmente', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='vinculo', + name='atualizado_manualmente', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/models.py b/core/models.py index 1bf107b..1eb8531 100644 --- a/core/models.py +++ b/core/models.py @@ -201,6 +201,7 @@ class Pessoa(models.Model): blank=True, related_name="pessoas", ) + atualizado_manualmente = models.BooleanField(default=False) class Meta: verbose_name = "pessoa" @@ -393,6 +394,7 @@ class Vinculo(models.Model): reserva_olimpiadas_conhecimento = models.BooleanField(null=True, blank=True) reserva_outros = models.BooleanField(null=True, blank=True) justificativa = models.PositiveSmallIntegerField(null=True, blank=True) + atualizado_manualmente = models.BooleanField(default=False) class Meta: verbose_name = "vinculo" diff --git a/core/tasks.py b/core/tasks.py index 46d5184..db5c438 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -30,7 +30,7 @@ def import_pessoas_ingressantes_task( result = import_pessoas_ingressantes_from_gennera( primeiro_ano=primeiro_ano, - base_url=settings.GENNERA_API_BASE_URL, + base_url=settings.GENNERA_LOCAL_BASE_URL, token=settings.GENNERA_LOCAL_AUTH_TOKEN, page_size=page_size, progress_callback=report_progress, @@ -60,7 +60,7 @@ def import_vinculos_censup_task( result = import_vinculos_from_gennera( ano=ano, - base_url=settings.GENNERA_API_BASE_URL, + base_url=settings.GENNERA_LOCAL_BASE_URL, token=settings.GENNERA_LOCAL_AUTH_TOKEN, fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size), progress_callback=report_progress, @@ -71,6 +71,7 @@ def import_vinculos_censup_task( "pessoas_without_vinculo": result.pessoas_without_vinculo, "created": result.created, "updated": result.updated, + "situacao_nao_informada_corrigidos": result.situacao_nao_informada_corrigidos, "errors": len(result.errors), } @@ -90,7 +91,7 @@ def import_carga_horaria_censup_task( ) result = import_carga_horaria_from_gennera( - base_url=settings.GENNERA_API_BASE_URL, + base_url=settings.GENNERA_LOCAL_BASE_URL, token=settings.GENNERA_LOCAL_AUTH_TOKEN, page_size=page_size, max_workers=threads, diff --git a/core/tests.py b/core/tests.py index 6d6a690..0b882c6 100644 --- a/core/tests.py +++ b/core/tests.py @@ -519,6 +519,120 @@ class CrudViewTests(AuthenticatedViewTestCase): self.assertRedirects(delete_response, reverse("crud_list", kwargs={"slug": "ufs"})) self.assertFalse(UF.objects.filter(codigo=22).exists()) + def test_crud_list_sorts_by_clicked_column(self) -> None: + UF.objects.create(codigo=1, nome="Zeta") + UF.objects.create(codigo=2, nome="Alfa") + + response_asc = self.client.get( + reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "nome", "dir": "asc"} + ) + response_desc = self.client.get( + reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "nome", "dir": "desc"} + ) + + self.assertEqual( + [uf.nome for uf in response_asc.context["page_obj"].object_list], + ["Alfa", "Zeta"], + ) + self.assertEqual( + [uf.nome for uf in response_desc.context["page_obj"].object_list], + ["Zeta", "Alfa"], + ) + + def test_crud_list_ignores_unknown_sort_field(self) -> None: + UF.objects.create(codigo=1, nome="Piaui") + + response = self.client.get( + reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "bogus"} + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["sort"], "") + + def test_crud_list_pagination_links_preserve_sort(self) -> None: + for index in range(25): + UF.objects.create(codigo=index + 1, nome=f"UF {index:02d}") + + response = self.client.get( + reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "nome", "dir": "desc"} + ) + + self.assertContains(response, "sort=nome&dir=desc&page=2") + + def test_crud_list_sorts_vinculos_by_related_pessoa_name(self) -> None: + pais = Pais.objects.create(codigo="BRA", nome="Brasil") + curso = Curso.objects.create( + codigo_mec=123, descricao="Pedagogia", carga_horaria_total=3200 + ) + for nome, cpf in (("Zeta Aluno", "11111111111"), ("Alfa Aluno", "22222222222")): + pessoa = Pessoa.objects.create( + nome=nome, + cpf=cpf, + data_nascimento=date(2000, 1, 1), + cor_raca=Pessoa.CorRaca.PARDA, + nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA, + pais_origem=pais, + aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO, + tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PUBLICA, + ) + Vinculo.objects.create( + pessoa=pessoa, + ano_censo_id=timezone.localdate().year - 1, + curso=curso, + situacao=Vinculo.Situacao.CURSANDO, + semestre_ingresso="012025", + carga_horaria_total_aluno=3200, + carga_horaria_integralizada=0, + **REQUIRED_VINCULO_BOOL_DEFAULTS, + ) + + response = self.client.get( + reverse("crud_list", kwargs={"slug": "vinculos"}), {"sort": "pessoa", "dir": "asc"} + ) + + self.assertEqual( + [v.pessoa.nome for v in response.context["page_obj"].object_list], + ["Alfa Aluno", "Zeta Aluno"], + ) + + def test_crud_update_marks_pessoa_as_atualizado_manualmente(self) -> None: + pais = Pais.objects.create(codigo="BRA", nome="Brasil") + pessoa = Pessoa.objects.create( + nome="Maria Silva", + cpf="12345678901", + cor_raca=Pessoa.CorRaca.PARDA, + nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA, + pais_origem=pais, + aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO, + tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PUBLICA, + ) + self.assertFalse(pessoa.atualizado_manualmente) + + form_url = reverse("crud_update", kwargs={"slug": "pessoas", "pk": pessoa.pk}) + form_page = self.client.get(form_url) + self.assertNotContains(form_page, "atualizado_manualmente") + + response = self.client.post( + form_url, + { + "nome": "Maria Silva Editada", + "cpf": "12345678901", + "cor_raca": Pessoa.CorRaca.PARDA, + "nacionalidade": Pessoa.Nacionalidade.BRASILEIRA_NATA, + "pais_origem": pais.pk, + "aluno_com_deficiencia": Pessoa.SimNaoNaoDispoe.NAO, + "tipo_escola_ensino_medio": Pessoa.TipoEscolaEnsinoMedio.PUBLICA, + }, + ) + + self.assertRedirects( + response, + reverse("crud_detail", kwargs={"slug": "pessoas", "pk": pessoa.pk}), + ) + pessoa.refresh_from_db() + self.assertEqual(pessoa.nome, "Maria Silva Editada") + self.assertTrue(pessoa.atualizado_manualmente) + def test_pending_required_fields_view_lists_courses_without_carga(self) -> None: Curso.objects.create( codigo_mec=123, @@ -837,6 +951,77 @@ class ImportAlunosTests(AuthenticatedViewTestCase): self.assertEqual(pessoa.data_nascimento, date(1999, 1, 1)) self.assertEqual(pessoa.cor_raca, Pessoa.CorRaca.PARDA) + def test_import_alunos_does_not_overwrite_manually_edited_pessoa(self) -> None: + Pessoa.objects.create( + nome="Nome Original", + cpf="12345678901", + data_nascimento=date(1999, 1, 1), + cor_raca=Pessoa.CorRaca.BRANCA, + nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA, + pais_origem=self.pais, + aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO, + tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PRIVADA, + atualizado_manualmente=True, + ) + + result = import_alunos( + [self.pessoa_line(nome="Nome Novo", nascimento="02022000")], + ano_censo=2025, + ) + + pessoa = Pessoa.objects.get(cpf="12345678901") + + self.assertEqual(result.pessoas_updated, 1) + self.assertEqual(pessoa.cor_raca, Pessoa.CorRaca.BRANCA) + self.assertEqual(pessoa.tipo_escola_ensino_medio, Pessoa.TipoEscolaEnsinoMedio.PRIVADA) + + def test_import_alunos_does_not_overwrite_manually_edited_vinculo(self) -> None: + pessoa = Pessoa.objects.create( + nome="Maria Silva", + cpf="12345678901", + data_nascimento=date(2000, 1, 1), + cor_raca=Pessoa.CorRaca.PARDA, + nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA, + pais_origem=self.pais, + aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO, + tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PUBLICA, + ) + Vinculo.objects.create( + pessoa=pessoa, + ano_censo_id=2025, + curso=self.curso, + situacao=Vinculo.Situacao.FORMADO, + semestre_ingresso="012020", + carga_horaria_total_aluno=3200, + carga_horaria_integralizada=3200, + atualizado_manualmente=True, + ingresso_vestibular=True, + ingresso_enem=False, + ingresso_avaliacao_seriada=False, + ingresso_selecao_simplificada=False, + ingresso_egresso_bi_li=False, + ingresso_pec_g=False, + ingresso_transferencia_ex_officio=False, + ingresso_decisao_judicial=False, + ingresso_vagas_remanescentes=False, + ingresso_programas_especiais=False, + apoio_social=False, + atividade_extracurricular=False, + ingressou_acao_afirmativa=False, + ) + + import_alunos( + [ + self.pessoa_line(nome="Maria Silva", nascimento="01012000"), + self.vinculo_line(codigo_curso=123, carga_horaria=1), + ], + ano_censo=2025, + ) + + vinculo = Vinculo.objects.get(pessoa=pessoa, curso=self.curso, ano_censo=2025) + self.assertEqual(vinculo.situacao, Vinculo.Situacao.FORMADO) + self.assertEqual(vinculo.carga_horaria_integralizada, 3200) + def test_import_alunos_view_accepts_upload(self) -> None: uploaded = SimpleUploadedFile( "aluno_exportacao.txt", @@ -1018,6 +1203,23 @@ class ExportAlunoRecordTests(TestCase): self.assertEqual(line.split("|")[4], str(polo.codigo)) + def test_export_vinculo_record_falls_back_to_curso_polo_when_vinculo_polo_is_none( + self, + ) -> None: + # Vinculos importados via API Gennera nunca preenchem Vinculo.polo diretamente + # (ver import_gennera_vinculo_group) -- o polo EaD fica so em Curso.polo. + Polo.objects.create(codigo=999, descricao="Polo Descartavel") + polo_do_curso = Polo.objects.create(codigo=777, descricao="Polo do Curso EaD") + self.curso.ead = True + self.curso.polo = polo_do_curso + self.curso.save(update_fields=["ead", "polo"]) + + pessoa = self.make_pessoa() + vinculo = self.make_vinculo(pessoa, polo=None) + line = export_vinculo_record(vinculo) + + self.assertEqual(line.split("|")[4], str(polo_do_curso.codigo)) + def test_export_vinculo_record_writes_minus_one_for_nao_informada_situacao(self) -> None: pessoa = self.make_pessoa() vinculo = self.make_vinculo(pessoa, situacao=Vinculo.Situacao.NAO_INFORMADA) @@ -1786,6 +1988,212 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase): self.assertEqual(result.pessoas_without_vinculo, 1) self.assertEqual(vinculo.situacao, Vinculo.Situacao.DESVINCULADO) + def test_import_vinculos_from_gennera_does_not_overwrite_manually_edited_vinculo( + self, + ) -> None: + pessoa = self.create_gennera_pessoa("Aluno Editado Manualmente", "02542618330") + curso = Curso.objects.create( + codigo_mec=123, + descricao="Pedagogia", + carga_horaria_total=3200, + ) + Vinculo.objects.create( + pessoa=pessoa, + ano_censo_id=2026, + curso=curso, + situacao=Vinculo.Situacao.TRANSFERENCIA_INTERNA, + semestre_ingresso="012026", + atualizado_manualmente=True, + ingresso_vestibular=False, + ingresso_enem=False, + ingresso_avaliacao_seriada=False, + ingresso_selecao_simplificada=False, + ingresso_egresso_bi_li=False, + ingresso_pec_g=False, + ingresso_transferencia_ex_officio=False, + ingresso_decisao_judicial=False, + ingresso_vagas_remanescentes=False, + ingresso_programas_especiais=False, + apoio_social=False, + atividade_extracurricular=False, + carga_horaria_total_aluno=3200, + carga_horaria_integralizada=1600, + ingressou_acao_afirmativa=False, + ) + + import_vinculos_from_gennera( + ano=2026, + base_url="http://api.example.test", + token="token", + fetcher=lambda base_url, token, ano, cpf: [ + { + "cpf": cpf, + "turno": "Noite", + "referenceYear": "2026/2", + "course": "Pedagogia", + "course_code": "123", + "ano": 2026, + "carga_horaria_acumulada": 3200, + } + ], + ) + + vinculo = Vinculo.objects.get(pessoa=pessoa, curso=curso, ano_censo=2026) + self.assertEqual(vinculo.situacao, Vinculo.Situacao.TRANSFERENCIA_INTERNA) + self.assertEqual(vinculo.carga_horaria_integralizada, 1600) + + def test_import_vinculos_from_gennera_desvinculado_skips_manually_edited(self) -> None: + pessoa = self.create_gennera_pessoa("Aluno Sem Remoto Manual", "02542618330") + curso = Curso.objects.create( + codigo_mec=123, + descricao="Pedagogia", + carga_horaria_total=3200, + ) + Vinculo.objects.create( + pessoa=pessoa, + ano_censo_id=2026, + curso=curso, + situacao=Vinculo.Situacao.CURSANDO, + semestre_ingresso="012026", + atualizado_manualmente=True, + ingresso_vestibular=False, + ingresso_enem=False, + ingresso_avaliacao_seriada=False, + ingresso_selecao_simplificada=False, + ingresso_egresso_bi_li=False, + ingresso_pec_g=False, + ingresso_transferencia_ex_officio=False, + ingresso_decisao_judicial=False, + ingresso_vagas_remanescentes=False, + ingresso_programas_especiais=False, + apoio_social=False, + atividade_extracurricular=False, + carga_horaria_total_aluno=3200, + carga_horaria_integralizada=1600, + ingressou_acao_afirmativa=False, + ) + + import_vinculos_from_gennera( + ano=2026, + base_url="http://api.example.test", + token="token", + fetcher=lambda base_url, token, ano, cpf: [], + ) + + vinculo = Vinculo.objects.get(pessoa=pessoa, curso=curso, ano_censo=2026) + self.assertEqual(vinculo.situacao, Vinculo.Situacao.CURSANDO) + + def test_import_vinculos_from_gennera_fixes_situacao_nao_informada(self) -> None: + pessoa = self.create_gennera_pessoa("Aluno Nao Informado", "02542618330") + curso = Curso.objects.create( + codigo_mec=123, + descricao="Pedagogia", + carga_horaria_total=3200, + ) + # Outro curso com dados remotos, so a API nao "zera" o pessoa via + # mark_person_vinculos_as_desvinculado (que so roda quando a API nao + # retorna NENHUM vinculo pra pessoa) -- assim o vinculo NAO_INFORMADA + # abaixo fica intocado pelo loop principal e so a nova etapa final o pega. + Curso.objects.create( + codigo_mec=999, + descricao="Direito", + carga_horaria_total=3600, + ) + vinculo = Vinculo.objects.create( + pessoa=pessoa, + ano_censo_id=2026, + curso=curso, + matricula="MAT-1", + situacao=Vinculo.Situacao.NAO_INFORMADA, + semestre_ingresso="012026", + ingresso_vestibular=False, + ingresso_enem=False, + ingresso_avaliacao_seriada=False, + ingresso_selecao_simplificada=False, + ingresso_egresso_bi_li=False, + ingresso_pec_g=False, + ingresso_transferencia_ex_officio=False, + ingresso_decisao_judicial=False, + ingresso_vagas_remanescentes=False, + ingresso_programas_especiais=False, + apoio_social=False, + atividade_extracurricular=False, + carga_horaria_total_aluno=3200, + carga_horaria_integralizada=0, + ingressou_acao_afirmativa=False, + ) + + with patch("core.importers.fetch_gennera_carga_horaria") as fetch_mock: + fetch_mock.return_value = [{"ano": 2026, "carga_horaria": 1800}] + result = import_vinculos_from_gennera( + ano=2026, + base_url="http://api.example.test", + token="token", + fetcher=lambda base_url, token, ano, cpf: [ + { + "cpf": cpf, + "turno": "Noite", + "referenceYear": "2026/1", + "course": "Direito", + "course_code": "999", + "ano": 2026, + "carga_horaria_acumulada": 100, + } + ], + ) + + vinculo.refresh_from_db() + self.assertEqual(vinculo.situacao, Vinculo.Situacao.DESVINCULADO) + self.assertEqual(vinculo.carga_horaria_integralizada, 1800) + self.assertEqual(result.situacao_nao_informada_corrigidos, 1) + fetch_mock.assert_called_once_with("http://api.example.test", "token", "MAT-1") + + def test_import_vinculos_from_gennera_skips_manually_edited_nao_informada(self) -> None: + pessoa = self.create_gennera_pessoa("Aluno Nao Informado Manual", "02542618330") + curso = Curso.objects.create( + codigo_mec=123, + descricao="Pedagogia", + carga_horaria_total=3200, + ) + vinculo = Vinculo.objects.create( + pessoa=pessoa, + ano_censo_id=2026, + curso=curso, + matricula="MAT-1", + situacao=Vinculo.Situacao.NAO_INFORMADA, + semestre_ingresso="012026", + atualizado_manualmente=True, + ingresso_vestibular=False, + ingresso_enem=False, + ingresso_avaliacao_seriada=False, + ingresso_selecao_simplificada=False, + ingresso_egresso_bi_li=False, + ingresso_pec_g=False, + ingresso_transferencia_ex_officio=False, + ingresso_decisao_judicial=False, + ingresso_vagas_remanescentes=False, + ingresso_programas_especiais=False, + apoio_social=False, + atividade_extracurricular=False, + carga_horaria_total_aluno=3200, + carga_horaria_integralizada=0, + ingressou_acao_afirmativa=False, + ) + + with patch("core.importers.fetch_gennera_carga_horaria") as fetch_mock: + result = import_vinculos_from_gennera( + ano=2026, + base_url="http://api.example.test", + token="token", + fetcher=lambda base_url, token, ano, cpf: [], + ) + + vinculo.refresh_from_db() + self.assertEqual(vinculo.situacao, Vinculo.Situacao.NAO_INFORMADA) + self.assertEqual(vinculo.carga_horaria_integralizada, 0) + self.assertEqual(result.situacao_nao_informada_corrigidos, 0) + fetch_mock.assert_not_called() + def create_gennera_pessoa(self, nome: str, cpf: str) -> Pessoa: return Pessoa.objects.create( nome=nome, diff --git a/core/views.py b/core/views.py index e3d68b3..99688ce 100644 --- a/core/views.py +++ b/core/views.py @@ -270,6 +270,7 @@ def export_alunos_view(request: HttpRequest, filtro: str) -> HttpResponse: "pessoa__municipio_nascimento", "pessoa__pais_origem", "curso", + "curso__polo", "curso_origem", "polo", "mobilidade_ies_destino", @@ -478,7 +479,7 @@ def processar_situacao_view(request: HttpRequest) -> HttpResponse: matricula__in=matriculas, ) found_matriculas = set(vinculos.values_list("matricula", flat=True)) - updated = vinculos.update(situacao=situacao) + updated = vinculos.update(situacao=situacao, atualizado_manualmente=True) result = { "updated": updated, "requested": len(matriculas), @@ -516,6 +517,16 @@ def crud_list(request: HttpRequest, slug: str) -> HttpResponse: if query: queryset = queryset.filter(build_search_query(config, query)) + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + if direction not in {"asc", "desc"}: + direction = "asc" + if sort in config.list_fields: + order_path = resolve_sort_field(config, sort) + queryset = queryset.order_by(order_path if direction == "asc" else f"-{order_path}") + else: + sort = "" + paginator = Paginator(queryset, 20) page_obj = paginator.get_page(request.GET.get("page")) rows = [ @@ -532,6 +543,8 @@ def crud_list(request: HttpRequest, slug: str) -> HttpResponse: { "config": config, "query": query, + "sort": sort, + "dir": direction, "page_obj": page_obj, "rows": rows, }, @@ -564,6 +577,7 @@ def crud_create(request: HttpRequest, slug: str) -> HttpResponse: if request.method == "POST" and form.is_valid(): obj = form.save() + mark_atualizado_manualmente(obj) messages.success(request, f"{config.title} salvo com sucesso.") return redirect("crud_detail", slug=slug, pk=obj.pk) @@ -587,6 +601,7 @@ def crud_update(request: HttpRequest, slug: str, pk: str) -> HttpResponse: if request.method == "POST" and form.is_valid(): obj = form.save() + mark_atualizado_manualmente(obj) messages.success(request, f"{config.title} atualizado com sucesso.") return redirect("crud_detail", slug=slug, pk=obj.pk) @@ -679,8 +694,21 @@ def build_search_query(config: CrudConfig, query: str) -> Q: return filters +def resolve_sort_field(config: CrudConfig, field: str) -> str: + for search_field, _lookup in config.search_fields: + if search_field == field or search_field.startswith(f"{field}__"): + return search_field + return field + + +def mark_atualizado_manualmente(obj: Model) -> None: + if hasattr(obj, "atualizado_manualmente") and not obj.atualizado_manualmente: + obj.atualizado_manualmente = True + obj.save(update_fields=["atualizado_manualmente"]) + + def build_model_form(model: type[Model]) -> type[forms.ModelForm]: - form_class = modelform_factory(model, fields="__all__") + form_class = modelform_factory(model, exclude=["atualizado_manualmente"]) class StyledForm(form_class): def __init__(self, *args: Any, **kwargs: Any) -> None: diff --git a/templates/base.html b/templates/base.html index 661dd5d..b12f3e1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -114,6 +114,10 @@ Paises + + + Polos +