Compare commits

...

3 Commits

Author SHA1 Message Date
3eca55578d perf: dedup vinculo per-cpf refetch, add retry/pooling, freeze carga horaria on FORMADO
Skip re-fetching a CPF from the per-pessoa Gennera loop when the bulk
ingressantes step already resolved it for the same ano, cutting
redundant HTTP fan-out. Add bounded retry with backoff on transient
connection errors (never on HTTP status errors) across the four Gennera
fetch functions, backed by a shared requests.Session for connection
reuse instead of one-off urllib calls. Also stop syncing
carga_horaria_integralizada from the API once a vinculo is FORMADO,
matching the existing sticky-situacao rule, in both the vinculo import
and the standalone carga-horaria command.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:02:00 -03:00
d96bea6755 feat: add exportar field to Curso, defaulting extinct courses to false
Lets operators exclude a course from the aluno export file without
deleting it. Existing courses already marked "Em extinção" are
backfilled to exportar=False by the migration.
2026-07-06 17:10:42 -03:00
36d3386d2d 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>
2026-07-06 15:39:36 -03:00
17 changed files with 1231 additions and 117 deletions

View File

@ -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=

View File

@ -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:

View File

@ -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", "")

View File

@ -6,14 +6,13 @@ from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
import itertools import itertools
import json
from pathlib import Path from pathlib import Path
import re import re
import time
from typing import Any, Callable, Iterable, Iterator from typing import Any, Callable, Iterable, Iterator
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen
import requests
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from .models import ( from .models import (
@ -35,6 +34,33 @@ GENNERA_USER_AGENT = (
) )
_gennera_session = requests.Session()
def _gennera_get_with_retry(
url: str,
headers: dict[str, str],
timeout: int,
retries: int = 2,
backoff: float = 0.5,
) -> requests.Response:
last_error: requests.RequestException | None = None
for attempt in range(retries + 1):
try:
response = _gennera_session.get(url, headers=headers, timeout=timeout)
response.raise_for_status()
return response
except requests.HTTPError:
raise
except requests.RequestException as exc:
last_error = exc
if attempt < retries:
time.sleep(backoff * (attempt + 1))
continue
raise
raise last_error
@dataclass @dataclass
class ImportCursosResult: class ImportCursosResult:
created: int = 0 created: int = 0
@ -104,6 +130,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:
@ -115,6 +142,22 @@ class ImportVinculosApiResult:
return self.created + self.updated + len(self.errors or []) return self.created + self.updated + len(self.errors or [])
@dataclass
class ImportVinculosIngressantesResult:
pessoas_processed: int = 0
pessoas_nao_encontradas: int = 0
created: int = 0
updated: int = 0
errors: list[ImportVinculoApiError] | None = None
cpfs_resolvidos: set[str] | None = None
def __post_init__(self) -> None:
if self.errors is None:
self.errors = []
if self.cpfs_resolvidos is None:
self.cpfs_resolvidos = set()
@dataclass @dataclass
class ImportCargaHorariaError: class ImportCargaHorariaError:
identifier: str identifier: str
@ -241,25 +284,23 @@ def fetch_gennera_pessoas_ingressantes_page(
"order": order, "order": order,
} }
url = f"{base_url.rstrip('/')}/api/censup_person_ingressantes?{urlencode(query)}" url = f"{base_url.rstrip('/')}/api/censup_person_ingressantes?{urlencode(query)}"
request = Request( headers = {
url,
headers={
"Accept": "application/json", "Accept": "application/json",
"auth_token": token, "auth_token": token,
"User-Agent": GENNERA_USER_AGENT, "User-Agent": GENNERA_USER_AGENT,
}, }
)
try: try:
with urlopen(request, timeout=timeout) as response: response = _gennera_get_with_retry(url, headers, timeout)
total_count = int(response.headers.get("X-Total-Count", "0")) total_count = int(response.headers.get("X-Total-Count", "0"))
total_pages = int(response.headers.get("X-Total-Pages", "1")) total_pages = int(response.headers.get("X-Total-Pages", "1"))
payload = json.loads(response.read().decode("utf-8")) payload = response.json()
except HTTPError as exc: except requests.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace") code = exc.response.status_code if exc.response is not None else 0
raise ValueError(f"API Gennera retornou HTTP {exc.code}: {detail}") from exc detail = exc.response.text if exc.response is not None else ""
except URLError as exc: raise ValueError(f"API Gennera retornou HTTP {code}: {detail}") from exc
raise ValueError(f"Erro ao conectar na API Gennera: {exc.reason}") from exc except requests.RequestException as exc:
raise ValueError(f"Erro ao conectar na API Gennera: {exc}") from exc
data = payload.get("data") if isinstance(payload, dict) else payload data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list): if not isinstance(data, list):
@ -307,25 +348,23 @@ def fetch_gennera_vinculos_censup_page(
if ano is not None: if ano is not None:
query["ano"] = ano query["ano"] = ano
url = f"{base_url.rstrip('/')}/api/censup_vinculos?{urlencode(query)}" url = f"{base_url.rstrip('/')}/api/censup_vinculos?{urlencode(query)}"
request = Request( headers = {
url,
headers={
"Accept": "application/json", "Accept": "application/json",
"auth_token": token, "auth_token": token,
"User-Agent": GENNERA_USER_AGENT, "User-Agent": GENNERA_USER_AGENT,
}, }
)
try: try:
with urlopen(request, timeout=timeout) as response: response = _gennera_get_with_retry(url, headers, timeout)
total_count = int(response.headers.get("X-Total-Count", "0")) total_count = int(response.headers.get("X-Total-Count", "0"))
total_pages = int(response.headers.get("X-Total-Pages", "1")) total_pages = int(response.headers.get("X-Total-Pages", "1"))
payload = json.loads(response.read().decode("utf-8")) payload = response.json()
except HTTPError as exc: except requests.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace") code = exc.response.status_code if exc.response is not None else 0
raise ValueError(f"API Gennera retornou HTTP {exc.code}: {detail}") from exc detail = exc.response.text if exc.response is not None else ""
except URLError as exc: raise ValueError(f"API Gennera retornou HTTP {code}: {detail}") from exc
raise ValueError(f"Erro ao conectar na API Gennera: {exc.reason}") from exc except requests.RequestException as exc:
raise ValueError(f"Erro ao conectar na API Gennera: {exc}") from exc
data = payload.get("data") if isinstance(payload, dict) else payload data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list): if not isinstance(data, list):
@ -355,6 +394,63 @@ def fetch_gennera_vinculos_censup(
return items return items
def fetch_gennera_vinculos_ingressantes_page(
base_url: str,
token: str,
ano: int,
page: int,
page_size: int = 100,
order: str = "asc",
timeout: int = 30,
) -> tuple[list[dict[str, Any]], int, int]:
query = {
"ano": ano,
"page": page,
"page_size": page_size,
"order": order,
}
url = f"{base_url.rstrip('/')}/api/censup_vinculos_ingressantes?{urlencode(query)}"
headers = {
"Accept": "application/json",
"auth_token": token,
"User-Agent": GENNERA_USER_AGENT,
}
try:
response = _gennera_get_with_retry(url, headers, timeout)
total_count = int(response.headers.get("X-Total-Count", "0"))
total_pages = int(response.headers.get("X-Total-Pages", "1"))
payload = response.json()
except requests.HTTPError as exc:
code = exc.response.status_code if exc.response is not None else 0
detail = exc.response.text if exc.response is not None else ""
raise ValueError(f"API Gennera retornou HTTP {code}: {detail}") from exc
except requests.RequestException as exc:
raise ValueError(f"Erro ao conectar na API Gennera: {exc}") from exc
data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list):
raise ValueError("Resposta da API Gennera sem lista 'data'.")
return data, total_count, total_pages
def iter_gennera_vinculos_ingressantes(
base_url: str,
token: str,
ano: int,
page_size: int = 100,
fetcher: Callable[
[str, str, int, int, int], tuple[list[dict[str, Any]], int, int]
] = fetch_gennera_vinculos_ingressantes_page,
) -> Iterator[tuple[int, int, int, list[dict[str, Any]]]]:
page = 1
total_pages = 1
while page <= total_pages:
items, total_count, total_pages = fetcher(base_url, token, ano, page, page_size)
yield page, total_pages, total_count, items
page += 1
def fetch_gennera_carga_horaria_page( def fetch_gennera_carga_horaria_page(
base_url: str, base_url: str,
token: str, token: str,
@ -373,25 +469,23 @@ def fetch_gennera_carga_horaria_page(
"order": order, "order": order,
} }
url = f"{base_url.rstrip('/')}/api/censup_carga_horaria?{urlencode(query)}" url = f"{base_url.rstrip('/')}/api/censup_carga_horaria?{urlencode(query)}"
request = Request( headers = {
url,
headers={
"Accept": "application/json", "Accept": "application/json",
"auth_token": token, "auth_token": token,
"User-Agent": GENNERA_USER_AGENT, "User-Agent": GENNERA_USER_AGENT,
}, }
)
try: try:
with urlopen(request, timeout=timeout) as response: response = _gennera_get_with_retry(url, headers, timeout)
total_count = int(response.headers.get("X-Total-Count", "0")) total_count = int(response.headers.get("X-Total-Count", "0"))
total_pages = int(response.headers.get("X-Total-Pages", "1")) total_pages = int(response.headers.get("X-Total-Pages", "1"))
payload = json.loads(response.read().decode("utf-8")) payload = response.json()
except HTTPError as exc: except requests.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace") code = exc.response.status_code if exc.response is not None else 0
raise ValueError(f"API Gennera retornou HTTP {exc.code}: {detail}") from exc detail = exc.response.text if exc.response is not None else ""
except URLError as exc: raise ValueError(f"API Gennera retornou HTTP {code}: {detail}") from exc
raise ValueError(f"Erro ao conectar na API Gennera: {exc.reason}") from exc except requests.RequestException as exc:
raise ValueError(f"Erro ao conectar na API Gennera: {exc}") from exc
data = payload.get("data") if isinstance(payload, dict) else payload data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list): if not isinstance(data, list):
@ -488,6 +582,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 or vinculo.situacao == Vinculo.Situacao.FORMADO:
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 +644,114 @@ 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_ingressantes_from_gennera(
ano: int,
base_url: str,
token: str,
page_size: int = 100,
progress_callback: Callable[[int, int, str, list[Vinculo]], None] | None = None,
fetcher: Callable[
..., tuple[list[dict[str, Any]], int, int]
] = fetch_gennera_vinculos_ingressantes_page,
) -> ImportVinculosIngressantesResult:
if not token:
raise ValueError("Token da API Gennera nao configurado.")
AnoCenso.objects.get_or_create(ano=ano)
result = ImportVinculosIngressantesResult()
grouped: dict[str, list[dict[str, Any]]] = {}
for _page, _total_pages, _total_count, items in iter_gennera_vinculos_ingressantes(
base_url, token, ano, page_size, fetcher=fetcher
):
for item in items:
cpf = only_digits(str(item.get("cpf") or ""))
if not cpf:
continue
grouped.setdefault(cpf, []).append(item)
total_pessoas = len(grouped)
for index, (cpf, rows) in enumerate(grouped.items(), start=1):
result.pessoas_processed += 1
pessoa = Pessoa.objects.filter(cpf=cpf).first()
if pessoa is None:
result.pessoas_nao_encontradas += 1
result.errors.append(
ImportVinculoApiError(identifier=cpf, message="Pessoa nao encontrada.")
)
if progress_callback is not None:
progress_callback(index, total_pessoas, cpf, [])
continue
try:
imported = import_gennera_vinculo_records(rows, pessoa=pessoa, ano=ano)
attach_pessoa_ano_censo(pessoa, ano)
except Exception as exc:
result.errors.append(ImportVinculoApiError(identifier=cpf, message=str(exc)))
if progress_callback is not None:
progress_callback(index, total_pessoas, cpf, [])
continue
result.cpfs_resolvidos.add(cpf)
for _, created in imported:
if created:
result.created += 1
else:
result.updated += 1
if progress_callback is not None:
progress_callback(index, total_pessoas, cpf, [v for v, _ in imported])
return result
def import_vinculos_from_gennera( def import_vinculos_from_gennera(
ano: int, ano: int,
base_url: str, base_url: str,
@ -555,13 +759,34 @@ def import_vinculos_from_gennera(
fetcher: Callable[[str, str, int, str], list[dict[str, Any]]], fetcher: Callable[[str, str, int, str], list[dict[str, Any]]],
progress_callback: Callable[[int, int, str, list[Vinculo]], None] | None = None, progress_callback: Callable[[int, int, str, list[Vinculo]], None] | None = None,
max_workers: int = 4, max_workers: int = 4,
ingressantes_page_size: int = 100,
ingressantes_fetcher: Callable[
..., tuple[list[dict[str, Any]], int, int]
] = fetch_gennera_vinculos_ingressantes_page,
) -> ImportVinculosApiResult: ) -> ImportVinculosApiResult:
if not token: if not token:
raise ValueError("Token da API Gennera nao configurado.") raise ValueError("Token da API Gennera nao configurado.")
AnoCenso.objects.get_or_create(ano=ano) AnoCenso.objects.get_or_create(ano=ano)
result = ImportVinculosApiResult() result = ImportVinculosApiResult()
pessoas = list(Pessoa.objects.exclude(cpf="").order_by("nome", "cpf"))
ingressantes_result = import_vinculos_ingressantes_from_gennera(
ano=ano,
base_url=base_url,
token=token,
page_size=ingressantes_page_size,
progress_callback=progress_callback,
fetcher=ingressantes_fetcher,
)
result.created += ingressantes_result.created
result.updated += ingressantes_result.updated
result.errors.extend(ingressantes_result.errors)
pessoas = [
pessoa
for pessoa in Pessoa.objects.exclude(cpf="").order_by("nome", "cpf")
if pessoa.cpf not in ingressantes_result.cpfs_resolvidos
]
total_pessoas = len(pessoas) total_pessoas = len(pessoas)
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
@ -618,6 +843,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,12 +929,18 @@ 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,
carga_horaria_integralizada=carga_horaria_integralizada, carga_horaria_integralizada=carga_horaria_integralizada,
existing=existing, existing=existing,
) )
if existing is not None and existing.situacao == Vinculo.Situacao.FORMADO:
carga_horaria_integralizada = existing.carga_horaria_integralizada
defaults = { defaults = {
"periodo_referencia": periodo_referencia, "periodo_referencia": periodo_referencia,
"turno": parse_gennera_turno(reference_item.get("turno")), "turno": parse_gennera_turno(reference_item.get("turno")),
@ -740,9 +974,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 +1075,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 +1166,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 +1239,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),

View File

@ -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,

View File

@ -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,

View File

@ -41,11 +41,12 @@ 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,
max_workers=threads, max_workers=threads,
ingressantes_page_size=page_size,
) )
self.stdout.write( self.stdout.write(
@ -55,6 +56,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."
) )
) )

View File

@ -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),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.14 on 2026-07-06 18:46
from django.db import migrations, models
def marcar_cursos_extintos_como_nao_exportar(apps, schema_editor):
Curso = apps.get_model("core", "Curso")
Curso.objects.filter(
situacao_funcionamento_curso__iregex=r"^em extin[cç][aã]o$"
).update(exportar=False)
def reverter(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('core', '0013_pessoa_atualizado_manualmente_and_more'),
]
operations = [
migrations.AddField(
model_name='curso',
name='exportar',
field=models.BooleanField(default=True),
),
migrations.RunPython(
marcar_cursos_extintos_como_nao_exportar,
reverter,
),
]

View File

@ -68,6 +68,7 @@ class Curso(models.Model):
situacao_funcionamento_curso = models.CharField(max_length=100, blank=True) situacao_funcionamento_curso = models.CharField(max_length=100, blank=True)
licenciatura = models.BooleanField(default=False) licenciatura = models.BooleanField(default=False)
ead = models.BooleanField(default=False) ead = models.BooleanField(default=False)
exportar = models.BooleanField(default=True)
carga_horaria_total = models.PositiveIntegerField() carga_horaria_total = models.PositiveIntegerField()
polo = models.ForeignKey( polo = models.ForeignKey(
"Polo", "Polo",
@ -201,6 +202,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 +395,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"

View File

@ -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,17 +60,19 @@ 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,
max_workers=threads, max_workers=threads,
ingressantes_page_size=page_size,
) )
return { return {
"pessoas_processed": result.pessoas_processed, "pessoas_processed": result.pessoas_processed,
"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 +92,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,

View File

@ -3,6 +3,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
import requests
from django.db import IntegrityError from django.db import IntegrityError
from django.test import SimpleTestCase, TestCase, override_settings from django.test import SimpleTestCase, TestCase, override_settings
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
@ -18,11 +19,13 @@ from .importers import (
fetch_gennera_pessoas_ingressantes_page, fetch_gennera_pessoas_ingressantes_page,
fetch_gennera_vinculos_censup_page, fetch_gennera_vinculos_censup_page,
import_alunos, import_alunos,
import_carga_horaria_from_gennera,
import_cursos, import_cursos,
import_pessoa_record, import_pessoa_record,
import_pessoas_ingressantes_from_gennera, import_pessoas_ingressantes_from_gennera,
import_vinculo_record, import_vinculo_record,
import_vinculos_from_gennera, import_vinculos_from_gennera,
import_vinculos_ingressantes_from_gennera,
import_polos, import_polos,
load_max_carga_horaria_by_curso, load_max_carga_horaria_by_curso,
) )
@ -30,6 +33,21 @@ from .models import AnoCenso, Curso, Instituicao, Municipio, Pais, Pessoa, Polo,
from .views import CRUD_CONFIGS from .views import CRUD_CONFIGS
class FakeGenneraResponse:
def __init__(self, json_data=None, headers=None, status_code=200, text=""):
self._json = json_data
self.headers = headers or {}
self.status_code = status_code
self.text = text
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise requests.HTTPError(f"{self.status_code} error", response=self)
def json(self):
return self._json
class AuthenticatedViewTestCase(TestCase): class AuthenticatedViewTestCase(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
@ -519,6 +537,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 +969,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 +1221,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)
@ -1352,19 +1572,13 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
self.assertIsNone(pessoa.id_aluno_inep) self.assertIsNone(pessoa.id_aluno_inep)
def test_fetch_gennera_pessoas_ingressantes_page_sends_auth_token_header(self) -> None: def test_fetch_gennera_pessoas_ingressantes_page_sends_auth_token_header(self) -> None:
class Response: response = FakeGenneraResponse(
headers = {"X-Total-Count": "0", "X-Total-Pages": "1"} {"data": []}, headers={"X-Total-Count": "0", "X-Total-Pages": "1"}
)
def __enter__(self) -> "Response": with patch(
return self "core.importers._gennera_session.get", return_value=response
) as get_mock:
def __exit__(self, *args: object) -> None:
return None
def read(self) -> bytes:
return b'{"data": []}'
with patch("core.importers.urlopen", return_value=Response()) as urlopen_mock:
result, total_count, total_pages = fetch_gennera_pessoas_ingressantes_page( result, total_count, total_pages = fetch_gennera_pessoas_ingressantes_page(
base_url="http://api.example.test", base_url="http://api.example.test",
token="env-token", token="env-token",
@ -1372,38 +1586,38 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
page=1, page=1,
) )
request = urlopen_mock.call_args.args[0] url = get_mock.call_args.args[0]
headers = get_mock.call_args.kwargs["headers"]
self.assertEqual(result, []) self.assertEqual(result, [])
self.assertEqual(total_count, 0) self.assertEqual(total_count, 0)
self.assertEqual(total_pages, 1) self.assertEqual(total_pages, 1)
self.assertIn( self.assertIn("/api/censup_person_ingressantes?", url)
"/api/censup_person_ingressantes?", self.assertIn("primeiroAno=2026", url)
request.full_url, self.assertEqual(headers["auth_token"], "env-token")
) self.assertEqual(headers["User-Agent"], GENNERA_USER_AGENT)
self.assertIn("primeiroAno=2026", request.full_url)
self.assertEqual(request.get_header("Auth_token"), "env-token")
self.assertEqual(request.get_header("User-agent"), GENNERA_USER_AGENT)
def test_fetch_gennera_vinculos_censup_page_uses_censup_vinculos_endpoint(self) -> None: def test_fetch_gennera_vinculos_censup_page_uses_censup_vinculos_endpoint(self) -> None:
class Response: response = FakeGenneraResponse(
headers = {"X-Total-Count": "1", "X-Total-Pages": "1"} {
"data": [
def __enter__(self) -> "Response": {
return self "cpf": "02542618330",
"turno": "Noite",
def __exit__(self, *args: object) -> None: "referenceYear": "2026/1",
return None "course": "Pedagogia",
"course_code": "123",
def read(self) -> bytes: "ano": 2026,
return ( "carga_horaria_acumulada": 1600,
b'{"data": [{"cpf": "02542618330", "turno": "Noite", ' }
b'"referenceYear": "2026/1", "course": "Pedagogia", ' ]
b'"course_code": "123", "ano": 2026, ' },
b'"carga_horaria_acumulada": 1600}]}' headers={"X-Total-Count": "1", "X-Total-Pages": "1"},
) )
with patch("core.importers.urlopen", return_value=Response()) as urlopen_mock: with patch(
"core.importers._gennera_session.get", return_value=response
) as get_mock:
result, total_count, total_pages = fetch_gennera_vinculos_censup_page( result, total_count, total_pages = fetch_gennera_vinculos_censup_page(
base_url="http://api.example.test", base_url="http://api.example.test",
token="env-token", token="env-token",
@ -1411,15 +1625,63 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
page=1, page=1,
) )
request = urlopen_mock.call_args.args[0] url = get_mock.call_args.args[0]
headers = get_mock.call_args.kwargs["headers"]
self.assertEqual(result[0]["course"], "Pedagogia") self.assertEqual(result[0]["course"], "Pedagogia")
self.assertEqual(total_count, 1) self.assertEqual(total_count, 1)
self.assertEqual(total_pages, 1) self.assertEqual(total_pages, 1)
self.assertIn("/api/censup_vinculos?", request.full_url) self.assertIn("/api/censup_vinculos?", url)
self.assertIn("cpf=02542618330", request.full_url) self.assertIn("cpf=02542618330", url)
self.assertEqual(request.get_header("Auth_token"), "env-token") self.assertEqual(headers["auth_token"], "env-token")
self.assertEqual(request.get_header("User-agent"), GENNERA_USER_AGENT) self.assertEqual(headers["User-Agent"], GENNERA_USER_AGENT)
def test_fetch_gennera_pessoas_ingressantes_page_retries_on_connection_error(
self,
) -> None:
response = FakeGenneraResponse(
{"data": []}, headers={"X-Total-Count": "0", "X-Total-Pages": "1"}
)
with (
patch(
"core.importers._gennera_session.get",
side_effect=[requests.ConnectionError("boom"), response],
) as get_mock,
patch("core.importers.time.sleep") as sleep_mock,
):
result, total_count, total_pages = fetch_gennera_pessoas_ingressantes_page(
base_url="http://api.example.test",
token="env-token",
primeiro_ano=2026,
page=1,
)
self.assertEqual(result, [])
self.assertEqual(total_count, 0)
self.assertEqual(total_pages, 1)
self.assertEqual(get_mock.call_count, 2)
sleep_mock.assert_called_once()
def test_fetch_gennera_pessoas_ingressantes_page_does_not_retry_on_http_error(
self,
) -> None:
response = FakeGenneraResponse({}, status_code=500, text="boom")
with patch(
"core.importers._gennera_session.get", return_value=response
) as get_mock:
with self.assertRaises(ValueError) as ctx:
fetch_gennera_pessoas_ingressantes_page(
base_url="http://api.example.test",
token="env-token",
primeiro_ano=2026,
page=1,
)
self.assertEqual(get_mock.call_count, 1)
self.assertIn("HTTP 500", str(ctx.exception))
self.assertIn("boom", str(ctx.exception))
def test_import_vinculos_from_gennera_uses_course_code_as_codigo_mec(self) -> None: def test_import_vinculos_from_gennera_uses_course_code_as_codigo_mec(self) -> None:
pessoa_com_vinculo = Pessoa.objects.create( pessoa_com_vinculo = Pessoa.objects.create(
@ -1474,6 +1736,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=fetcher, fetcher=fetcher,
) )
@ -1510,6 +1773,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [ fetcher=lambda base_url, token, ano, cpf: [
{ {
"cpf": cpf, "cpf": cpf,
@ -1552,6 +1816,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [ fetcher=lambda base_url, token, ano, cpf: [
{ {
"cpf": cpf, "cpf": cpf,
@ -1591,6 +1856,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [ fetcher=lambda base_url, token, ano, cpf: [
{ {
"cpf": cpf, "cpf": cpf,
@ -1620,6 +1886,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2025, ano=2025,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [ fetcher=lambda base_url, token, ano, cpf: [
{ {
"cpf": cpf, "cpf": cpf,
@ -1637,6 +1904,139 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
self.assertEqual(len(result.errors or []), 1) self.assertEqual(len(result.errors or []), 1)
self.assertIn("Codigo MEC do curso obrigatorio", result.errors[0].message) self.assertIn("Codigo MEC do curso obrigatorio", result.errors[0].message)
def test_import_vinculos_ingressantes_from_gennera_tracks_only_successful_cpfs(
self,
) -> None:
self.create_gennera_pessoa("Aluno Ok", "02542618330")
self.create_gennera_pessoa("Aluno Sem Curso", "11122233344")
Curso.objects.create(
codigo_mec=123,
descricao="Pedagogia",
carga_horaria_total=3200,
)
def fetcher(base_url, token, ano, page, page_size):
rows = [
{
"cpf": "02542618330",
"turno": "Noite",
"referenceYear": "2026/1",
"course": "Pedagogia",
"course_code": "123",
"ano": 2026,
"carga_horaria_acumulada": 800,
},
{
"cpf": "11122233344",
"turno": "Noite",
"referenceYear": "2026/1",
"course": "Curso Inexistente",
"course_code": None,
"ano": 2026,
"carga_horaria_acumulada": 100,
},
{
"cpf": "99988877766",
"turno": "Noite",
"referenceYear": "2026/1",
"course": "Pedagogia",
"course_code": "123",
"ano": 2026,
"carga_horaria_acumulada": 100,
},
]
return rows, len(rows), 1
result = import_vinculos_ingressantes_from_gennera(
ano=2026,
base_url="http://api.example.test",
token="token",
fetcher=fetcher,
)
self.assertEqual(result.cpfs_resolvidos, {"02542618330"})
self.assertEqual(result.pessoas_nao_encontradas, 1)
error_identifiers = {error.identifier for error in result.errors}
self.assertIn("99988877766", error_identifiers)
self.assertIn("11122233344", error_identifiers)
def test_import_vinculos_from_gennera_skips_per_cpf_refetch_for_bulk_resolved_cpf(
self,
) -> None:
self.create_gennera_pessoa("Aluno Bulk", "02542618330")
self.create_gennera_pessoa("Aluno Per Cpf", "11122233344")
Curso.objects.create(
codigo_mec=123,
descricao="Pedagogia",
carga_horaria_total=3200,
)
def ingressantes_fetcher(base_url, token, ano, page, page_size):
rows = [
{
"cpf": "02542618330",
"turno": "Noite",
"referenceYear": "2026/1",
"course": "Pedagogia",
"course_code": "123",
"ano": 2026,
"carga_horaria_acumulada": 800,
}
]
return rows, len(rows), 1
requested_cpfs = []
def fetcher(base_url, token, ano, cpf):
requested_cpfs.append(cpf)
return []
import_vinculos_from_gennera(
ano=2026,
base_url="http://api.example.test",
token="token",
ingressantes_fetcher=ingressantes_fetcher,
fetcher=fetcher,
)
self.assertNotIn("02542618330", requested_cpfs)
self.assertIn("11122233344", requested_cpfs)
def test_import_vinculos_from_gennera_retries_bulk_cpf_that_failed_ingressantes_step(
self,
) -> None:
self.create_gennera_pessoa("Aluno Falha Bulk", "02542618330")
def ingressantes_fetcher(base_url, token, ano, page, page_size):
rows = [
{
"cpf": "02542618330",
"turno": "Noite",
"referenceYear": "2026/1",
"course": "Curso Inexistente",
"course_code": None,
"ano": 2026,
"carga_horaria_acumulada": 100,
}
]
return rows, len(rows), 1
requested_cpfs = []
def fetcher(base_url, token, ano, cpf):
requested_cpfs.append(cpf)
return []
import_vinculos_from_gennera(
ano=2026,
base_url="http://api.example.test",
token="token",
ingressantes_fetcher=ingressantes_fetcher,
fetcher=fetcher,
)
self.assertIn("02542618330", requested_cpfs)
def test_import_vinculos_from_gennera_keeps_existing_formado(self) -> None: def test_import_vinculos_from_gennera_keeps_existing_formado(self) -> None:
pessoa = self.create_gennera_pessoa("Aluno Formado", "02542618330") pessoa = self.create_gennera_pessoa("Aluno Formado", "02542618330")
curso = Curso.objects.create( curso = Curso.objects.create(
@ -1672,6 +2072,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [ fetcher=lambda base_url, token, ano, cpf: [
{ {
"cpf": cpf, "cpf": cpf,
@ -1688,7 +2089,52 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
vinculo = Vinculo.objects.get(pessoa=pessoa, curso=curso, ano_censo=2026) vinculo = Vinculo.objects.get(pessoa=pessoa, curso=curso, ano_censo=2026)
self.assertEqual(vinculo.situacao, Vinculo.Situacao.FORMADO) self.assertEqual(vinculo.situacao, Vinculo.Situacao.FORMADO)
self.assertEqual(vinculo.carga_horaria_integralizada, 1600) self.assertEqual(vinculo.carga_horaria_integralizada, 3200)
def test_import_carga_horaria_from_gennera_skips_formado_vinculo(self) -> None:
pessoa = self.create_gennera_pessoa("Aluno Formado", "02542618330")
curso = Curso.objects.create(
codigo_mec=123,
descricao="Pedagogia",
carga_horaria_total=3200,
)
vinculo = Vinculo.objects.create(
pessoa=pessoa,
ano_censo_id=2026,
matricula="MAT-1",
periodo_referencia=Vinculo.PeriodoReferencia.PRIMEIRO_SEMESTRE,
curso=curso,
situacao=Vinculo.Situacao.FORMADO,
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=3200,
ingressou_acao_afirmativa=False,
)
result = import_carga_horaria_from_gennera(
base_url="http://api.example.test",
token="token",
fetcher=lambda base_url, token, matricula, page_size: [
{"ano": 2026, "carga_horaria": 999}
],
)
vinculo.refresh_from_db()
self.assertEqual(vinculo.carga_horaria_integralizada, 3200)
self.assertEqual(result.vinculos_atualizados, 0)
def test_import_vinculos_from_gennera_sets_desvinculado_when_no_remote_vinculo(self) -> None: def test_import_vinculos_from_gennera_sets_desvinculado_when_no_remote_vinculo(self) -> None:
pessoa = self.create_gennera_pessoa("Aluno Sem Remoto", "02542618330") pessoa = self.create_gennera_pessoa("Aluno Sem Remoto", "02542618330")
@ -1725,6 +2171,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [], fetcher=lambda base_url, token, ano, cpf: [],
) )
@ -1768,6 +2215,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
ano=2026, ano=2026,
base_url="http://api.example.test", base_url="http://api.example.test",
token="token", token="token",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
fetcher=lambda base_url, token, ano, cpf: [ fetcher=lambda base_url, token, ano, cpf: [
{ {
"cpf": cpf, "cpf": cpf,
@ -1786,6 +2234,216 @@ 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",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
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",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
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",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
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",
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
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,

View File

@ -113,6 +113,7 @@ CRUD_CONFIGS = {
"ead", "ead",
"polo", "polo",
"carga_horaria_total", "carga_horaria_total",
"exportar",
), ),
search_fields=( search_fields=(
("codigo_mec", "int"), ("codigo_mec", "int"),
@ -270,6 +271,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 +480,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 +518,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 +544,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 +578,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 +602,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 +695,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:

View File

@ -8,4 +8,5 @@ dependencies = [
"django>=5.2,<6.0", "django>=5.2,<6.0",
"psycopg[binary]>=3.2,<4.0", "psycopg[binary]>=3.2,<4.0",
"celery[redis]>=5.4,<6.0", "celery[redis]>=5.4,<6.0",
"requests>=2.31,<3.0",
] ]

View File

@ -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">

View File

@ -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>

105
uv.lock
View File

@ -31,6 +31,7 @@ dependencies = [
{ name = "celery", extra = ["redis"] }, { name = "celery", extra = ["redis"] },
{ name = "django" }, { name = "django" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "requests" },
] ]
[package.metadata] [package.metadata]
@ -38,6 +39,7 @@ requires-dist = [
{ name = "celery", extras = ["redis"], specifier = ">=5.4,<6.0" }, { name = "celery", extras = ["redis"], specifier = ">=5.4,<6.0" },
{ name = "django", specifier = ">=5.2,<6.0" }, { name = "django", specifier = ">=5.2,<6.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4.0" },
{ name = "requests", specifier = ">=2.31,<3.0" },
] ]
[[package]] [[package]]
@ -74,6 +76,76 @@ redis = [
{ name = "kombu", extra = ["redis"] }, { name = "kombu", extra = ["redis"] },
] ]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" },
{ url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" },
{ url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" },
{ url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" },
{ url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" },
{ url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" },
{ url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" },
{ url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" },
{ url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" },
{ url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" },
{ url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" },
{ url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" },
{ url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" },
{ url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" },
{ url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" },
{ url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" },
{ url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" },
{ url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" },
{ url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" },
{ url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" },
{ url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" },
{ url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" },
{ url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" },
{ url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" },
{ url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" },
{ url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" },
{ url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" },
{ url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" },
{ url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" },
{ url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" },
{ url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" },
{ url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" },
{ url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" },
{ url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" },
{ url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" },
{ url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.4.2" version = "8.4.2"
@ -146,6 +218,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" }, { url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
] ]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]] [[package]]
name = "kombu" name = "kombu"
version = "5.6.2" version = "5.6.2"
@ -266,6 +347,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
] ]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@ -314,6 +410,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" },
] ]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]] [[package]]
name = "vine" name = "vine"
version = "5.1.0" version = "5.1.0"