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 logging
import os
from datetime import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@ -25,6 +26,56 @@ def _iso(valor):
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):
"""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,
# A sprint grava nome_assinante/data_assinatura; o POST da
# 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'),
'data': info.get('data') or info.get('data_assinatura'),
'data': _data_da_assinatura(info),
'tipo_certificado': info.get('tipo_certificado'),
'autor_id': _autor_do_signed_by(username, ids_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 os
import uuid
from datetime import timedelta
from datetime import datetime, timedelta
import pytest
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
# ---------------------------------------------------------------------------
# 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)
def test_concluidas_cursor_composto_nao_repete_no_empate(cliente_hub):
instante = timezone.now()

Loading…
Cancel
Save