diff --git a/docs/CONFIGURACAO_BD_REMOTO.md b/docs/CONFIGURACAO_BD_REMOTO.md index 32575d4d5..9e52c2d86 100644 --- a/docs/CONFIGURACAO_BD_REMOTO.md +++ b/docs/CONFIGURACAO_BD_REMOTO.md @@ -2,6 +2,19 @@ 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 ### 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_EMAIL: email@dominio.net DEBUG: 'False' - DATABASE_URL: postgresql://bruno:Sapl%402025@sgvp.com.br:5432/sapl + DATABASE_URL: postgresql://:@demo.legisinc.com.br:5432/demo # ... 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: - `@` → `%40` - `:` → `%3A` - `/` → `%2F` -Exemplo: `Sapl@2025` vira `Sapl%402025` +Exemplo: `S3nh@2026` vira `S3nh%402026` ### 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: ```bash -docker run --rm postgres:10.5-alpine psql \ - "postgresql://bruno:Sapl%402025@sgvp.com.br:5432/sapl" \ +docker run --rm postgres:17-alpine psql \ + "postgresql://:@demo.legisinc.com.br:5432/demo" \ -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://:@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 ```bash -cd /home/bruno/sapl/docker +cd ~/dev/sapl/docker docker-compose build sapl ``` @@ -149,7 +212,7 @@ Se estiver usando um banco remoto existente, use os usuários já cadastrados ne │ (Aplicação) │ └────────┬────────┘ │ - ├──→ sgvp.com.br:5432 (PostgreSQL Remoto) + ├──→ demo.legisinc.com.br:5432 (PostgreSQL 17 Remoto) │ ├──→ SOLR (Porta 8983) │ @@ -175,11 +238,39 @@ docker exec sapl ls -la /var/interlegis/sapl/run/ docker exec sapl env | grep DATABASE_URL # 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" \ -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 ```bash @@ -235,6 +326,10 @@ sapl/ ## Notas Finais - 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 -- O timezone do banco é configurado automaticamente para `America/Sao_Paulo` +- As migrations são aplicadas automaticamente na inicialização (`migrate_db` no `start.sh`) + — 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` só + pula essa etapa se o banco já estiver em `UTC` - Os arquivos de media e data ficam em volumes Docker separados diff --git a/docs/LOCALHOST_SETUP.md b/docs/LOCALHOST_SETUP.md index 55f9b54b7..c7c4ab9bd 100644 --- a/docs/LOCALHOST_SETUP.md +++ b/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 +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 -cd /root/dev/sapl -python3 -m venv .venv +sed 's|dev-requirements.txt|requirements.txt|' docker/Dockerfile.dev > /tmp/Dockerfile.localhost +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 pip install -r requirements/requirements.txt ``` ## 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 -DATABASE_URL=postgresql://kemuel:kasepulvida@sgvp.com.br:5432/sapl +DATABASE_URL=postgresql://:@demo.legisinc.com.br:5432/demo SECRET_KEY= DEBUG=True +DJANGO_DEBUG=True EMAIL_USE_TLS=True 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 -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://:@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 python manage.py runserver 0.0.0.0:8001 ``` 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 '[^<]*' +``` + +Acesse — 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. diff --git a/knowledge b/knowledge index bce4eae59..029084552 160000 --- a/knowledge +++ b/knowledge @@ -1 +1 @@ -Subproject commit bce4eae59d594182a8b5451b509b141c9c0df416 +Subproject commit 0290845521c9419992038a2499b3e8f6302b6e56 diff --git a/sapl/integracao_hub/migrations/0002_anexoproposicao.py b/sapl/integracao_hub/migrations/0002_anexoproposicao.py new file mode 100644 index 000000000..22279a9cc --- /dev/null +++ b/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)', + }, + ), + ] diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index a9a9ff723..3f9e95ee1 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -33,3 +33,59 @@ class EventoRecebido(models.Model): def __str__(self): 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 só 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) + diff --git a/sapl/integracao_hub/tests/test_poll.py b/sapl/integracao_hub/tests/test_poll.py index 65ec83db8..ac060b7d1 100644 --- a/sapl/integracao_hub/tests/test_poll.py +++ b/sapl/integracao_hub/tests/test_poll.py @@ -16,7 +16,7 @@ BASE = '/api/integracao/poll/' @pytest.fixture() def cliente_hub(db): - usuario = baker.make('auth.User') + usuario = baker.make('auth.User', username='hub-teste') permissao = Permission.objects.get( content_type__app_label='integracao_hub', codename='pode_integrar') usuario.user_permissions.add(permissao) @@ -158,7 +158,7 @@ def test_limite_e_respeitado(cliente_hub): @pytest.mark.django_db(transaction=False) 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. # Um create() aqui colide com a UNIQUE de authtoken_token. token, _ = Token.objects.get_or_create(user=usuario) diff --git a/sapl/integracao_hub/tests/test_recepcao.py b/sapl/integracao_hub/tests/test_recepcao.py index b869ea9a5..5c4d5b7d4 100644 --- a/sapl/integracao_hub/tests/test_recepcao.py +++ b/sapl/integracao_hub/tests/test_recepcao.py @@ -1,3 +1,4 @@ +import hashlib import uuid import pytest @@ -24,7 +25,7 @@ def app_config(db): @pytest.fixture() def cliente_hub(db): - usuario = baker.make('auth.User') + usuario = baker.make('auth.User', username='hub-teste') permissao = Permission.objects.get( content_type__app_label='integracao_hub', codename='pode_integrar') 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) -def test_arquivo_vira_texto_original(cliente_hub, app_config, autor, tipo): - arquivo = SimpleUploadedFile( - 'proposicao.pdf', b'%PDF-1.4 conteudo', 'application/pdf') +def test_anexo_do_app_vira_anexo_geral_nao_texto_oficial( + cliente_hub, app_config, autor, tipo): + """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( URL, corpo(autor, tipo, arquivos=arquivo), format='multipart') assert resposta.status_code == 201 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) -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 = [ - SimpleUploadedFile('a.pdf', b'%PDF-1.4 a', 'application/pdf'), - SimpleUploadedFile('b.pdf', b'%PDF-1.4 b', 'application/pdf'), + SimpleUploadedFile('foto.jpg', b'jpg-bytes', 'image/jpeg'), + SimpleUploadedFile('video.mp4', b'mp4-bytes', 'video/mp4'), ] resposta = cliente_hub.post( URL, corpo(autor, tipo, arquivos=arquivos), format='multipart') - assert resposta.status_code == 422 - assert Proposicao.objects.count() == 0 + assert resposta.status_code == 201 + proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id']) + assert proposicao.anexos_do_app.count() == 2 @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) 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. # Um create() aqui colide com a UNIQUE de authtoken_token. token, _ = Token.objects.get_or_create(user=usuario) diff --git a/sapl/integracao_hub/tests/test_reconciliacao.py b/sapl/integracao_hub/tests/test_reconciliacao.py index d0e1a945c..1b5c606a4 100644 --- a/sapl/integracao_hub/tests/test_reconciliacao.py +++ b/sapl/integracao_hub/tests/test_reconciliacao.py @@ -15,7 +15,7 @@ ENVIADAS = '/api/integracao/poll/proposicoes-enviadas/' @pytest.fixture() def cliente_hub(db): - usuario = baker.make('auth.User') + usuario = baker.make('auth.User', username='hub-teste') usuario.user_permissions.add( Permission.objects.get( 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) 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. # Um create() aqui colide com a UNIQUE de authtoken_token. token, _ = Token.objects.get_or_create(user=usuario) diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py index 91f076cfb..9e8e54ec3 100644 --- a/sapl/integracao_hub/views.py +++ b/sapl/integracao_hub/views.py @@ -1,4 +1,5 @@ import logging +import hashlib import uuid 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.utils import get_client_ip -from .models import EventoRecebido +from .models import AnexoProposicao, EventoRecebido from .serializacao import serializar_proposicao, serializar_tramitacao LIMITE_PADRAO = 100 @@ -80,15 +81,12 @@ class RecepcaoProposicaoView(IntegracaoHubView): 'autor %s inexistente no SAPL — conferir o mapa de identidade ' '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') - if len(arquivos) > 1: - return self._erro( - 'a proposição no SAPL comporta um único texto_original; ' - 'recebidos %d arquivos' % len(arquivos)) - form = ProposicaoForm( - data=self._dados_do_form(request), - files={'texto_original': arquivos[0]} if arquivos else None) + form = ProposicaoForm(data=self._dados_do_form(request)) form.instance.autor = autor if not form.is_valid(): @@ -99,6 +97,8 @@ class RecepcaoProposicaoView(IntegracaoHubView): proposicao = form.save() EventoRecebido.objects.create( chave_idempotencia=chave, proposicao=proposicao) + for arquivo in arquivos: + self._gravar_anexo(proposicao, arquivo) except IntegrityError: # Entrega concorrente do mesmo evento: quem perdeu a corrida # devolve a proposição de quem ganhou. @@ -126,12 +126,25 @@ class RecepcaoProposicaoView(IntegracaoHubView): 'tipo': request.data.get('tipo'), 'descricao': request.data.get('ementa'), '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, 'ip': get_client_ip(request), '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): corpo = {'detalhe': detalhe} if erros is not None: