feat: protect manual edits from API overwrite, fix pending situacao, sort CRUD lists
- Add Pessoa/Vinculo.atualizado_manualmente flag, set automatically on web CRUD save; all Gennera/txt import paths now skip records flagged this way instead of overwriting them - Vinculo import: finalize step corrects leftover "situacao nao informada" vinculos to "Desvinculado", pulling carga horaria from censup_carga_horaria - Generic CRUD list view: click a column header to sort (asc/desc), persists through pagination; FK columns sort by their related display field - Rename GENNERA_API_BASE_URL setting to GENNERA_LOCAL_BASE_URL Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1e72e0239a
commit
36d3386d2d
@ -8,7 +8,7 @@ POSTGRES_PASSWORD=autocensup
|
|||||||
POSTGRES_HOST=localhost
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
GENNERA_API_BASE_URL=http://gennera.solucaoti.net.br
|
GENNERA_LOCAL_BASE_URL=http://gennera.solucaoti.net.br
|
||||||
API_TOKEN=
|
API_TOKEN=
|
||||||
GENNERA_LOCAL_AUTH_TOKEN=
|
GENNERA_LOCAL_AUTH_TOKEN=
|
||||||
CENSUP_IES_CODIGO=
|
CENSUP_IES_CODIGO=
|
||||||
|
|||||||
@ -33,7 +33,7 @@ Variaveis principais:
|
|||||||
- `DJANGO_ALLOWED_HOSTS`: hosts permitidos.
|
- `DJANGO_ALLOWED_HOSTS`: hosts permitidos.
|
||||||
- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`: conexao PostgreSQL.
|
- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`: conexao PostgreSQL.
|
||||||
- `DATABASE_URL`: alternativa para configurar o banco por URL.
|
- `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.
|
- `API_TOKEN`: token Bearer usado nas chamadas a API Gennera.
|
||||||
|
|
||||||
Comandos basicos:
|
Comandos basicos:
|
||||||
|
|||||||
@ -137,7 +137,7 @@ LOGIN_URL = "login"
|
|||||||
LOGIN_REDIRECT_URL = "home"
|
LOGIN_REDIRECT_URL = "home"
|
||||||
LOGOUT_REDIRECT_URL = "login"
|
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_API_TOKEN = os.getenv("API_TOKEN", "")
|
||||||
GENNERA_LOCAL_AUTH_TOKEN = os.getenv("GENNERA_LOCAL_AUTH_TOKEN", "")
|
GENNERA_LOCAL_AUTH_TOKEN = os.getenv("GENNERA_LOCAL_AUTH_TOKEN", "")
|
||||||
|
|
||||||
|
|||||||
@ -104,6 +104,7 @@ class ImportVinculosApiResult:
|
|||||||
pessoas_without_vinculo: int = 0
|
pessoas_without_vinculo: int = 0
|
||||||
created: int = 0
|
created: int = 0
|
||||||
updated: int = 0
|
updated: int = 0
|
||||||
|
situacao_nao_informada_corrigidos: int = 0
|
||||||
errors: list[ImportVinculoApiError] | None = None
|
errors: list[ImportVinculoApiError] | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@ -488,6 +489,8 @@ def import_carga_horaria_from_gennera(
|
|||||||
|
|
||||||
atualizados = []
|
atualizados = []
|
||||||
for vinculo in Vinculo.objects.filter(matricula=matricula).select_related("curso"):
|
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)
|
carga = carga_horaria_ate_ano(by_ano, vinculo.ano_censo_id)
|
||||||
if carga is None or carga == vinculo.carga_horaria_integralizada:
|
if carga is None or carga == vinculo.carga_horaria_integralizada:
|
||||||
continue
|
continue
|
||||||
@ -548,6 +551,53 @@ def import_pessoas_ingressantes_from_gennera(
|
|||||||
return result
|
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(
|
def import_vinculos_from_gennera(
|
||||||
ano: int,
|
ano: int,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
@ -618,6 +668,9 @@ def import_vinculos_from_gennera(
|
|||||||
index, total_pessoas, pessoa.cpf, [vinculo for vinculo, _ in imported]
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@ -701,6 +754,9 @@ def import_gennera_vinculo_group(
|
|||||||
for item in rows
|
for item in rows
|
||||||
)
|
)
|
||||||
existing = Vinculo.objects.filter(pessoa=pessoa, curso=curso, ano_censo=ano).first()
|
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(
|
situacao = gennera_vinculo_situacao(
|
||||||
rows=rows,
|
rows=rows,
|
||||||
curso=curso,
|
curso=curso,
|
||||||
@ -740,9 +796,11 @@ def import_gennera_vinculo_group(
|
|||||||
|
|
||||||
|
|
||||||
def mark_person_vinculos_as_desvinculado(pessoa: Pessoa, ano: int) -> int:
|
def mark_person_vinculos_as_desvinculado(pessoa: Pessoa, ano: int) -> int:
|
||||||
return Vinculo.objects.filter(pessoa=pessoa, ano_censo=ano).exclude(
|
return Vinculo.objects.filter(
|
||||||
situacao=Vinculo.Situacao.FORMADO
|
pessoa=pessoa, ano_censo=ano, atualizado_manualmente=False
|
||||||
).update(situacao=Vinculo.Situacao.DESVINCULADO)
|
).exclude(situacao=Vinculo.Situacao.FORMADO).update(
|
||||||
|
situacao=Vinculo.Situacao.DESVINCULADO
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def import_alunos(lines: Iterable[str], ano_censo: int) -> ImportAlunosResult:
|
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,
|
cpf=cpf,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
if not created:
|
if not created and not pessoa.atualizado_manualmente:
|
||||||
for field, value in defaults.items():
|
for field, value in defaults.items():
|
||||||
if field in {"nome", "data_nascimento"}:
|
if field in {"nome", "data_nascimento"}:
|
||||||
continue
|
continue
|
||||||
@ -930,6 +988,12 @@ def import_vinculo_record(
|
|||||||
"justificativa": parse_optional_int(fields[72]),
|
"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():
|
with transaction.atomic():
|
||||||
vinculo, created = Vinculo.objects.update_or_create(
|
vinculo, created = Vinculo.objects.update_or_create(
|
||||||
pessoa=pessoa,
|
pessoa=pessoa,
|
||||||
@ -997,12 +1061,13 @@ def export_vinculo_record(vinculo: Vinculo) -> str:
|
|||||||
if vinculo.situacao == Vinculo.Situacao.NAO_INFORMADA
|
if vinculo.situacao == Vinculo.Situacao.NAO_INFORMADA
|
||||||
else str(vinculo.situacao)
|
else str(vinculo.situacao)
|
||||||
)
|
)
|
||||||
|
polo = vinculo.polo or vinculo.curso.polo
|
||||||
fields = [
|
fields = [
|
||||||
"42",
|
"42",
|
||||||
vinculo.matricula,
|
vinculo.matricula,
|
||||||
_fmt_optional_int(vinculo.periodo_referencia),
|
_fmt_optional_int(vinculo.periodo_referencia),
|
||||||
_fmt_fk_pk(vinculo.curso_id),
|
_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),
|
_fmt_optional_int(vinculo.turno),
|
||||||
situacao,
|
situacao,
|
||||||
_fmt_fk_pk(vinculo.curso_origem_id),
|
_fmt_fk_pk(vinculo.curso_origem_id),
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = import_carga_horaria_from_gennera(
|
result = import_carga_horaria_from_gennera(
|
||||||
base_url=settings.GENNERA_API_BASE_URL,
|
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||||
token=token,
|
token=token,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
max_workers=threads,
|
max_workers=threads,
|
||||||
|
|||||||
@ -26,7 +26,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
result = import_pessoas_ingressantes_from_gennera(
|
result = import_pessoas_ingressantes_from_gennera(
|
||||||
primeiro_ano=primeiro_ano,
|
primeiro_ano=primeiro_ano,
|
||||||
base_url=settings.GENNERA_API_BASE_URL,
|
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||||
token=token,
|
token=token,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
progress_callback=report_progress,
|
progress_callback=report_progress,
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
result = import_vinculos_from_gennera(
|
result = import_vinculos_from_gennera(
|
||||||
ano=ano,
|
ano=ano,
|
||||||
base_url=settings.GENNERA_API_BASE_URL,
|
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||||
token=token,
|
token=token,
|
||||||
fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size),
|
fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size),
|
||||||
progress_callback=report_progress,
|
progress_callback=report_progress,
|
||||||
@ -55,6 +55,8 @@ class Command(BaseCommand):
|
|||||||
f"{result.pessoas_without_vinculo} sem vinculo no ano, "
|
f"{result.pessoas_without_vinculo} sem vinculo no ano, "
|
||||||
f"{result.created} vinculos criados, "
|
f"{result.created} vinculos criados, "
|
||||||
f"{result.updated} vinculos atualizados, "
|
f"{result.updated} vinculos atualizados, "
|
||||||
|
f"{result.situacao_nao_informada_corrigidos} corrigidos de "
|
||||||
|
"situacao nao informada para desvinculado, "
|
||||||
f"{len(result.errors)} com erro."
|
f"{len(result.errors)} com erro."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -201,6 +201,7 @@ class Pessoa(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name="pessoas",
|
related_name="pessoas",
|
||||||
)
|
)
|
||||||
|
atualizado_manualmente = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "pessoa"
|
verbose_name = "pessoa"
|
||||||
@ -393,6 +394,7 @@ class Vinculo(models.Model):
|
|||||||
reserva_olimpiadas_conhecimento = models.BooleanField(null=True, blank=True)
|
reserva_olimpiadas_conhecimento = models.BooleanField(null=True, blank=True)
|
||||||
reserva_outros = models.BooleanField(null=True, blank=True)
|
reserva_outros = models.BooleanField(null=True, blank=True)
|
||||||
justificativa = models.PositiveSmallIntegerField(null=True, blank=True)
|
justificativa = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||||
|
atualizado_manualmente = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "vinculo"
|
verbose_name = "vinculo"
|
||||||
|
|||||||
@ -30,7 +30,7 @@ def import_pessoas_ingressantes_task(
|
|||||||
|
|
||||||
result = import_pessoas_ingressantes_from_gennera(
|
result = import_pessoas_ingressantes_from_gennera(
|
||||||
primeiro_ano=primeiro_ano,
|
primeiro_ano=primeiro_ano,
|
||||||
base_url=settings.GENNERA_API_BASE_URL,
|
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||||
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
progress_callback=report_progress,
|
progress_callback=report_progress,
|
||||||
@ -60,7 +60,7 @@ def import_vinculos_censup_task(
|
|||||||
|
|
||||||
result = import_vinculos_from_gennera(
|
result = import_vinculos_from_gennera(
|
||||||
ano=ano,
|
ano=ano,
|
||||||
base_url=settings.GENNERA_API_BASE_URL,
|
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||||
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
||||||
fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size),
|
fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size),
|
||||||
progress_callback=report_progress,
|
progress_callback=report_progress,
|
||||||
@ -71,6 +71,7 @@ def import_vinculos_censup_task(
|
|||||||
"pessoas_without_vinculo": result.pessoas_without_vinculo,
|
"pessoas_without_vinculo": result.pessoas_without_vinculo,
|
||||||
"created": result.created,
|
"created": result.created,
|
||||||
"updated": result.updated,
|
"updated": result.updated,
|
||||||
|
"situacao_nao_informada_corrigidos": result.situacao_nao_informada_corrigidos,
|
||||||
"errors": len(result.errors),
|
"errors": len(result.errors),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,7 +91,7 @@ def import_carga_horaria_censup_task(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = import_carga_horaria_from_gennera(
|
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,
|
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
max_workers=threads,
|
max_workers=threads,
|
||||||
|
|||||||
408
core/tests.py
408
core/tests.py
@ -519,6 +519,120 @@ class CrudViewTests(AuthenticatedViewTestCase):
|
|||||||
self.assertRedirects(delete_response, reverse("crud_list", kwargs={"slug": "ufs"}))
|
self.assertRedirects(delete_response, reverse("crud_list", kwargs={"slug": "ufs"}))
|
||||||
self.assertFalse(UF.objects.filter(codigo=22).exists())
|
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:
|
def test_pending_required_fields_view_lists_courses_without_carga(self) -> None:
|
||||||
Curso.objects.create(
|
Curso.objects.create(
|
||||||
codigo_mec=123,
|
codigo_mec=123,
|
||||||
@ -837,6 +951,77 @@ class ImportAlunosTests(AuthenticatedViewTestCase):
|
|||||||
self.assertEqual(pessoa.data_nascimento, date(1999, 1, 1))
|
self.assertEqual(pessoa.data_nascimento, date(1999, 1, 1))
|
||||||
self.assertEqual(pessoa.cor_raca, Pessoa.CorRaca.PARDA)
|
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:
|
def test_import_alunos_view_accepts_upload(self) -> None:
|
||||||
uploaded = SimpleUploadedFile(
|
uploaded = SimpleUploadedFile(
|
||||||
"aluno_exportacao.txt",
|
"aluno_exportacao.txt",
|
||||||
@ -1018,6 +1203,23 @@ class ExportAlunoRecordTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(line.split("|")[4], str(polo.codigo))
|
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:
|
def test_export_vinculo_record_writes_minus_one_for_nao_informada_situacao(self) -> None:
|
||||||
pessoa = self.make_pessoa()
|
pessoa = self.make_pessoa()
|
||||||
vinculo = self.make_vinculo(pessoa, situacao=Vinculo.Situacao.NAO_INFORMADA)
|
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(result.pessoas_without_vinculo, 1)
|
||||||
self.assertEqual(vinculo.situacao, Vinculo.Situacao.DESVINCULADO)
|
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:
|
def create_gennera_pessoa(self, nome: str, cpf: str) -> Pessoa:
|
||||||
return Pessoa.objects.create(
|
return Pessoa.objects.create(
|
||||||
nome=nome,
|
nome=nome,
|
||||||
|
|||||||
@ -270,6 +270,7 @@ def export_alunos_view(request: HttpRequest, filtro: str) -> HttpResponse:
|
|||||||
"pessoa__municipio_nascimento",
|
"pessoa__municipio_nascimento",
|
||||||
"pessoa__pais_origem",
|
"pessoa__pais_origem",
|
||||||
"curso",
|
"curso",
|
||||||
|
"curso__polo",
|
||||||
"curso_origem",
|
"curso_origem",
|
||||||
"polo",
|
"polo",
|
||||||
"mobilidade_ies_destino",
|
"mobilidade_ies_destino",
|
||||||
@ -478,7 +479,7 @@ def processar_situacao_view(request: HttpRequest) -> HttpResponse:
|
|||||||
matricula__in=matriculas,
|
matricula__in=matriculas,
|
||||||
)
|
)
|
||||||
found_matriculas = set(vinculos.values_list("matricula", flat=True))
|
found_matriculas = set(vinculos.values_list("matricula", flat=True))
|
||||||
updated = vinculos.update(situacao=situacao)
|
updated = vinculos.update(situacao=situacao, atualizado_manualmente=True)
|
||||||
result = {
|
result = {
|
||||||
"updated": updated,
|
"updated": updated,
|
||||||
"requested": len(matriculas),
|
"requested": len(matriculas),
|
||||||
@ -516,6 +517,16 @@ def crud_list(request: HttpRequest, slug: str) -> HttpResponse:
|
|||||||
if query:
|
if query:
|
||||||
queryset = queryset.filter(build_search_query(config, 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)
|
paginator = Paginator(queryset, 20)
|
||||||
page_obj = paginator.get_page(request.GET.get("page"))
|
page_obj = paginator.get_page(request.GET.get("page"))
|
||||||
rows = [
|
rows = [
|
||||||
@ -532,6 +543,8 @@ def crud_list(request: HttpRequest, slug: str) -> HttpResponse:
|
|||||||
{
|
{
|
||||||
"config": config,
|
"config": config,
|
||||||
"query": query,
|
"query": query,
|
||||||
|
"sort": sort,
|
||||||
|
"dir": direction,
|
||||||
"page_obj": page_obj,
|
"page_obj": page_obj,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
},
|
},
|
||||||
@ -564,6 +577,7 @@ def crud_create(request: HttpRequest, slug: str) -> HttpResponse:
|
|||||||
|
|
||||||
if request.method == "POST" and form.is_valid():
|
if request.method == "POST" and form.is_valid():
|
||||||
obj = form.save()
|
obj = form.save()
|
||||||
|
mark_atualizado_manualmente(obj)
|
||||||
messages.success(request, f"{config.title} salvo com sucesso.")
|
messages.success(request, f"{config.title} salvo com sucesso.")
|
||||||
return redirect("crud_detail", slug=slug, pk=obj.pk)
|
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():
|
if request.method == "POST" and form.is_valid():
|
||||||
obj = form.save()
|
obj = form.save()
|
||||||
|
mark_atualizado_manualmente(obj)
|
||||||
messages.success(request, f"{config.title} atualizado com sucesso.")
|
messages.success(request, f"{config.title} atualizado com sucesso.")
|
||||||
return redirect("crud_detail", slug=slug, pk=obj.pk)
|
return redirect("crud_detail", slug=slug, pk=obj.pk)
|
||||||
|
|
||||||
@ -679,8 +694,21 @@ def build_search_query(config: CrudConfig, query: str) -> Q:
|
|||||||
return filters
|
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]:
|
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):
|
class StyledForm(form_class):
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
|||||||
@ -114,6 +114,10 @@
|
|||||||
<i class="ti ti-world me-2"></i>
|
<i class="ti ti-world me-2"></i>
|
||||||
Paises
|
Paises
|
||||||
</a>
|
</a>
|
||||||
|
<a class="dropdown-item" href="{% url 'crud_list' 'polos' %}">
|
||||||
|
<i class="ti ti-map-pin-2 me-2"></i>
|
||||||
|
Polos
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|||||||
@ -38,7 +38,17 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% for field in config.list_fields %}
|
{% for field in config.list_fields %}
|
||||||
<th>{{ field }}</th>
|
<th>
|
||||||
|
<a
|
||||||
|
href="?q={{ query|urlencode }}&sort={{ field }}&dir={% if sort == field and dir == 'asc' %}desc{% else %}asc{% endif %}"
|
||||||
|
class="text-reset text-decoration-none d-inline-flex align-items-center gap-1"
|
||||||
|
>
|
||||||
|
{{ field }}
|
||||||
|
{% if sort == field %}
|
||||||
|
<i class="ti ti-chevron-{% if dir == 'asc' %}up{% else %}down{% endif %}"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th class="w-1"></th>
|
<th class="w-1"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -99,14 +109,14 @@
|
|||||||
<ul class="pagination m-0 ms-auto">
|
<ul class="pagination m-0 ms-auto">
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?q={{ query }}&page={{ page_obj.previous_page_number }}">
|
<a class="page-link" href="?q={{ query|urlencode }}&sort={{ sort }}&dir={{ dir }}&page={{ page_obj.previous_page_number }}">
|
||||||
<i class="ti ti-chevron-left"></i>
|
<i class="ti ti-chevron-left"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?q={{ query }}&page={{ page_obj.next_page_number }}">
|
<a class="page-link" href="?q={{ query|urlencode }}&sort={{ sort }}&dir={{ dir }}&page={{ page_obj.next_page_number }}">
|
||||||
<i class="ti ti-chevron-right"></i>
|
<i class="ti ti-chevron-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user