From 59fbd3c095a5752dcce957a6be92b976a6f49bb5 Mon Sep 17 00:00:00 2001 From: Kemuel-sepulvida Date: Sat, 22 Aug 2026 19:42:03 -0300 Subject: [PATCH] Fix(Integracao): normaliza a data da assinatura para ISO na serializacao AB#1498 (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- sapl/integracao_hub/serializacao.py | 55 +++++++++++- sapl/integracao_hub/tests/test_assinatura.py | 92 +++++++++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index 17ceb6226..acb6df6ba 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/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` já nasce ISO + (`views_assinatura:1552/1983`, via `self_reported_timestamp`), mas é a + minoria: 1198 dos 1207 registros medidos em 22/08/2026 têm só + `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 vê 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 já 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 diff --git a/sapl/integracao_hub/tests/test_assinatura.py b/sapl/integracao_hub/tests/test_assinatura.py index 7c5385656..66b8add55 100644 --- a/sapl/integracao_hub/tests/test_assinatura.py +++ b/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()