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>
This commit is contained in:
Rogério de Lima Sousa 2026-07-06 18:02:00 -03:00
parent d96bea6755
commit 3eca55578d
6 changed files with 635 additions and 99 deletions

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
@ -116,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
@ -242,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):
@ -308,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):
@ -356,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,
@ -374,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):
@ -489,7 +582,7 @@ 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: if vinculo.atualizado_manualmente or vinculo.situacao == Vinculo.Situacao.FORMADO:
continue 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:
@ -598,6 +691,67 @@ def corrigir_situacao_nao_informada(
return corrigidos 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,
@ -605,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:
@ -763,6 +938,9 @@ def import_gennera_vinculo_group(
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")),

View File

@ -46,6 +46,7 @@ class Command(BaseCommand):
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(

View File

@ -65,6 +65,7 @@ def import_vinculos_censup_task(
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,

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(
@ -1554,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",
@ -1574,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",
@ -1613,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(
@ -1676,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,
) )
@ -1712,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,
@ -1754,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,
@ -1793,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,
@ -1822,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,
@ -1839,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(
@ -1874,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,
@ -1890,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")
@ -1927,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: [],
) )
@ -1970,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,
@ -2025,6 +2271,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,
@ -2077,6 +2324,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: [],
) )
@ -2129,6 +2377,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,
@ -2185,6 +2434,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: [],
) )

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",
] ]

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"