Browse Source

Feat(Assinatura): pendentes vira fonte por data — alvo materializado tarde nao some AB#1473 (#21)

`assinaturas-pendentes` era keyset puro por `materia_id`. A materializacao e
justamente o passo que pode acontecer MUITO depois do protocolo: alvo gerado
hoje para materia antiga nasce com id abaixo do cursor e nunca mais e lido —
sem erro, sem WARN, e a reconciliacao cobre proposicao/tramitacao, nao
assinatura.

Em Franco isso nao era caso de borda, era o acervo: em 22/08/2026, 861 materias
DOCX esperando a conversao voltar a funcionar, TODAS com id < 1076, contra um
cursor em 1078. No dia em que o OnlyOffice convertesse, as 861 materializariam
de uma vez e sumiriam todas — caladas.

Agora o keyset e `(gerado_em, materia_id)`, o mesmo de `assinaturas-concluidas`:
quem materializa tarde entra pela DATA, nao pelo id. O desempate por id impede
que uma passada de recuperacao inteira, com timestamps empatados, releia a
mesma primeira pagina para sempre (§1.1).

`gerado_em` e `auto_now`, entao a retificacao reapresenta a materia sozinha —
que e o comportamento desejado de qualquer forma: o app precisa saber que o
alvo mudou. O dedupe do hub (marcador = hash do documento) mata a releitura do
mesmo estado.

`desde` ausente mantem o keyset antigo por id, para o hub da versao anterior
seguir funcionando durante a subida.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
903f272061
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 17
      sapl/integracao_hub/serializacao.py
  2. 95
      sapl/integracao_hub/tests/test_assinatura.py
  3. 48
      sapl/integracao_hub/views.py

17
sapl/integracao_hub/serializacao.py

@ -214,12 +214,19 @@ def serializar_pendencia(alvo, request):
# são a forma canônica que o próprio SAPL usa ao chamar o microserviço. Duplicar # são a forma canônica que o próprio SAPL usa ao chamar o microserviço. Duplicar
# a montagem é o caminho conhecido para as duas divergirem na primeira mudança. # a montagem é o caminho conhecido para as duas divergirem na primeira mudança.
return { return {
# Keyset da fonte: o hub le `id` no topo e devolve como `id_gt`, e a # Keyset da fonte: cursor composto `(gerado_em, id)`, o mesmo de
# view filtra `materia_id__gt` (o alvo e OneToOne com a materia, ver # assinaturas-concluidas. O `id` e o da MATERIA, nao o do registro de
# AssinaturasPendentesPollView). Aqui vai o id da MATERIA, nao o do # pendencia — emitir alvo.pk faria o hub pedir uma pagina que a view
# registro de pendencia — emitir alvo.pk faria o hub pedir uma pagina # nunca entende, relendo a mesma primeira pagina para sempre.
# que a view nunca entende, relendo a mesma primeira pagina para sempre.
'id': materia.pk, 'id': materia.pk,
# A DATA e o que salva materia que materializa TARDE. Com keyset so por
# id, alvo criado depois com id abaixo do cursor era pulado para
# sempre, calado: em 22/08/2026 as 861 materias DOCX de Franco (todas
# com id < 1076, cursor em 1078) iam sumir inteiras no dia em que a
# conversao voltasse a funcionar. `gerado_em` e auto_now, entao a
# retificacao tambem reapresenta a materia sozinha — que e o
# comportamento desejado de qualquer forma (§5.1).
'gerado_em': _iso(alvo.gerado_em),
'materia': { 'materia': {
'id': materia.pk, 'id': materia.pk,
'numero': materia.numero, 'numero': materia.numero,

95
sapl/integracao_hub/tests/test_assinatura.py

@ -611,3 +611,98 @@ def test_pendentes_traz_id_da_materia_no_topo(cliente_hub):
item = next(i for i in resposta.data['resultados'] item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk) if i['materia']['id'] == materia.pk)
assert item['id'] == materia.pk assert item['id'] == materia.pk
# ---------------------------------------------------------------------------
# Cursor composto (gerado_em, id) — a matéria que materializa TARDE
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_pendentes_traz_gerado_em_para_o_cursor(cliente_hub):
"""Sem o campo o hub não tem como montar `CursorPorData` e a fonte trava."""
materia, alvo = criar_materia_com_alvo()
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/',
{'desde': '1970-01-01T00:00:00+00:00',
'id_gt': 0})
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assert item['gerado_em']
@pytest.mark.django_db(transaction=False)
def test_alvo_materializado_depois_com_id_menor_ainda_e_lido(cliente_hub):
"""O modo de falha que ia engolir as 861 matérias de Franco.
Matéria ANTIGA (id baixo) que materializa hoje: com keyset por id ela
nasce abaixo do cursor e nunca mais é lida sem erro e sem WARN. Pela
data, ela entra normalmente.
"""
antiga = baker.make(MateriaLegislativa, numero_protocolo=201)
recente, alvo_recente = criar_materia_com_alvo()
assert antiga.pk < recente.pk
# o hub já andou até depois da matéria recente
cursor_desde = alvo_recente.gerado_em
cursor_id = recente.pk
# ...e SÓ AGORA a antiga materializa (conversão DOCX voltou a funcionar)
antiga.texto_original.save('texto.pdf', ContentFile(PDF_ALVO), save=True)
alvo_antigo = DocumentoParaAssinatura(
materia=antiga,
hash_sha256=hashlib.sha256(PDF_ALVO).hexdigest(),
hash_origem=hashlib.sha256(PDF_ALVO).hexdigest())
alvo_antigo.arquivo.save('materia_%s_alvo.pdf' % antiga.pk,
ContentFile(PDF_ALVO), save=True)
resposta = cliente_hub.get(
BASE + 'assinaturas-pendentes/',
{'desde': cursor_desde.isoformat(), 'id_gt': cursor_id})
ids = [i['materia']['id'] for i in resposta.data['resultados']]
assert antiga.pk in ids
@pytest.mark.django_db(transaction=False)
def test_empate_de_gerado_em_nao_trava_o_cursor(cliente_hub):
"""Passada de recuperação materializa em lote — timestamps empatam.
Sem o desempate por id, uma página inteira no mesmo instante devolveria
sempre a mesma primeira página, para sempre (mesmo racional do §1.1).
"""
primeira, alvo_a = criar_materia_com_alvo()
segunda, alvo_b = criar_materia_com_alvo()
instante = alvo_a.gerado_em
DocumentoParaAssinatura.objects.filter(
pk__in=[alvo_a.pk, alvo_b.pk]).update(gerado_em=instante)
resposta = cliente_hub.get(
BASE + 'assinaturas-pendentes/',
{'desde': instante.isoformat(), 'id_gt': primeira.pk})
ids = [i['materia']['id'] for i in resposta.data['resultados']]
assert primeira.pk not in ids
assert segunda.pk in ids
@pytest.mark.django_db(transaction=False)
def test_sem_desde_mantem_o_keyset_antigo_por_id(cliente_hub):
"""Compatibilidade de subida: hub da versão anterior só manda `id_gt`."""
primeira, _ = criar_materia_com_alvo()
segunda, _ = criar_materia_com_alvo()
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/',
{'id_gt': primeira.pk})
ids = [i['materia']['id'] for i in resposta.data['resultados']]
assert primeira.pk not in ids
assert segunda.pk in ids
@pytest.mark.django_db(transaction=False)
def test_desde_invalido_e_400(cliente_hub):
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/',
{'desde': 'ontem', 'id_gt': 0})
assert resposta.status_code == 400

48
sapl/integracao_hub/views.py

@ -319,13 +319,31 @@ class InventarioView(PollView):
class AssinaturasPendentesPollView(PollView): class AssinaturasPendentesPollView(PollView):
"""Fonte de poll da pendência de assinatura (refinamento §3, keyset por id). """Fonte de poll da pendência de assinatura — cursor composto `(gerado_em, id)`.
devolve matéria com o PDF-alvo materializado (§5.1): DOCX ainda não devolve matéria com o PDF-alvo materializado (§5.1): DOCX ainda não
convertido não sai do SAPL segue visível apenas na tela local. O cursor convertido não sai do SAPL segue visível apenas na tela local. Item com
anda sobre o id da MATÉRIA (o alvo é OneToOne), então página sem pendência `autores_pendentes` vazio é ruído inofensivo, nunca loop: o cursor avança
por autor ainda avança o cursor item com `autores_pendentes` vazio é igual.
ruído inofensivo, nunca loop.
**Por que a data entrou no cursor (22/08/2026).** Esta fonte era keyset puro
por `materia_id`, e a materialização é justamente o passo que pode acontecer
MUITO depois do protocolo. Alvo criado hoje para uma matéria antiga nasce com
id abaixo do cursor e nunca mais é lido sem erro, sem WARN, e a
reconciliação não cobre assinatura. No acervo de Franco isso valia o acervo
inteiro: 861 matérias DOCX paradas por conversão quebrada, TODAS com id <
1076, contra um cursor em 1078. No dia em que a conversão voltasse a
funcionar, as 861 materializariam de uma vez e sumiriam todas.
Ordenar por `gerado_em` mata isso na raiz: quem materializa tarde entra pela
data, não pelo id. E como `gerado_em` é `auto_now`, a retificação
reapresenta a matéria sozinha o app precisa saber que o alvo mudou (§5.1),
e o dedupe do hub (marcador = hash do documento) mata a releitura do mesmo
estado.
`desde` ausente mantém o keyset antigo por id, para o hub de versão anterior
continuar funcionando durante a subida. Some quando as duas pontas estiverem
na nova versão.
""" """
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@ -333,12 +351,26 @@ class AssinaturasPendentesPollView(PollView):
if id_gt is None: if id_gt is None:
return Response({'detalhe': 'id_gt deve ser inteiro'}, return Response({'detalhe': 'id_gt deve ser inteiro'},
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
alvos = (DocumentoParaAssinatura.objects alvos = (DocumentoParaAssinatura.objects
.filter(materia_id__gt=id_gt)
.select_related('materia') .select_related('materia')
.prefetch_related( .prefetch_related(
'materia__autoria_set__autor__operadorautor_set__user') 'materia__autoria_set__autor__operadorautor_set__user'))
.order_by('materia_id')[:self._limite(request)])
if 'desde' in request.query_params:
desde = self._desde(request)
if desde is None:
return Response(
{'detalhe': 'desde deve ser um datetime ISO-8601'},
status=status.HTTP_400_BAD_REQUEST)
depois_do_instante = Q(gerado_em__gt=desde)
no_mesmo_instante = Q(gerado_em=desde) & Q(materia_id__gt=id_gt)
alvos = (alvos.filter(depois_do_instante | no_mesmo_instante)
.order_by('gerado_em', 'materia_id'))
else:
alvos = alvos.filter(materia_id__gt=id_gt).order_by('materia_id')
alvos = alvos[:self._limite(request)]
return Response({'resultados': [ return Response({'resultados': [
serializar_pendencia(alvo, request) for alvo in alvos]}) serializar_pendencia(alvo, request) for alvo in alvos]})

Loading…
Cancel
Save