Compare commits
3 Commits
1e72e0239a
...
3eca55578d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eca55578d | |||
| d96bea6755 | |||
| 36d3386d2d |
@ -8,7 +8,7 @@ POSTGRES_PASSWORD=autocensup
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
GENNERA_API_BASE_URL=http://gennera.solucaoti.net.br
|
||||
GENNERA_LOCAL_BASE_URL=http://gennera.solucaoti.net.br
|
||||
API_TOKEN=
|
||||
GENNERA_LOCAL_AUTH_TOKEN=
|
||||
CENSUP_IES_CODIGO=
|
||||
|
||||
@ -33,7 +33,7 @@ Variaveis principais:
|
||||
- `DJANGO_ALLOWED_HOSTS`: hosts permitidos.
|
||||
- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`: conexao PostgreSQL.
|
||||
- `DATABASE_URL`: alternativa para configurar o banco por URL.
|
||||
- `GENNERA_API_BASE_URL`: URL base da API Gennera.
|
||||
- `GENNERA_LOCAL_BASE_URL`: URL base da API Gennera.
|
||||
- `API_TOKEN`: token Bearer usado nas chamadas a API Gennera.
|
||||
|
||||
Comandos basicos:
|
||||
|
||||
@ -137,7 +137,7 @@ LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "home"
|
||||
LOGOUT_REDIRECT_URL = "login"
|
||||
|
||||
GENNERA_API_BASE_URL = os.getenv("GENNERA_API_BASE_URL", "http://gennera.solucaoti.net.br")
|
||||
GENNERA_LOCAL_BASE_URL = os.getenv("GENNERA_LOCAL_BASE_URL", "http://gennera.solucaoti.net.br")
|
||||
GENNERA_API_TOKEN = os.getenv("API_TOKEN", "")
|
||||
GENNERA_LOCAL_AUTH_TOKEN = os.getenv("GENNERA_LOCAL_AUTH_TOKEN", "")
|
||||
|
||||
|
||||
@ -6,14 +6,13 @@ from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import itertools
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Iterable, Iterator
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import requests
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
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
|
||||
class ImportCursosResult:
|
||||
created: int = 0
|
||||
@ -104,6 +130,7 @@ class ImportVinculosApiResult:
|
||||
pessoas_without_vinculo: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
situacao_nao_informada_corrigidos: int = 0
|
||||
errors: list[ImportVinculoApiError] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@ -115,6 +142,22 @@ class ImportVinculosApiResult:
|
||||
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
|
||||
class ImportCargaHorariaError:
|
||||
identifier: str
|
||||
@ -241,25 +284,23 @@ def fetch_gennera_pessoas_ingressantes_page(
|
||||
"order": order,
|
||||
}
|
||||
url = f"{base_url.rstrip('/')}/api/censup_person_ingressantes?{urlencode(query)}"
|
||||
request = Request(
|
||||
url,
|
||||
headers={
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"auth_token": token,
|
||||
"User-Agent": GENNERA_USER_AGENT,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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_pages = int(response.headers.get("X-Total-Pages", "1"))
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"API Gennera retornou HTTP {exc.code}: {detail}") from exc
|
||||
except URLError as exc:
|
||||
raise ValueError(f"Erro ao conectar na API Gennera: {exc.reason}") from exc
|
||||
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):
|
||||
@ -307,25 +348,23 @@ def fetch_gennera_vinculos_censup_page(
|
||||
if ano is not None:
|
||||
query["ano"] = ano
|
||||
url = f"{base_url.rstrip('/')}/api/censup_vinculos?{urlencode(query)}"
|
||||
request = Request(
|
||||
url,
|
||||
headers={
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"auth_token": token,
|
||||
"User-Agent": GENNERA_USER_AGENT,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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_pages = int(response.headers.get("X-Total-Pages", "1"))
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"API Gennera retornou HTTP {exc.code}: {detail}") from exc
|
||||
except URLError as exc:
|
||||
raise ValueError(f"Erro ao conectar na API Gennera: {exc.reason}") from exc
|
||||
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):
|
||||
@ -355,6 +394,63 @@ def fetch_gennera_vinculos_censup(
|
||||
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(
|
||||
base_url: str,
|
||||
token: str,
|
||||
@ -373,25 +469,23 @@ def fetch_gennera_carga_horaria_page(
|
||||
"order": order,
|
||||
}
|
||||
url = f"{base_url.rstrip('/')}/api/censup_carga_horaria?{urlencode(query)}"
|
||||
request = Request(
|
||||
url,
|
||||
headers={
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"auth_token": token,
|
||||
"User-Agent": GENNERA_USER_AGENT,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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_pages = int(response.headers.get("X-Total-Pages", "1"))
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"API Gennera retornou HTTP {exc.code}: {detail}") from exc
|
||||
except URLError as exc:
|
||||
raise ValueError(f"Erro ao conectar na API Gennera: {exc.reason}") from exc
|
||||
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):
|
||||
@ -488,6 +582,8 @@ def import_carga_horaria_from_gennera(
|
||||
|
||||
atualizados = []
|
||||
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)
|
||||
if carga is None or carga == vinculo.carga_horaria_integralizada:
|
||||
continue
|
||||
@ -548,6 +644,114 @@ def import_pessoas_ingressantes_from_gennera(
|
||||
return result
|
||||
|
||||
|
||||
def corrigir_situacao_nao_informada(
|
||||
ano: int,
|
||||
base_url: str,
|
||||
token: str,
|
||||
max_workers: int = 4,
|
||||
) -> int:
|
||||
vinculos = list(
|
||||
Vinculo.objects.filter(
|
||||
ano_censo=ano,
|
||||
situacao=Vinculo.Situacao.NAO_INFORMADA,
|
||||
atualizado_manualmente=False,
|
||||
)
|
||||
.exclude(matricula="")
|
||||
.select_related("curso")
|
||||
)
|
||||
if not vinculos:
|
||||
return 0
|
||||
|
||||
matriculas = {vinculo.matricula for vinculo in vinculos}
|
||||
carga_por_matricula: dict[str, dict[int, int]] = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_matricula = {
|
||||
executor.submit(fetch_gennera_carga_horaria, base_url, token, matricula): matricula
|
||||
for matricula in matriculas
|
||||
}
|
||||
for future in as_completed(future_to_matricula):
|
||||
matricula = future_to_matricula[future]
|
||||
try:
|
||||
rows = future.result()
|
||||
except Exception:
|
||||
continue
|
||||
carga_por_matricula[matricula] = carga_horaria_por_ano(rows)
|
||||
|
||||
corrigidos = 0
|
||||
for vinculo in vinculos:
|
||||
by_ano = carga_por_matricula.get(vinculo.matricula, {})
|
||||
carga = carga_horaria_ate_ano(by_ano, vinculo.ano_censo_id)
|
||||
if carga is not None:
|
||||
vinculo.carga_horaria_integralizada = carga
|
||||
vinculo.situacao = Vinculo.Situacao.DESVINCULADO
|
||||
vinculo.save(update_fields=["situacao", "carga_horaria_integralizada"])
|
||||
corrigidos += 1
|
||||
|
||||
return corrigidos
|
||||
|
||||
|
||||
def import_vinculos_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(
|
||||
ano: int,
|
||||
base_url: str,
|
||||
@ -555,13 +759,34 @@ def import_vinculos_from_gennera(
|
||||
fetcher: Callable[[str, str, int, str], list[dict[str, Any]]],
|
||||
progress_callback: Callable[[int, int, str, list[Vinculo]], None] | None = None,
|
||||
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:
|
||||
if not token:
|
||||
raise ValueError("Token da API Gennera nao configurado.")
|
||||
|
||||
AnoCenso.objects.get_or_create(ano=ano)
|
||||
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)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
result.situacao_nao_informada_corrigidos = corrigir_situacao_nao_informada(
|
||||
ano=ano, base_url=base_url, token=token, max_workers=max_workers
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@ -701,12 +929,18 @@ def import_gennera_vinculo_group(
|
||||
for item in rows
|
||||
)
|
||||
existing = Vinculo.objects.filter(pessoa=pessoa, curso=curso, ano_censo=ano).first()
|
||||
if existing is not None and existing.atualizado_manualmente:
|
||||
return existing, False
|
||||
|
||||
situacao = gennera_vinculo_situacao(
|
||||
rows=rows,
|
||||
curso=curso,
|
||||
carga_horaria_integralizada=carga_horaria_integralizada,
|
||||
existing=existing,
|
||||
)
|
||||
if existing is not None and existing.situacao == Vinculo.Situacao.FORMADO:
|
||||
carga_horaria_integralizada = existing.carga_horaria_integralizada
|
||||
|
||||
defaults = {
|
||||
"periodo_referencia": periodo_referencia,
|
||||
"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:
|
||||
return Vinculo.objects.filter(pessoa=pessoa, ano_censo=ano).exclude(
|
||||
situacao=Vinculo.Situacao.FORMADO
|
||||
).update(situacao=Vinculo.Situacao.DESVINCULADO)
|
||||
return Vinculo.objects.filter(
|
||||
pessoa=pessoa, ano_censo=ano, atualizado_manualmente=False
|
||||
).exclude(situacao=Vinculo.Situacao.FORMADO).update(
|
||||
situacao=Vinculo.Situacao.DESVINCULADO
|
||||
)
|
||||
|
||||
|
||||
def import_alunos(lines: Iterable[str], ano_censo: int) -> ImportAlunosResult:
|
||||
@ -839,7 +1075,7 @@ def import_pessoa_record(fields: list[str]) -> tuple[Pessoa, bool]:
|
||||
cpf=cpf,
|
||||
defaults=defaults,
|
||||
)
|
||||
if not created:
|
||||
if not created and not pessoa.atualizado_manualmente:
|
||||
for field, value in defaults.items():
|
||||
if field in {"nome", "data_nascimento"}:
|
||||
continue
|
||||
@ -930,6 +1166,12 @@ def import_vinculo_record(
|
||||
"justificativa": parse_optional_int(fields[72]),
|
||||
}
|
||||
|
||||
existing = Vinculo.objects.filter(
|
||||
pessoa=pessoa, curso=curso, ano_censo_id=ano_censo
|
||||
).first()
|
||||
if existing is not None and existing.atualizado_manualmente:
|
||||
return existing, False
|
||||
|
||||
with transaction.atomic():
|
||||
vinculo, created = Vinculo.objects.update_or_create(
|
||||
pessoa=pessoa,
|
||||
@ -997,12 +1239,13 @@ def export_vinculo_record(vinculo: Vinculo) -> str:
|
||||
if vinculo.situacao == Vinculo.Situacao.NAO_INFORMADA
|
||||
else str(vinculo.situacao)
|
||||
)
|
||||
polo = vinculo.polo or vinculo.curso.polo
|
||||
fields = [
|
||||
"42",
|
||||
vinculo.matricula,
|
||||
_fmt_optional_int(vinculo.periodo_referencia),
|
||||
_fmt_fk_pk(vinculo.curso_id),
|
||||
_fmt_fk_pk(vinculo.polo.codigo if vinculo.polo_id else None),
|
||||
_fmt_fk_pk(polo.codigo if polo else None),
|
||||
_fmt_optional_int(vinculo.turno),
|
||||
situacao,
|
||||
_fmt_fk_pk(vinculo.curso_origem_id),
|
||||
|
||||
@ -39,7 +39,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
result = import_carga_horaria_from_gennera(
|
||||
base_url=settings.GENNERA_API_BASE_URL,
|
||||
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||
token=token,
|
||||
page_size=page_size,
|
||||
max_workers=threads,
|
||||
|
||||
@ -26,7 +26,7 @@ class Command(BaseCommand):
|
||||
|
||||
result = import_pessoas_ingressantes_from_gennera(
|
||||
primeiro_ano=primeiro_ano,
|
||||
base_url=settings.GENNERA_API_BASE_URL,
|
||||
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||
token=token,
|
||||
page_size=page_size,
|
||||
progress_callback=report_progress,
|
||||
|
||||
@ -41,11 +41,12 @@ class Command(BaseCommand):
|
||||
|
||||
result = import_vinculos_from_gennera(
|
||||
ano=ano,
|
||||
base_url=settings.GENNERA_API_BASE_URL,
|
||||
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||
token=token,
|
||||
fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size),
|
||||
progress_callback=report_progress,
|
||||
max_workers=threads,
|
||||
ingressantes_page_size=page_size,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
@ -55,6 +56,8 @@ class Command(BaseCommand):
|
||||
f"{result.pessoas_without_vinculo} sem vinculo no ano, "
|
||||
f"{result.created} vinculos criados, "
|
||||
f"{result.updated} vinculos atualizados, "
|
||||
f"{result.situacao_nao_informada_corrigidos} corrigidos de "
|
||||
"situacao nao informada para desvinculado, "
|
||||
f"{len(result.errors)} com erro."
|
||||
)
|
||||
)
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
33
core/migrations/0014_curso_exportar.py
Normal file
33
core/migrations/0014_curso_exportar.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@ -68,6 +68,7 @@ class Curso(models.Model):
|
||||
situacao_funcionamento_curso = models.CharField(max_length=100, blank=True)
|
||||
licenciatura = models.BooleanField(default=False)
|
||||
ead = models.BooleanField(default=False)
|
||||
exportar = models.BooleanField(default=True)
|
||||
carga_horaria_total = models.PositiveIntegerField()
|
||||
polo = models.ForeignKey(
|
||||
"Polo",
|
||||
@ -201,6 +202,7 @@ class Pessoa(models.Model):
|
||||
blank=True,
|
||||
related_name="pessoas",
|
||||
)
|
||||
atualizado_manualmente = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "pessoa"
|
||||
@ -393,6 +395,7 @@ class Vinculo(models.Model):
|
||||
reserva_olimpiadas_conhecimento = models.BooleanField(null=True, blank=True)
|
||||
reserva_outros = models.BooleanField(null=True, blank=True)
|
||||
justificativa = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
atualizado_manualmente = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "vinculo"
|
||||
|
||||
@ -30,7 +30,7 @@ def import_pessoas_ingressantes_task(
|
||||
|
||||
result = import_pessoas_ingressantes_from_gennera(
|
||||
primeiro_ano=primeiro_ano,
|
||||
base_url=settings.GENNERA_API_BASE_URL,
|
||||
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
||||
page_size=page_size,
|
||||
progress_callback=report_progress,
|
||||
@ -60,17 +60,19 @@ def import_vinculos_censup_task(
|
||||
|
||||
result = import_vinculos_from_gennera(
|
||||
ano=ano,
|
||||
base_url=settings.GENNERA_API_BASE_URL,
|
||||
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
||||
fetcher=partial(fetch_gennera_vinculos_censup, page_size=page_size),
|
||||
progress_callback=report_progress,
|
||||
max_workers=threads,
|
||||
ingressantes_page_size=page_size,
|
||||
)
|
||||
return {
|
||||
"pessoas_processed": result.pessoas_processed,
|
||||
"pessoas_without_vinculo": result.pessoas_without_vinculo,
|
||||
"created": result.created,
|
||||
"updated": result.updated,
|
||||
"situacao_nao_informada_corrigidos": result.situacao_nao_informada_corrigidos,
|
||||
"errors": len(result.errors),
|
||||
}
|
||||
|
||||
@ -90,7 +92,7 @@ def import_carga_horaria_censup_task(
|
||||
)
|
||||
|
||||
result = import_carga_horaria_from_gennera(
|
||||
base_url=settings.GENNERA_API_BASE_URL,
|
||||
base_url=settings.GENNERA_LOCAL_BASE_URL,
|
||||
token=settings.GENNERA_LOCAL_AUTH_TOKEN,
|
||||
page_size=page_size,
|
||||
max_workers=threads,
|
||||
|
||||
742
core/tests.py
742
core/tests.py
@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
from django.db import IntegrityError
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@ -18,11 +19,13 @@ from .importers import (
|
||||
fetch_gennera_pessoas_ingressantes_page,
|
||||
fetch_gennera_vinculos_censup_page,
|
||||
import_alunos,
|
||||
import_carga_horaria_from_gennera,
|
||||
import_cursos,
|
||||
import_pessoa_record,
|
||||
import_pessoas_ingressantes_from_gennera,
|
||||
import_vinculo_record,
|
||||
import_vinculos_from_gennera,
|
||||
import_vinculos_ingressantes_from_gennera,
|
||||
import_polos,
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
def setUp(self) -> None:
|
||||
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.assertFalse(UF.objects.filter(codigo=22).exists())
|
||||
|
||||
def test_crud_list_sorts_by_clicked_column(self) -> None:
|
||||
UF.objects.create(codigo=1, nome="Zeta")
|
||||
UF.objects.create(codigo=2, nome="Alfa")
|
||||
|
||||
response_asc = self.client.get(
|
||||
reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "nome", "dir": "asc"}
|
||||
)
|
||||
response_desc = self.client.get(
|
||||
reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "nome", "dir": "desc"}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[uf.nome for uf in response_asc.context["page_obj"].object_list],
|
||||
["Alfa", "Zeta"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[uf.nome for uf in response_desc.context["page_obj"].object_list],
|
||||
["Zeta", "Alfa"],
|
||||
)
|
||||
|
||||
def test_crud_list_ignores_unknown_sort_field(self) -> None:
|
||||
UF.objects.create(codigo=1, nome="Piaui")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "bogus"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["sort"], "")
|
||||
|
||||
def test_crud_list_pagination_links_preserve_sort(self) -> None:
|
||||
for index in range(25):
|
||||
UF.objects.create(codigo=index + 1, nome=f"UF {index:02d}")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("crud_list", kwargs={"slug": "ufs"}), {"sort": "nome", "dir": "desc"}
|
||||
)
|
||||
|
||||
self.assertContains(response, "sort=nome&dir=desc&page=2")
|
||||
|
||||
def test_crud_list_sorts_vinculos_by_related_pessoa_name(self) -> None:
|
||||
pais = Pais.objects.create(codigo="BRA", nome="Brasil")
|
||||
curso = Curso.objects.create(
|
||||
codigo_mec=123, descricao="Pedagogia", carga_horaria_total=3200
|
||||
)
|
||||
for nome, cpf in (("Zeta Aluno", "11111111111"), ("Alfa Aluno", "22222222222")):
|
||||
pessoa = Pessoa.objects.create(
|
||||
nome=nome,
|
||||
cpf=cpf,
|
||||
data_nascimento=date(2000, 1, 1),
|
||||
cor_raca=Pessoa.CorRaca.PARDA,
|
||||
nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA,
|
||||
pais_origem=pais,
|
||||
aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO,
|
||||
tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PUBLICA,
|
||||
)
|
||||
Vinculo.objects.create(
|
||||
pessoa=pessoa,
|
||||
ano_censo_id=timezone.localdate().year - 1,
|
||||
curso=curso,
|
||||
situacao=Vinculo.Situacao.CURSANDO,
|
||||
semestre_ingresso="012025",
|
||||
carga_horaria_total_aluno=3200,
|
||||
carga_horaria_integralizada=0,
|
||||
**REQUIRED_VINCULO_BOOL_DEFAULTS,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("crud_list", kwargs={"slug": "vinculos"}), {"sort": "pessoa", "dir": "asc"}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[v.pessoa.nome for v in response.context["page_obj"].object_list],
|
||||
["Alfa Aluno", "Zeta Aluno"],
|
||||
)
|
||||
|
||||
def test_crud_update_marks_pessoa_as_atualizado_manualmente(self) -> None:
|
||||
pais = Pais.objects.create(codigo="BRA", nome="Brasil")
|
||||
pessoa = Pessoa.objects.create(
|
||||
nome="Maria Silva",
|
||||
cpf="12345678901",
|
||||
cor_raca=Pessoa.CorRaca.PARDA,
|
||||
nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA,
|
||||
pais_origem=pais,
|
||||
aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO,
|
||||
tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PUBLICA,
|
||||
)
|
||||
self.assertFalse(pessoa.atualizado_manualmente)
|
||||
|
||||
form_url = reverse("crud_update", kwargs={"slug": "pessoas", "pk": pessoa.pk})
|
||||
form_page = self.client.get(form_url)
|
||||
self.assertNotContains(form_page, "atualizado_manualmente")
|
||||
|
||||
response = self.client.post(
|
||||
form_url,
|
||||
{
|
||||
"nome": "Maria Silva Editada",
|
||||
"cpf": "12345678901",
|
||||
"cor_raca": Pessoa.CorRaca.PARDA,
|
||||
"nacionalidade": Pessoa.Nacionalidade.BRASILEIRA_NATA,
|
||||
"pais_origem": pais.pk,
|
||||
"aluno_com_deficiencia": Pessoa.SimNaoNaoDispoe.NAO,
|
||||
"tipo_escola_ensino_medio": Pessoa.TipoEscolaEnsinoMedio.PUBLICA,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertRedirects(
|
||||
response,
|
||||
reverse("crud_detail", kwargs={"slug": "pessoas", "pk": pessoa.pk}),
|
||||
)
|
||||
pessoa.refresh_from_db()
|
||||
self.assertEqual(pessoa.nome, "Maria Silva Editada")
|
||||
self.assertTrue(pessoa.atualizado_manualmente)
|
||||
|
||||
def test_pending_required_fields_view_lists_courses_without_carga(self) -> None:
|
||||
Curso.objects.create(
|
||||
codigo_mec=123,
|
||||
@ -837,6 +969,77 @@ class ImportAlunosTests(AuthenticatedViewTestCase):
|
||||
self.assertEqual(pessoa.data_nascimento, date(1999, 1, 1))
|
||||
self.assertEqual(pessoa.cor_raca, Pessoa.CorRaca.PARDA)
|
||||
|
||||
def test_import_alunos_does_not_overwrite_manually_edited_pessoa(self) -> None:
|
||||
Pessoa.objects.create(
|
||||
nome="Nome Original",
|
||||
cpf="12345678901",
|
||||
data_nascimento=date(1999, 1, 1),
|
||||
cor_raca=Pessoa.CorRaca.BRANCA,
|
||||
nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA,
|
||||
pais_origem=self.pais,
|
||||
aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO,
|
||||
tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PRIVADA,
|
||||
atualizado_manualmente=True,
|
||||
)
|
||||
|
||||
result = import_alunos(
|
||||
[self.pessoa_line(nome="Nome Novo", nascimento="02022000")],
|
||||
ano_censo=2025,
|
||||
)
|
||||
|
||||
pessoa = Pessoa.objects.get(cpf="12345678901")
|
||||
|
||||
self.assertEqual(result.pessoas_updated, 1)
|
||||
self.assertEqual(pessoa.cor_raca, Pessoa.CorRaca.BRANCA)
|
||||
self.assertEqual(pessoa.tipo_escola_ensino_medio, Pessoa.TipoEscolaEnsinoMedio.PRIVADA)
|
||||
|
||||
def test_import_alunos_does_not_overwrite_manually_edited_vinculo(self) -> None:
|
||||
pessoa = Pessoa.objects.create(
|
||||
nome="Maria Silva",
|
||||
cpf="12345678901",
|
||||
data_nascimento=date(2000, 1, 1),
|
||||
cor_raca=Pessoa.CorRaca.PARDA,
|
||||
nacionalidade=Pessoa.Nacionalidade.BRASILEIRA_NATA,
|
||||
pais_origem=self.pais,
|
||||
aluno_com_deficiencia=Pessoa.SimNaoNaoDispoe.NAO,
|
||||
tipo_escola_ensino_medio=Pessoa.TipoEscolaEnsinoMedio.PUBLICA,
|
||||
)
|
||||
Vinculo.objects.create(
|
||||
pessoa=pessoa,
|
||||
ano_censo_id=2025,
|
||||
curso=self.curso,
|
||||
situacao=Vinculo.Situacao.FORMADO,
|
||||
semestre_ingresso="012020",
|
||||
carga_horaria_total_aluno=3200,
|
||||
carga_horaria_integralizada=3200,
|
||||
atualizado_manualmente=True,
|
||||
ingresso_vestibular=True,
|
||||
ingresso_enem=False,
|
||||
ingresso_avaliacao_seriada=False,
|
||||
ingresso_selecao_simplificada=False,
|
||||
ingresso_egresso_bi_li=False,
|
||||
ingresso_pec_g=False,
|
||||
ingresso_transferencia_ex_officio=False,
|
||||
ingresso_decisao_judicial=False,
|
||||
ingresso_vagas_remanescentes=False,
|
||||
ingresso_programas_especiais=False,
|
||||
apoio_social=False,
|
||||
atividade_extracurricular=False,
|
||||
ingressou_acao_afirmativa=False,
|
||||
)
|
||||
|
||||
import_alunos(
|
||||
[
|
||||
self.pessoa_line(nome="Maria Silva", nascimento="01012000"),
|
||||
self.vinculo_line(codigo_curso=123, carga_horaria=1),
|
||||
],
|
||||
ano_censo=2025,
|
||||
)
|
||||
|
||||
vinculo = Vinculo.objects.get(pessoa=pessoa, curso=self.curso, ano_censo=2025)
|
||||
self.assertEqual(vinculo.situacao, Vinculo.Situacao.FORMADO)
|
||||
self.assertEqual(vinculo.carga_horaria_integralizada, 3200)
|
||||
|
||||
def test_import_alunos_view_accepts_upload(self) -> None:
|
||||
uploaded = SimpleUploadedFile(
|
||||
"aluno_exportacao.txt",
|
||||
@ -1018,6 +1221,23 @@ class ExportAlunoRecordTests(TestCase):
|
||||
|
||||
self.assertEqual(line.split("|")[4], str(polo.codigo))
|
||||
|
||||
def test_export_vinculo_record_falls_back_to_curso_polo_when_vinculo_polo_is_none(
|
||||
self,
|
||||
) -> None:
|
||||
# Vinculos importados via API Gennera nunca preenchem Vinculo.polo diretamente
|
||||
# (ver import_gennera_vinculo_group) -- o polo EaD fica so em Curso.polo.
|
||||
Polo.objects.create(codigo=999, descricao="Polo Descartavel")
|
||||
polo_do_curso = Polo.objects.create(codigo=777, descricao="Polo do Curso EaD")
|
||||
self.curso.ead = True
|
||||
self.curso.polo = polo_do_curso
|
||||
self.curso.save(update_fields=["ead", "polo"])
|
||||
|
||||
pessoa = self.make_pessoa()
|
||||
vinculo = self.make_vinculo(pessoa, polo=None)
|
||||
line = export_vinculo_record(vinculo)
|
||||
|
||||
self.assertEqual(line.split("|")[4], str(polo_do_curso.codigo))
|
||||
|
||||
def test_export_vinculo_record_writes_minus_one_for_nao_informada_situacao(self) -> None:
|
||||
pessoa = self.make_pessoa()
|
||||
vinculo = self.make_vinculo(pessoa, situacao=Vinculo.Situacao.NAO_INFORMADA)
|
||||
@ -1352,19 +1572,13 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
self.assertIsNone(pessoa.id_aluno_inep)
|
||||
|
||||
def test_fetch_gennera_pessoas_ingressantes_page_sends_auth_token_header(self) -> None:
|
||||
class Response:
|
||||
headers = {"X-Total-Count": "0", "X-Total-Pages": "1"}
|
||||
response = FakeGenneraResponse(
|
||||
{"data": []}, headers={"X-Total-Count": "0", "X-Total-Pages": "1"}
|
||||
)
|
||||
|
||||
def __enter__(self) -> "Response":
|
||||
return self
|
||||
|
||||
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:
|
||||
with patch(
|
||||
"core.importers._gennera_session.get", return_value=response
|
||||
) as get_mock:
|
||||
result, total_count, total_pages = fetch_gennera_pessoas_ingressantes_page(
|
||||
base_url="http://api.example.test",
|
||||
token="env-token",
|
||||
@ -1372,38 +1586,38 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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(total_count, 0)
|
||||
self.assertEqual(total_pages, 1)
|
||||
self.assertIn(
|
||||
"/api/censup_person_ingressantes?",
|
||||
request.full_url,
|
||||
)
|
||||
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)
|
||||
self.assertIn("/api/censup_person_ingressantes?", url)
|
||||
self.assertIn("primeiroAno=2026", url)
|
||||
self.assertEqual(headers["auth_token"], "env-token")
|
||||
self.assertEqual(headers["User-Agent"], GENNERA_USER_AGENT)
|
||||
|
||||
def test_fetch_gennera_vinculos_censup_page_uses_censup_vinculos_endpoint(self) -> None:
|
||||
class Response:
|
||||
headers = {"X-Total-Count": "1", "X-Total-Pages": "1"}
|
||||
|
||||
def __enter__(self) -> "Response":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return (
|
||||
b'{"data": [{"cpf": "02542618330", "turno": "Noite", '
|
||||
b'"referenceYear": "2026/1", "course": "Pedagogia", '
|
||||
b'"course_code": "123", "ano": 2026, '
|
||||
b'"carga_horaria_acumulada": 1600}]}'
|
||||
response = FakeGenneraResponse(
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"cpf": "02542618330",
|
||||
"turno": "Noite",
|
||||
"referenceYear": "2026/1",
|
||||
"course": "Pedagogia",
|
||||
"course_code": "123",
|
||||
"ano": 2026,
|
||||
"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(
|
||||
base_url="http://api.example.test",
|
||||
token="env-token",
|
||||
@ -1411,15 +1625,63 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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(total_count, 1)
|
||||
self.assertEqual(total_pages, 1)
|
||||
self.assertIn("/api/censup_vinculos?", request.full_url)
|
||||
self.assertIn("cpf=02542618330", request.full_url)
|
||||
self.assertEqual(request.get_header("Auth_token"), "env-token")
|
||||
self.assertEqual(request.get_header("User-agent"), GENNERA_USER_AGENT)
|
||||
self.assertIn("/api/censup_vinculos?", url)
|
||||
self.assertIn("cpf=02542618330", url)
|
||||
self.assertEqual(headers["auth_token"], "env-token")
|
||||
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:
|
||||
pessoa_com_vinculo = Pessoa.objects.create(
|
||||
@ -1474,6 +1736,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
ano=2026,
|
||||
base_url="http://api.example.test",
|
||||
token="token",
|
||||
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
|
||||
fetcher=fetcher,
|
||||
)
|
||||
|
||||
@ -1510,6 +1773,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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,
|
||||
@ -1552,6 +1816,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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,
|
||||
@ -1591,6 +1856,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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,
|
||||
@ -1620,6 +1886,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
ano=2025,
|
||||
base_url="http://api.example.test",
|
||||
token="token",
|
||||
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
|
||||
fetcher=lambda base_url, token, ano, cpf: [
|
||||
{
|
||||
"cpf": cpf,
|
||||
@ -1637,6 +1904,139 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
self.assertEqual(len(result.errors or []), 1)
|
||||
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:
|
||||
pessoa = self.create_gennera_pessoa("Aluno Formado", "02542618330")
|
||||
curso = Curso.objects.create(
|
||||
@ -1672,6 +2072,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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,
|
||||
@ -1688,7 +2089,52 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
vinculo = Vinculo.objects.get(pessoa=pessoa, curso=curso, ano_censo=2026)
|
||||
|
||||
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:
|
||||
pessoa = self.create_gennera_pessoa("Aluno Sem Remoto", "02542618330")
|
||||
@ -1725,6 +2171,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
ano=2026,
|
||||
base_url="http://api.example.test",
|
||||
token="token",
|
||||
ingressantes_fetcher=lambda *a, **k: ([], 0, 1),
|
||||
fetcher=lambda base_url, token, ano, cpf: [],
|
||||
)
|
||||
|
||||
@ -1768,6 +2215,7 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
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,
|
||||
@ -1786,6 +2234,216 @@ class ImportPessoasGenneraTests(AuthenticatedViewTestCase):
|
||||
self.assertEqual(result.pessoas_without_vinculo, 1)
|
||||
self.assertEqual(vinculo.situacao, Vinculo.Situacao.DESVINCULADO)
|
||||
|
||||
def test_import_vinculos_from_gennera_does_not_overwrite_manually_edited_vinculo(
|
||||
self,
|
||||
) -> None:
|
||||
pessoa = self.create_gennera_pessoa("Aluno Editado Manualmente", "02542618330")
|
||||
curso = Curso.objects.create(
|
||||
codigo_mec=123,
|
||||
descricao="Pedagogia",
|
||||
carga_horaria_total=3200,
|
||||
)
|
||||
Vinculo.objects.create(
|
||||
pessoa=pessoa,
|
||||
ano_censo_id=2026,
|
||||
curso=curso,
|
||||
situacao=Vinculo.Situacao.TRANSFERENCIA_INTERNA,
|
||||
semestre_ingresso="012026",
|
||||
atualizado_manualmente=True,
|
||||
ingresso_vestibular=False,
|
||||
ingresso_enem=False,
|
||||
ingresso_avaliacao_seriada=False,
|
||||
ingresso_selecao_simplificada=False,
|
||||
ingresso_egresso_bi_li=False,
|
||||
ingresso_pec_g=False,
|
||||
ingresso_transferencia_ex_officio=False,
|
||||
ingresso_decisao_judicial=False,
|
||||
ingresso_vagas_remanescentes=False,
|
||||
ingresso_programas_especiais=False,
|
||||
apoio_social=False,
|
||||
atividade_extracurricular=False,
|
||||
carga_horaria_total_aluno=3200,
|
||||
carga_horaria_integralizada=1600,
|
||||
ingressou_acao_afirmativa=False,
|
||||
)
|
||||
|
||||
import_vinculos_from_gennera(
|
||||
ano=2026,
|
||||
base_url="http://api.example.test",
|
||||
token="token",
|
||||
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:
|
||||
return Pessoa.objects.create(
|
||||
nome=nome,
|
||||
|
||||
@ -113,6 +113,7 @@ CRUD_CONFIGS = {
|
||||
"ead",
|
||||
"polo",
|
||||
"carga_horaria_total",
|
||||
"exportar",
|
||||
),
|
||||
search_fields=(
|
||||
("codigo_mec", "int"),
|
||||
@ -270,6 +271,7 @@ def export_alunos_view(request: HttpRequest, filtro: str) -> HttpResponse:
|
||||
"pessoa__municipio_nascimento",
|
||||
"pessoa__pais_origem",
|
||||
"curso",
|
||||
"curso__polo",
|
||||
"curso_origem",
|
||||
"polo",
|
||||
"mobilidade_ies_destino",
|
||||
@ -478,7 +480,7 @@ def processar_situacao_view(request: HttpRequest) -> HttpResponse:
|
||||
matricula__in=matriculas,
|
||||
)
|
||||
found_matriculas = set(vinculos.values_list("matricula", flat=True))
|
||||
updated = vinculos.update(situacao=situacao)
|
||||
updated = vinculos.update(situacao=situacao, atualizado_manualmente=True)
|
||||
result = {
|
||||
"updated": updated,
|
||||
"requested": len(matriculas),
|
||||
@ -516,6 +518,16 @@ def crud_list(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
if query:
|
||||
queryset = queryset.filter(build_search_query(config, query))
|
||||
|
||||
sort = request.GET.get("sort", "")
|
||||
direction = request.GET.get("dir", "asc")
|
||||
if direction not in {"asc", "desc"}:
|
||||
direction = "asc"
|
||||
if sort in config.list_fields:
|
||||
order_path = resolve_sort_field(config, sort)
|
||||
queryset = queryset.order_by(order_path if direction == "asc" else f"-{order_path}")
|
||||
else:
|
||||
sort = ""
|
||||
|
||||
paginator = Paginator(queryset, 20)
|
||||
page_obj = paginator.get_page(request.GET.get("page"))
|
||||
rows = [
|
||||
@ -532,6 +544,8 @@ def crud_list(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
{
|
||||
"config": config,
|
||||
"query": query,
|
||||
"sort": sort,
|
||||
"dir": direction,
|
||||
"page_obj": page_obj,
|
||||
"rows": rows,
|
||||
},
|
||||
@ -564,6 +578,7 @@ def crud_create(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
|
||||
if request.method == "POST" and form.is_valid():
|
||||
obj = form.save()
|
||||
mark_atualizado_manualmente(obj)
|
||||
messages.success(request, f"{config.title} salvo com sucesso.")
|
||||
return redirect("crud_detail", slug=slug, pk=obj.pk)
|
||||
|
||||
@ -587,6 +602,7 @@ def crud_update(request: HttpRequest, slug: str, pk: str) -> HttpResponse:
|
||||
|
||||
if request.method == "POST" and form.is_valid():
|
||||
obj = form.save()
|
||||
mark_atualizado_manualmente(obj)
|
||||
messages.success(request, f"{config.title} atualizado com sucesso.")
|
||||
return redirect("crud_detail", slug=slug, pk=obj.pk)
|
||||
|
||||
@ -679,8 +695,21 @@ def build_search_query(config: CrudConfig, query: str) -> Q:
|
||||
return filters
|
||||
|
||||
|
||||
def resolve_sort_field(config: CrudConfig, field: str) -> str:
|
||||
for search_field, _lookup in config.search_fields:
|
||||
if search_field == field or search_field.startswith(f"{field}__"):
|
||||
return search_field
|
||||
return field
|
||||
|
||||
|
||||
def mark_atualizado_manualmente(obj: Model) -> None:
|
||||
if hasattr(obj, "atualizado_manualmente") and not obj.atualizado_manualmente:
|
||||
obj.atualizado_manualmente = True
|
||||
obj.save(update_fields=["atualizado_manualmente"])
|
||||
|
||||
|
||||
def build_model_form(model: type[Model]) -> type[forms.ModelForm]:
|
||||
form_class = modelform_factory(model, fields="__all__")
|
||||
form_class = modelform_factory(model, exclude=["atualizado_manualmente"])
|
||||
|
||||
class StyledForm(form_class):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
|
||||
@ -8,4 +8,5 @@ dependencies = [
|
||||
"django>=5.2,<6.0",
|
||||
"psycopg[binary]>=3.2,<4.0",
|
||||
"celery[redis]>=5.4,<6.0",
|
||||
"requests>=2.31,<3.0",
|
||||
]
|
||||
|
||||
@ -114,6 +114,10 @@
|
||||
<i class="ti ti-world me-2"></i>
|
||||
Paises
|
||||
</a>
|
||||
<a class="dropdown-item" href="{% url 'crud_list' 'polos' %}">
|
||||
<i class="ti ti-map-pin-2 me-2"></i>
|
||||
Polos
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
|
||||
@ -38,7 +38,17 @@
|
||||
<thead>
|
||||
<tr>
|
||||
{% 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 %}
|
||||
<th class="w-1"></th>
|
||||
</tr>
|
||||
@ -99,14 +109,14 @@
|
||||
<ul class="pagination m-0 ms-auto">
|
||||
{% if page_obj.has_previous %}
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
105
uv.lock
105
uv.lock
@ -31,6 +31,7 @@ dependencies = [
|
||||
{ name = "celery", extra = ["redis"] },
|
||||
{ name = "django" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@ -38,6 +39,7 @@ requires-dist = [
|
||||
{ name = "celery", extras = ["redis"], specifier = ">=5.4,<6.0" },
|
||||
{ name = "django", specifier = ">=5.2,<6.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4.0" },
|
||||
{ name = "requests", specifier = ">=2.31,<3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -74,6 +76,76 @@ 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]]
|
||||
name = "click"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "kombu"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "six"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "vine"
|
||||
version = "5.1.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user