Browse Source

Merge pull request #8 from Move-System/feat/anexos-gerais-da-proposicao

Feat(Integracao): anexos do app viram acervo geral, nao texto oficial AB#1480
pull/3858/head
Kemuel-sepulvida 1 month ago
committed by GitHub
parent
commit
f3f68e3b33
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 115
      docs/CONFIGURACAO_BD_REMOTO.md
  2. 201
      docs/LOCALHOST_SETUP.md
  3. 2
      knowledge
  4. 33
      sapl/integracao_hub/migrations/0002_anexoproposicao.py
  5. 56
      sapl/integracao_hub/models.py
  6. 4
      sapl/integracao_hub/tests/test_poll.py
  7. 37
      sapl/integracao_hub/tests/test_recepcao.py
  8. 4
      sapl/integracao_hub/tests/test_reconciliacao.py
  9. 31
      sapl/integracao_hub/views.py

115
docs/CONFIGURACAO_BD_REMOTO.md

@ -2,6 +2,19 @@
Este documento explica como configurar o SGVP para usar um banco de dados PostgreSQL remoto. Este documento explica como configurar o SGVP para usar um banco de dados PostgreSQL remoto.
> **Atualizado em 13/08/2026.** Duas correções: o host `sgvp.com.br:5432` não responde
> mais — o banco remoto ativo é `demo.legisinc.com.br:5432` (base `demo`) — e as
> credenciais que estavam em claro neste documento foram rejeitadas pelo servidor. Peça as credenciais atuais ao responsável pelo ambiente e
> mantenha-as fora do repositório (`sapl/.env`, que é gitignored, ou variáveis de
> ambiente do host). Para subir só o Django em desenvolvimento, sem o compose completo,
> veja [LOCALHOST_SETUP.md](LOCALHOST_SETUP.md).
>
> **Leia antes a seção [O que o usuário remoto precisa poder fazer](#o-que-o-usuário-remoto-precisa-poder-fazer)**:
> o `start.sh` executa DDL na inicialização, e um usuário só de leitura/escrita de dados
> — como o disponível hoje no `demo`**derruba o container no boot**. Nesse caso o
> caminho é a cópia local do banco, descrita na seção 4 do
> [LOCALHOST_SETUP.md](LOCALHOST_SETUP.md#4-cópia-local-do-banco-quando-há-migration-pendente).
## Problemas Corrigidos ## Problemas Corrigidos
### 1. Erro 404 ao acessar http://localhost:8000/ ### 1. Erro 404 ao acessar http://localhost:8000/
@ -52,16 +65,19 @@ Adicione a variável `DATABASE_URL` nas variáveis de ambiente do serviço `sapl
ADMIN_PASSWORD: interlegis ADMIN_PASSWORD: interlegis
ADMIN_EMAIL: email@dominio.net ADMIN_EMAIL: email@dominio.net
DEBUG: 'False' DEBUG: 'False'
DATABASE_URL: postgresql://bruno:Sapl%402025@sgvp.com.br:5432/sapl DATABASE_URL: postgresql://<usuario>:<senha-url-encoded>@demo.legisinc.com.br:5432/demo
# ... outras variáveis # ... outras variáveis
``` ```
Prefira injetar a `DATABASE_URL` por variável de ambiente do host (ou por um `.env`
gitignored) em vez de escrevê-la no `docker-compose.yaml`, que é versionado.
**IMPORTANTE**: Se a senha contiver caracteres especiais como `@`, eles devem ser codificados: **IMPORTANTE**: Se a senha contiver caracteres especiais como `@`, eles devem ser codificados:
- `@``%40` - `@``%40`
- `:``%3A` - `:``%3A`
- `/``%2F` - `/``%2F`
Exemplo: `Sapl@2025` vira `Sapl%402025` Exemplo: `S3nh@2026` vira `S3nh%402026`
### Passo 2: Remover dependência do banco local ### Passo 2: Remover dependência do banco local
@ -87,17 +103,64 @@ No mesmo arquivo, remova `sapldb` das dependências:
Antes de iniciar o SGVP, teste a conexão: Antes de iniciar o SGVP, teste a conexão:
```bash ```bash
docker run --rm postgres:10.5-alpine psql \ docker run --rm postgres:17-alpine psql \
"postgresql://bruno:Sapl%402025@sgvp.com.br:5432/sapl" \ "postgresql://<usuario>:<senha-url-encoded>@demo.legisinc.com.br:5432/demo" \
-c "\dt" -c "\dt"
``` ```
Se listar as tabelas, a conexão está funcionando! Se listar as tabelas, a conexão está funcionando — mas **conectar não basta**: veja a
seção seguinte antes de subir o compose.
## O que o usuário remoto precisa poder fazer
Conexão bem-sucedida não garante boot. O `docker/startup_scripts/start.sh` roda, nesta
ordem, duas etapas que exigem privilégio além de ler e gravar dados:
| Etapa no `start.sh` | Comando | Privilégio exigido | Se falhar |
|---|---|---|---|
| `configure_pg_timezone` | `ALTER DATABASE … SET timezone` e `ALTER ROLE … SET timezone` | dono do banco / superusuário | `exit 1` — o container morre no boot |
| `migrate_db` | `manage.py migrate --noinput` | `CREATE` no schema `public` (quando há migration pendente) | erro e boot interrompido |
O `configure_pg_timezone` só é pulado se o banco já responder `UTC` em `show time zone`
(linhas 45-49 do `start.sh`). O `demo` responde `America/Sao_Paulo`, então **a etapa não é
pulada** e o `ALTER DATABASE` é tentado de verdade.
Verifique os três pontos antes de subir:
```bash
DB="postgresql://<usuario>:<senha-url-encoded>@demo.legisinc.com.br:5432/demo"
docker run --rm -e PGURL="$DB" postgres:17-alpine sh -c 'psql "$PGURL" \
-Atc "show time zone;" \
-c "select has_schema_privilege(current_user, '"'"'public'"'"', '"'"'CREATE'"'"');" \
-c "select pg_get_userbyid(datdba) as dono_do_banco, current_user as voce
from pg_database where datname = current_database();"'
```
Situação verificada em 13/08/2026 com o usuário `kasepulvida` no `demo`: timezone
`America/Sao_Paulo`, `CREATE` no schema = `f`, dono do banco = `postgres` (não é o
usuário). Ou seja, **este documento não sobe hoje com essas credenciais** — o boot para no
`ALTER DATABASE`. As saídas:
```
ALTER DATABASE "demo" failed. Need DB owner or superuser.
django.db.utils.ProgrammingError: permission denied for schema public
```
Três caminhos, em ordem de preferência:
1. **Cópia local do banco** — você vira dono e nada disso trava. Procedimento na seção 4
do [LOCALHOST_SETUP.md](LOCALHOST_SETUP.md#4-cópia-local-do-banco-quando-há-migration-pendente).
2. **Pedir os privilégios ao dono do banco** (`GRANT CREATE ON SCHEMA public` e as
`ALTER`s de timezone aplicadas por ele) — só faz sentido se o banco remoto for mesmo o
alvo; lembre que ele é compartilhado.
3. **Rodar só o Django, sem o compose** — o `runserver` não executa o `start.sh`, então
nenhuma das duas etapas acontece. Serve quando não há migration pendente e você só
precisa ler/gravar dados. Ver [LOCALHOST_SETUP.md](LOCALHOST_SETUP.md).
### Passo 4: Reconstruir a imagem Docker ### Passo 4: Reconstruir a imagem Docker
```bash ```bash
cd /home/bruno/sapl/docker cd ~/dev/sapl/docker
docker-compose build sapl docker-compose build sapl
``` ```
@ -149,7 +212,7 @@ Se estiver usando um banco remoto existente, use os usuários já cadastrados ne
│ (Aplicação) │ │ (Aplicação) │
└────────┬────────┘ └────────┬────────┘
├──→ sgvp.com.br:5432 (PostgreSQL Remoto) ├──→ demo.legisinc.com.br:5432 (PostgreSQL 17 Remoto)
├──→ SOLR (Porta 8983) ├──→ SOLR (Porta 8983)
@ -175,11 +238,39 @@ docker exec sapl ls -la /var/interlegis/sapl/run/
docker exec sapl env | grep DATABASE_URL docker exec sapl env | grep DATABASE_URL
# Testar conexão manualmente # Testar conexão manualmente
docker run --rm postgres:10.5-alpine psql \ docker run --rm postgres:17-alpine psql \
"postgresql://usuario:senha@host:5432/database" \ "postgresql://usuario:senha@host:5432/database" \
-c "SELECT version();" -c "SELECT version();"
``` ```
Use um cliente `psql` de versão maior ou igual à do servidor (o `demo` roda PostgreSQL
17.10); um cliente antigo falha no `pg_dump`/`pg_restore`.
### `ALTER DATABASE "…" failed. Need DB owner or superuser.`
O container morre no boot, antes das migrations. O usuário do `DATABASE_URL` não é dono do
banco nem superusuário, e o banco não está em UTC — então o `configure_pg_timezone` do
`start.sh` tenta o `ALTER DATABASE` e aborta com `exit 1`.
Ver [O que o usuário remoto precisa poder fazer](#o-que-o-usuário-remoto-precisa-poder-fazer).
### `permission denied for schema public`
```
django.db.utils.ProgrammingError: permission denied for schema public
LINE 1: CREATE TABLE "..." ("id" serial NO...
```
Há migration pendente e o usuário não tem `CREATE` no schema `public`. A migration roda em
transação, então a tentativa recusada não deixa resíduo no banco. Confirme com:
```bash
docker exec sapl python3 manage.py showmigrations | grep '\[ \]'
```
Solução: cópia local do banco (seção 4 do
[LOCALHOST_SETUP.md](LOCALHOST_SETUP.md#4-cópia-local-do-banco-quando-há-migration-pendente))
ou `GRANT CREATE ON SCHEMA public` concedido pelo dono do banco.
### Erro 404 no nginx ### Erro 404 no nginx
```bash ```bash
@ -235,6 +326,10 @@ sapl/
## Notas Finais ## Notas Finais
- O banco de dados local (container `postgres`) não é mais necessário quando usando banco remoto - O banco de dados local (container `postgres`) não é mais necessário quando usando banco remoto
- As migrations são aplicadas automaticamente na inicialização - As migrations são aplicadas automaticamente na inicialização (`migrate_db` no `start.sh`)
- O timezone do banco é configurado automaticamente para `America/Sao_Paulo` — o que também significa que **não há como subir o compose sem tentar migrar**: se houver
migration pendente e o usuário não tiver `CREATE`, o boot falha
- O timezone do banco é configurado automaticamente para `America/Sao_Paulo` via
`ALTER DATABASE`/`ALTER ROLE` — exige dono do banco ou superusuário, e o `start.sh`
pula essa etapa se o banco já estiver em `UTC`
- Os arquivos de media e data ficam em volumes Docker separados - Os arquivos de media e data ficam em volumes Docker separados

201
docs/LOCALHOST_SETUP.md

@ -1,40 +1,221 @@
# Subir o SAPL apontando para o banco remoto (localhost) # Subir o SAPL em localhost (banco remoto ou cópia local)
Passo a passo para subir o ambiente local usando o Postgres já disponível em `sgvp.com.br:5432`. Passo a passo para subir o ambiente local usando o Postgres já disponível em
`demo.legisinc.com.br:5432` (base `demo`) — ou uma cópia local dele, quando a tarefa
exigir migration.
> Validado em 13/08/2026 em Ubuntu 26.04. O host `sgvp.com.br:5432`, citado em
> versões anteriores deste documento, não responde mais — use `demo.legisinc.com.br`.
**Antes de tudo, responda uma pergunta: esta tarefa precisa de migration?**
Se precisar, o banco remoto não serve — o usuário de acesso não tem permissão de DDL —
e você vai precisar da cópia local descrita na [seção 4](#4-cópia-local-do-banco-quando-há-migration-pendente).
A [seção 3](#3-decidir-remoto-direto-ou-cópia-local) mostra como descobrir isso em dois comandos.
## 1) Preparar o ambiente Python ## 1) Preparar o ambiente Python
O projeto roda em **Python 3.12 + Django 2.2**. Django 2.2 não funciona em Python 3.13+,
então escolha o caminho conforme o seu sistema.
### Opção A — Docker (recomendado; obrigatório em Ubuntu 24.04+)
Distribuições recentes (Ubuntu 26.04, por exemplo) já não oferecem Python 3.12 no apt.
Nesse caso use a imagem `python:3.12-slim-bookworm`, com as mesmas dependências de
sistema do `docker/Dockerfile.dev`:
```bash
cd ~/dev/sapl
docker build -f docker/Dockerfile.dev -t sapl:dev .
```
> **Atenção**: o `Dockerfile.dev` instala `requirements/dev-requirements.txt`, que hoje
> tem um conflito de dependências — `django-debug-toolbar` está pinado em `2.2.1` no
> `requirements.txt` e em `3.2.4` no `dev-requirements.txt`, e o pip não resolve.
> Até isso ser corrigido, copie o `Dockerfile.dev` trocando a última linha do
> `pip install` para `requirements/requirements.txt` e construa a partir da cópia:
```bash ```bash
cd /root/dev/sapl sed 's|dev-requirements.txt|requirements.txt|' docker/Dockerfile.dev > /tmp/Dockerfile.localhost
python3 -m venv .venv docker build -f /tmp/Dockerfile.localhost -t sapl:localhost .
```
### Opção B — virtualenv (se o sistema tiver Python 3.12)
```bash
cd ~/dev/sapl
python3.12 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements/requirements.txt pip install -r requirements/requirements.txt
``` ```
## 2) Configurar variáveis de ambiente ## 2) Configurar variáveis de ambiente
Edite `sapl/.env` com os valores do banco remoto: Crie `sapl/.env` (o arquivo é gitignored) com os valores do banco remoto:
```env ```env
DATABASE_URL=postgresql://kemuel:kasepulvida@sgvp.com.br:5432/sapl DATABASE_URL=postgresql://<usuario>:<senha-url-encoded>@demo.legisinc.com.br:5432/demo
SECRET_KEY=<sua-chave-secreta> SECRET_KEY=<sua-chave-secreta>
DEBUG=True DEBUG=True
DJANGO_DEBUG=True
EMAIL_USE_TLS=True EMAIL_USE_TLS=True
EMAIL_PORT=587 EMAIL_PORT=587
``` ```
> Observação: não rode `migrate` contra esse banco se ele for de produção. > **`DJANGO_DEBUG`, não `DEBUG`**: o `settings.py` lê o modo debug do Django de
> `DJANGO_DEBUG` (linha 38). A variável `DEBUG` só controla o log de conexão do banco.
> Defina as duas.
>
> **Senha com caractere especial** precisa ser URL-encoded no `DATABASE_URL`:
> `@``%40`, `:``%3A`, `/``%2F`. Ex.: `S3nh@2026` vira `S3nh%402026`.
> Sem isso o parser da URL quebra no lugar errado e a autenticação falha.
Peça as credenciais atuais ao responsável pelo ambiente — as que estavam neste
documento e no `CONFIGURACAO_BD_REMOTO.md` foram rejeitadas pelo servidor em 12/08/2026.
## 3) Decidir: remoto direto ou cópia local?
Esta é a decisão que define o resto do setup. O critério é **se há migration pendente**:
```bash
docker run --rm -v "$PWD":/sapl-dev -w /sapl-dev sapl:localhost \
python manage.py showmigrations | grep '\[ \]'
```
(o `settings.py` lê o `sapl/.env` do próprio volume montado, não precisa de `--env-file`;
se a `DATABASE_URL` já apontar para o banco local da seção 4, acrescente `--network sapl-net`)
- **Não imprimiu nada** (todas `[X]`) → siga usando o banco remoto direto. Pule para a seção 5.
A aplicação grava dados normalmente; o que falta ao usuário é só permissão de DDL.
- **Imprimiu alguma migration** → você precisa de DDL, e o remoto vai recusar. **Faça a cópia
local (seção 4).**
Para confirmar a permissão de DDL antes de tentar (opcional):
```bash
DB=$(grep '^DATABASE_URL=' sapl/.env | cut -d= -f2-)
docker run --rm -e PGURL="$DB" postgres:17-alpine \
sh -c 'psql "$PGURL" -Atc "select has_schema_privilege(current_user, '"'"'public'"'"', '"'"'CREATE'"'"');"'
```
`f` significa sem permissão de criar tabela. Foi o resultado do usuário `kasepulvida` em
`demo.legisinc.com.br` em 13/08/2026 — o `migrate` falha com:
```
django.db.utils.ProgrammingError: permission denied for schema public
```
A migration roda em transação, então uma tentativa recusada não deixa resíduo no banco.
> **Nunca rode `migrate` num banco remoto compartilhado ou de produção** sem autorização
> do dono do banco. A alternativa "peça um `GRANT CREATE ON SCHEMA public`" existe, mas
> aplica o schema novo num banco que outras pessoas usam — prefira a cópia local.
## 4) Cópia local do banco (quando há migration pendente)
Copia o banco que você está apontando hoje — `demo` ou qualquer outro — para um Postgres
local, onde você tem liberdade total de `migrate`, reset e escrita, sem tocar no remoto.
São ~1 min para 61 MB. Os comandos leem a origem do próprio `sapl/.env`, então valem para
qualquer banco de origem.
**4.1 — Dump da origem** (só precisa de `SELECT`; o usuário remoto tem):
```bash
DB=$(grep '^DATABASE_URL=' sapl/.env | cut -d= -f2-)
docker run --rm -e PGURL="$DB" -v /tmp:/dump postgres:17-alpine \
sh -c 'pg_dump "$PGURL" --no-owner --no-privileges --format=custom --file=/dump/origem.dump'
```
`--no-owner --no-privileges` descarta os donos e ACLs do servidor de origem, que não
existem na sua máquina.
## 3) Testar conexão (opcional) **4.2 — Postgres local numa rede Docker própria:**
```bash ```bash
python manage.py showmigrations --plan docker network create sapl-net
docker run -d --name sapl-postgres --network sapl-net -p 5433:5432 \
-e POSTGRES_USER=sapl -e POSTGRES_PASSWORD=sapl -e POSTGRES_DB=demo \
postgres:17-alpine
```
A rede é o que permite o container do SAPL enxergar o banco pelo hostname `sapl-postgres`.
A porta `5433` no host é só para você abrir um cliente SQL (DBeaver, psql) de fora.
**4.3 — Restaurar:**
```bash
docker cp /tmp/origem.dump sapl-postgres:/tmp/origem.dump
docker exec sapl-postgres pg_restore -U sapl -d demo \
--no-owner --no-privileges /tmp/origem.dump
docker exec sapl-postgres psql -U sapl -d demo -Atc \
"select count(*) from information_schema.tables where table_schema='public';"
```
A contagem deve bater com a da origem (163 tabelas no `demo` em 13/08/2026).
**4.4 — Apontar o `.env` para o banco local**, mantendo a URL remota comentada para voltar
depois:
```env
# DATABASE_URL_REMOTO=postgresql://<usuario>:<senha>@demo.legisinc.com.br:5432/demo
DATABASE_URL=postgresql://sapl:sapl@sapl-postgres:5432/demo
``` ```
## 4) Subir o servidor Django > Se preferir guardar um backup do `.env`, **não deixe o arquivo dentro do repositório**:
> o `.gitignore` cobre `sapl/.env`, mas não variações como `.env.bak`, e a senha do remoto
> vazaria no commit. Guarde fora da árvore do projeto.
**4.5 — Conectar o container do SAPL à rede e aplicar a migration:**
```bash
docker network connect sapl-net sapl-localhost
docker restart sapl-localhost
docker exec sapl-localhost python manage.py migrate
docker exec sapl-localhost python manage.py showmigrations | grep '\[ \]' # sem saída
```
Se o container ainda não existe, crie-o já com `--network sapl-net` (seção 5).
**Para voltar ao banco remoto**: reponha a `DATABASE_URL` remota no `.env` e reinicie o
container. Os containers locais podem ficar parados (`docker stop sapl-postgres`) e serem
reaproveitados depois; para refazer a cópia do zero, `docker rm -f sapl-postgres` e repita
os passos 4.2 e 4.3.
## 5) Subir o servidor Django
### Docker
```bash
docker run -d --name sapl-localhost -p 8001:8001 \
-v "$PWD":/sapl-dev -w /sapl-dev sapl:localhost \
python manage.py runserver 0.0.0.0:8001
```
Se estiver usando a cópia local do banco (seção 4), acrescente `--network sapl-net` para o
container enxergar o `sapl-postgres`.
O repositório é montado como volume, então o autoreload pega as edições no código.
```bash
docker logs -f sapl-localhost # acompanhar o log
docker restart sapl-localhost # reiniciar
docker rm -f sapl-localhost # derrubar
```
### virtualenv
```bash ```bash
python manage.py runserver 0.0.0.0:8001 python manage.py runserver 0.0.0.0:8001
``` ```
Se preferir sem autoreload: `python manage.py runserver 0.0.0.0:8001 --noreload`. Se preferir sem autoreload: `python manage.py runserver 0.0.0.0:8001 --noreload`.
## 6) Verificar
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8001/ # 200
curl -s http://localhost:8001/ | grep -o '<title>[^<]*</title>'
```
Acesse <http://localhost:8001> — o título deve ser
`SGVP - Câmara Municipal de Franco da Rocha`. Use os usuários já cadastrados no banco
(os mesmos do remoto, se você fez a cópia da seção 4) para logar.

2
knowledge

@ -1 +1 @@
Subproject commit bce4eae59d594182a8b5451b509b141c9c0df416 Subproject commit 0290845521c9419992038a2499b3e8f6302b6e56

33
sapl/integracao_hub/migrations/0002_anexoproposicao.py

@ -0,0 +1,33 @@
# Generated by Django 2.2.28 on 2026-08-18 14:13
from django.db import migrations, models
import django.db.models.deletion
import sapl.integracao_hub.models
class Migration(migrations.Migration):
dependencies = [
('materia', '0094_add_anexoproposicao'),
('integracao_hub', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AnexoProposicao',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('arquivo', models.FileField(upload_to=sapl.integracao_hub.models._caminho_anexo, verbose_name='Arquivo')),
('nome_original', models.CharField(max_length=255, verbose_name='Nome original')),
('mime', models.CharField(blank=True, max_length=100, verbose_name='Tipo MIME')),
('tamanho_bytes', models.BigIntegerField(verbose_name='Tamanho (bytes)')),
('hash_sha256', models.CharField(max_length=64, verbose_name='SHA-256 dos bytes recebidos')),
('recebido_em', models.DateTimeField(auto_now_add=True, verbose_name='Recebido em')),
('proposicao', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='anexos_do_app', to='materia.Proposicao', verbose_name='Proposição')),
],
options={
'verbose_name': 'Anexo de Proposição (app)',
'verbose_name_plural': 'Anexos de Proposição (app)',
},
),
]

56
sapl/integracao_hub/models.py

@ -33,3 +33,59 @@ class EventoRecebido(models.Model):
def __str__(self): def __str__(self):
return str(self.chave_idempotencia) return str(self.chave_idempotencia)
def _caminho_anexo(instancia, nome):
return 'integracao_hub/proposicao_%s/%s' % (instancia.proposicao_id, nome)
class AnexoProposicao(models.Model):
"""Anexo GERAL vindo do app (foto, vídeo, qualquer mídia) — não é o texto oficial.
Decisão do arquiteto (17/08/2026): o que o vereador anexa no app é evidência da
demanda (foto da rua, vídeo da indicação), não o documento legislativo este o SAPL
continua gerando pelo fluxo próprio (texto_original). Por ora GRAVA, sem tela: a
consulta vem depois. Vive neste app isolado para custo zero de rebase do fork.
O hash é calculado AQUI, dos bytes recebidos: é o SAPL dizendo o que ele guardou,
fechando a cadeia app -> hub -> legislativo (o hub confere o hash do app antes de
entregar; nós registramos o que chegou).
"""
proposicao = models.ForeignKey(
'materia.Proposicao',
on_delete=models.PROTECT,
related_name='anexos_do_app',
verbose_name=_('Proposição'))
arquivo = models.FileField(
upload_to=_caminho_anexo,
verbose_name=_('Arquivo'))
nome_original = models.CharField(
max_length=255,
verbose_name=_('Nome original'))
mime = models.CharField(
max_length=100,
blank=True,
verbose_name=_('Tipo MIME'))
tamanho_bytes = models.BigIntegerField(
verbose_name=_('Tamanho (bytes)'))
hash_sha256 = models.CharField(
max_length=64,
verbose_name=_('SHA-256 dos bytes recebidos'))
recebido_em = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Recebido em'))
class Meta:
verbose_name = _('Anexo de Proposição (app)')
verbose_name_plural = _('Anexos de Proposição (app)')
def __str__(self):
return '%s (%s)' % (self.nome_original, self.proposicao_id)

4
sapl/integracao_hub/tests/test_poll.py

@ -16,7 +16,7 @@ BASE = '/api/integracao/poll/'
@pytest.fixture() @pytest.fixture()
def cliente_hub(db): def cliente_hub(db):
usuario = baker.make('auth.User') usuario = baker.make('auth.User', username='hub-teste')
permissao = Permission.objects.get( permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar') content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao) usuario.user_permissions.add(permissao)
@ -158,7 +158,7 @@ def test_limite_e_respeitado(cliente_hub):
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_poll_sem_permissao_da_403(db): def test_poll_sem_permissao_da_403(db):
usuario = baker.make('auth.User') usuario = baker.make('auth.User', username='hub-teste')
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token. # Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario) token, _ = Token.objects.get_or_create(user=usuario)

37
sapl/integracao_hub/tests/test_recepcao.py

@ -1,3 +1,4 @@
import hashlib
import uuid import uuid
import pytest import pytest
@ -24,7 +25,7 @@ def app_config(db):
@pytest.fixture() @pytest.fixture()
def cliente_hub(db): def cliente_hub(db):
usuario = baker.make('auth.User') usuario = baker.make('auth.User', username='hub-teste')
permissao = Permission.objects.get( permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar') content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao) usuario.user_permissions.add(permissao)
@ -103,30 +104,44 @@ def test_texto_e_justificativa_viram_observacao(cliente_hub, app_config,
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_arquivo_vira_texto_original(cliente_hub, app_config, autor, tipo): def test_anexo_do_app_vira_anexo_geral_nao_texto_oficial(
arquivo = SimpleUploadedFile( cliente_hub, app_config, autor, tipo):
'proposicao.pdf', b'%PDF-1.4 conteudo', 'application/pdf') """Anexo do app e evidencia da demanda (foto, video), nao o documento legislativo.
Decisao do arquiteto (17/08/2026): o texto_original continua sendo do fluxo
proprio do SAPL; o que chega do app vira AnexoProposicao gravado com hash
dos bytes recebidos, consultavel no futuro, sem tela por ora.
"""
conteudo = b'video-da-rua-esburacada'
arquivo = SimpleUploadedFile('rua.mp4', conteudo, 'video/mp4')
resposta = cliente_hub.post( resposta = cliente_hub.post(
URL, corpo(autor, tipo, arquivos=arquivo), format='multipart') URL, corpo(autor, tipo, arquivos=arquivo), format='multipart')
assert resposta.status_code == 201 assert resposta.status_code == 201
proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id']) proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id'])
assert proposicao.texto_original assert not proposicao.texto_original # o oficial nasce no fluxo do SAPL
anexo = proposicao.anexos_do_app.get()
assert anexo.nome_original == 'rua.mp4'
assert anexo.mime == 'video/mp4'
assert anexo.tamanho_bytes == len(conteudo)
assert anexo.hash_sha256 == hashlib.sha256(conteudo).hexdigest()
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_mais_de_um_arquivo_e_rejeitado(cliente_hub, app_config, autor, tipo): def test_varios_anexos_sao_aceitos(cliente_hub, app_config, autor, tipo):
# O limite de 1 era premissa errada (anexo != texto_original). Foto + video convivem.
arquivos = [ arquivos = [
SimpleUploadedFile('a.pdf', b'%PDF-1.4 a', 'application/pdf'), SimpleUploadedFile('foto.jpg', b'jpg-bytes', 'image/jpeg'),
SimpleUploadedFile('b.pdf', b'%PDF-1.4 b', 'application/pdf'), SimpleUploadedFile('video.mp4', b'mp4-bytes', 'video/mp4'),
] ]
resposta = cliente_hub.post( resposta = cliente_hub.post(
URL, corpo(autor, tipo, arquivos=arquivos), format='multipart') URL, corpo(autor, tipo, arquivos=arquivos), format='multipart')
assert resposta.status_code == 422 assert resposta.status_code == 201
assert Proposicao.objects.count() == 0 proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id'])
assert proposicao.anexos_do_app.count() == 2
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
@ -160,7 +175,7 @@ def test_chave_invalida_da_422(cliente_hub, app_config, autor, tipo):
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_sem_permissao_da_403(db, app_config, autor, tipo): def test_sem_permissao_da_403(db, app_config, autor, tipo):
usuario = baker.make('auth.User') usuario = baker.make('auth.User', username='hub-teste')
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token. # Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario) token, _ = Token.objects.get_or_create(user=usuario)

4
sapl/integracao_hub/tests/test_reconciliacao.py

@ -15,7 +15,7 @@ ENVIADAS = '/api/integracao/poll/proposicoes-enviadas/'
@pytest.fixture() @pytest.fixture()
def cliente_hub(db): def cliente_hub(db):
usuario = baker.make('auth.User') usuario = baker.make('auth.User', username='hub-teste')
usuario.user_permissions.add( usuario.user_permissions.add(
Permission.objects.get( Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar')) content_type__app_label='integracao_hub', codename='pode_integrar'))
@ -92,7 +92,7 @@ def test_inventario_traz_a_materia_de_cada_tramitacao(cliente_hub):
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_inventario_sem_permissao_da_403(db): def test_inventario_sem_permissao_da_403(db):
usuario = baker.make('auth.User') usuario = baker.make('auth.User', username='hub-teste')
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario. # get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token. # Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario) token, _ = Token.objects.get_or_create(user=usuario)

31
sapl/integracao_hub/views.py

@ -1,4 +1,5 @@
import logging import logging
import hashlib
import uuid import uuid
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
@ -18,7 +19,7 @@ from sapl.materia.forms import ProposicaoForm
from sapl.materia.models import Proposicao, Tramitacao from sapl.materia.models import Proposicao, Tramitacao
from sapl.utils import get_client_ip from sapl.utils import get_client_ip
from .models import EventoRecebido from .models import AnexoProposicao, EventoRecebido
from .serializacao import serializar_proposicao, serializar_tramitacao from .serializacao import serializar_proposicao, serializar_tramitacao
LIMITE_PADRAO = 100 LIMITE_PADRAO = 100
@ -80,15 +81,12 @@ class RecepcaoProposicaoView(IntegracaoHubView):
'autor %s inexistente no SAPL — conferir o mapa de identidade ' 'autor %s inexistente no SAPL — conferir o mapa de identidade '
'no hub' % request.data.get('autor')) 'no hub' % request.data.get('autor'))
# Anexos do app são GERAIS (foto, vídeo — evidência da demanda), não o texto
# oficial (decisão do arquiteto, 17/08/2026). O texto_original continua sendo do
# fluxo próprio do SAPL; o que chega aqui vira AnexoProposicao, só gravação por ora.
arquivos = request.FILES.getlist('arquivos') arquivos = request.FILES.getlist('arquivos')
if len(arquivos) > 1:
return self._erro(
'a proposição no SAPL comporta um único texto_original; '
'recebidos %d arquivos' % len(arquivos))
form = ProposicaoForm( form = ProposicaoForm(data=self._dados_do_form(request))
data=self._dados_do_form(request),
files={'texto_original': arquivos[0]} if arquivos else None)
form.instance.autor = autor form.instance.autor = autor
if not form.is_valid(): if not form.is_valid():
@ -99,6 +97,8 @@ class RecepcaoProposicaoView(IntegracaoHubView):
proposicao = form.save() proposicao = form.save()
EventoRecebido.objects.create( EventoRecebido.objects.create(
chave_idempotencia=chave, proposicao=proposicao) chave_idempotencia=chave, proposicao=proposicao)
for arquivo in arquivos:
self._gravar_anexo(proposicao, arquivo)
except IntegrityError: except IntegrityError:
# Entrega concorrente do mesmo evento: quem perdeu a corrida # Entrega concorrente do mesmo evento: quem perdeu a corrida
# devolve a proposição de quem ganhou. # devolve a proposição de quem ganhou.
@ -126,12 +126,25 @@ class RecepcaoProposicaoView(IntegracaoHubView):
'tipo': request.data.get('tipo'), 'tipo': request.data.get('tipo'),
'descricao': request.data.get('ementa'), 'descricao': request.data.get('ementa'),
'observacao': observacao.strip(), 'observacao': observacao.strip(),
'tipo_texto': 'D' if request.FILES.getlist('arquivos') else '', # Sem 'D': anexo geral não é texto digital da proposição — o documento
# oficial nasce no fluxo do SAPL (Editar Documento / template).
'tipo_texto': '',
'user': request.user.pk, 'user': request.user.pk,
'ip': get_client_ip(request), 'ip': get_client_ip(request),
'ultima_edicao': timezone.now(), 'ultima_edicao': timezone.now(),
} }
def _gravar_anexo(self, proposicao, arquivo):
conteudo = arquivo.read()
arquivo.seek(0)
AnexoProposicao.objects.create(
proposicao=proposicao,
arquivo=arquivo,
nome_original=arquivo.name or 'sem-nome',
mime=getattr(arquivo, 'content_type', '') or '',
tamanho_bytes=len(conteudo),
hash_sha256=hashlib.sha256(conteudo).hexdigest())
def _erro(self, detalhe, erros=None): def _erro(self, detalhe, erros=None):
corpo = {'detalhe': detalhe} corpo = {'detalhe': detalhe}
if erros is not None: if erros is not None:

Loading…
Cancel
Save