Browse Source

Fix(Integracao): normaliza a data da assinatura para ISO na serializacao AB#1498 (#25)

`serializar_materia_assinada` unificava a CHAVE (`data` ou `data_assinatura`),
nao o FORMATO. `data_assinatura` e gravado em %d/%m/%Y em seis pontos de
views_assinatura e no receiver da propria integracao — 1198 de 1207 registros
do acervo medido em 22/08/2026. O consumidor parseia como ISO, estourava, e o
ciclo inteiro do poll morria com o cursor parado: a fonte
sapl:assinaturas-concluidas ficou congelada desde as 18:01.

O laco era fechado: cada assinatura feita PELO APP envenenava a fonte que
contaria ao app que ela aconteceu.

Normaliza na fronteira de serializacao, e nao nos pontos de gravacao, porque
`data_assinatura` alimenta a tela de verificacao publica (views_assinatura:864,
2096, 2136): mudar o que se grava mudaria o que o cidadao ve e obrigaria a
migrar 964 materias. Um ponto so conserta o acervo inteiro sem tocar em dado.

Data ilegivel sai como null em vez de estourar — o consumidor ja recai em
`assinado_em`.

Refinamento: engineering/architecture/Assinatura_Volta_Concluidas_Refinamento.md

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

55
sapl/integracao_hub/serializacao.py

@ -1,6 +1,7 @@
import hashlib import hashlib
import logging import logging
import os import os
from datetime import datetime
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
@ -25,6 +26,56 @@ def _iso(valor):
return valor.isoformat() return valor.isoformat()
#: Formatos em que `data_assinatura` é gravado hoje. `views_assinatura` usa os
#: dois (`%H:%M:%S` na maioria dos pontos, `%H:%M` em 389/599) e ambos chegam
#: aqui pelo acervo antigo.
_FORMATOS_DATA_ASSINATURA = ('%d/%m/%Y %H:%M:%S', '%d/%m/%Y %H:%M')
def _data_da_assinatura(info):
"""A data de um ato de assinatura, SEMPRE em ISO-8601 com offset.
O contrato promete ISO; o acervo entrega duas coisas. `data` nasce ISO
(`views_assinatura:1552/1983`, via `self_reported_timestamp`), mas é a
minoria: 1198 dos 1207 registros medidos em 22/08/2026 têm
`data_assinatura`, em `%d/%m/%Y`. O consumidor parseia como ISO e estoura
e um estouro aqui congelava a fonte inteira (ver PollerSapl).
Normalizar AQUI, e não nos seis pontos de gravação, é deliberado:
`data_assinatura` alimenta a tela de verificação pública
(`views_assinatura:864/2096/2136`). Mudar o que se grava mudaria o que o
cidadão e obrigaria a migrar 964 matérias; normalizar na fronteira
conserta o acervo inteiro sem tocar em dado nem em exibição.
Sem data utilizável devolve None o consumidor recai em `assinado_em`,
que é o fallback que ele tem.
"""
iso = info.get('data')
if iso:
return iso
bruta = (info.get('data_assinatura') or '').strip()
if not bruta:
return None
for formato in _FORMATOS_DATA_ASSINATURA:
try:
ingenua = datetime.strptime(bruta, formato)
except ValueError:
continue
# Foi `timezone.localtime` que gravou — então é hora local da casa, e é
# como hora local que ela tem que ser reinterpretada. Assumir UTC aqui
# deslocaria toda a série pelo offset do fuso.
return timezone.make_aware(
ingenua, timezone.get_current_timezone()).isoformat()
logger.warning(
'integracao_hub: data_assinatura %r não casa com nenhum formato '
'conhecido — assinatura serializada sem data (o consumidor recai em '
'assinado_em)', bruta)
return None
def _materia_da_proposicao(proposicao): def _materia_da_proposicao(proposicao):
"""A matéria gerada vem da generic FK, não de um campo direto. """A matéria gerada vem da generic FK, não de um campo direto.
@ -266,8 +317,10 @@ def serializar_materia_assinada(materia, request):
'signed_by': username, 'signed_by': username,
# A sprint grava nome_assinante/data_assinatura; o POST da # A sprint grava nome_assinante/data_assinatura; o POST da
# integração grava nome/data — o contrato enxerga um shape só. # integração grava nome/data — o contrato enxerga um shape só.
# Shape só na CHAVE não basta: o formato também tem que convergir,
# e é o que `_data_da_assinatura` garante (sempre ISO ou None).
'nome': info.get('nome') or info.get('nome_assinante'), 'nome': info.get('nome') or info.get('nome_assinante'),
'data': info.get('data') or info.get('data_assinatura'), 'data': _data_da_assinatura(info),
'tipo_certificado': info.get('tipo_certificado'), 'tipo_certificado': info.get('tipo_certificado'),
'autor_id': _autor_do_signed_by(username, ids_da_autoria), 'autor_id': _autor_do_signed_by(username, ids_da_autoria),
# Rastro operacional (quem disparou o ato) — separado da autoria # Rastro operacional (quem disparou o ato) — separado da autoria

92
sapl/integracao_hub/tests/test_assinatura.py

@ -1,7 +1,7 @@
import hashlib import hashlib
import os import os
import uuid import uuid
from datetime import timedelta from datetime import datetime, timedelta
import pytest import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@ -202,6 +202,96 @@ def test_concluidas_devolve_documento_assinado_e_autor_resolvido(cliente_hub):
assert assinatura['autor_id'] == autor.pk assert assinatura['autor_id'] == autor.pk
# ---------------------------------------------------------------------------
# Normalização da data do ato (AB#1498)
#
# O consumidor parseia `data` como ISO. O acervo grava `data_assinatura` em
# `%d/%m/%Y` — 1198 de 1207 registros medidos em 22/08/2026 — e era isso que
# congelava a fonte inteira no hub. A normalização mora na serialização porque
# `data_assinatura` alimenta a tela de verificação pública e não pode mudar.
# ---------------------------------------------------------------------------
def _assinatura_de(cliente_hub, materia):
resposta = cliente_hub.get(
BASE + 'assinaturas-concluidas/',
{'desde': (timezone.now() - timedelta(days=1)).isoformat(),
'id_gt': 0})
assert resposta.status_code == 200
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
return item['assinaturas'][0]
@pytest.mark.parametrize('gravado,esperado_prefixo', [
('22/08/2026 15:41:30', '2026-08-22T15:41:30'),
('19/08/2026 10:00', '2026-08-19T10:00:00'),
])
@pytest.mark.django_db(transaction=False)
def test_concluidas_converte_data_brasileira_para_iso(
cliente_hub, gravado, esperado_prefixo):
"""Os dois formatos que `views_assinatura` grava saem em ISO."""
materia, _ = criar_materia_com_alvo()
assinar_localmente(materia)
materia.assinatura_info[0]['data_assinatura'] = gravado
materia.save()
data = _assinatura_de(cliente_hub, materia)['data']
# Parseável como ISO é o que o contrato promete — é o parse que travava.
assert datetime.fromisoformat(data) is not None
assert data.startswith(esperado_prefixo)
# Hora LOCAL preservada: reinterpretar como UTC deslocaria a série inteira.
assert datetime.fromisoformat(data).utcoffset() is not None
@pytest.mark.django_db(transaction=False)
def test_concluidas_preserva_data_que_ja_veio_iso(cliente_hub):
"""Os 9 registros bons (`self_reported_timestamp`) não podem regredir."""
materia, _ = criar_materia_com_alvo()
assinar_localmente(materia)
materia.assinatura_info[0]['data'] = '2026-08-22T17:00:20.885536+00:00'
materia.save()
assert _assinatura_de(cliente_hub, materia)['data'] == \
'2026-08-22T17:00:20.885536+00:00'
@pytest.mark.parametrize('info_extra', [
{'data_assinatura': ''},
{'data_assinatura': 'ontem de tarde'},
{},
])
@pytest.mark.django_db(transaction=False)
def test_concluidas_sem_data_utilizavel_devolve_nulo(cliente_hub, info_extra):
"""Data ilegível não estoura: sai `null` e o consumidor usa `assinado_em`.
É a diferença entre perder a data de UM ato e congelar a fonte inteira.
"""
materia, _ = criar_materia_com_alvo()
assinar_localmente(materia)
materia.assinatura_info[0].pop('data_assinatura', None)
materia.assinatura_info[0].update(info_extra)
materia.save()
assert _assinatura_de(cliente_hub, materia)['data'] is None
@pytest.mark.django_db(transaction=False)
def test_concluidas_normaliza_o_que_o_proprio_receiver_gravou(cliente_hub):
"""O laço fechado: o receiver da integração grava em `%d/%m/%Y %H:%M:%S`.
Sem esta normalização, cada assinatura feita PELO APP envenenava a fonte
que contaria ao app que ela aconteceu.
"""
materia, _ = criar_materia_com_alvo()
assinar_localmente(materia)
materia.assinatura_info[0]['data_assinatura'] = \
timezone.localtime(timezone.now()).strftime('%d/%m/%Y %H:%M:%S')
materia.save()
assert datetime.fromisoformat(_assinatura_de(cliente_hub, materia)['data'])
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_concluidas_cursor_composto_nao_repete_no_empate(cliente_hub): def test_concluidas_cursor_composto_nao_repete_no_empate(cliente_hub):
instante = timezone.now() instante = timezone.now()

Loading…
Cancel
Save