diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aadcec8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.env +.env.* +.venv +**/__pycache__ +**/*.sqlite3 +web/private_media +web/staticfiles +work diff --git a/.env b/.env deleted file mode 100644 index cb69474..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -SUPABASE_URL="https://example.supabase.co" -SUPABASE_SERVICE_ROLE_KEY="example-service-role-key" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..419d119 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,44 @@ +name: HIMP Web +on: + push: + pull_request: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: himp + POSTGRES_USER: himp + POSTGRES_PASSWORD: ci-only-password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + env: + DJANGO_DEBUG: "true" + DATABASE_URL: postgres://himp:ci-only-password@localhost:5432/himp + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-web.txt + - run: pip install -r requirements-web.txt + - run: python web/manage.py makemigrations --check --dry-run + - run: python web/manage.py migrate --noinput + - run: python web/manage.py bootstrap_roles + - run: python web/manage.py collectstatic --noinput + - run: python web/manage.py test office --verbosity 2 + - name: Production settings checks + env: + DJANGO_DEBUG: "false" + DJANGO_SECRET_KEY: ci-only-placeholder-long-enough-for-security-checks-1234567890 + DJANGO_ALLOWED_HOSTS: himp.example.com + DJANGO_CSRF_TRUSTED_ORIGINS: https://himp.example.com + run: python web/manage.py check --deploy --fail-level WARNING diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0546f1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +.venv/ +*.sqlite3 +web/private_media/ +web/staticfiles/ +credentials*.json +token*.json +*.zip +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27a21a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 DJANGO_DEBUG=false +WORKDIR /app +COPY requirements-web.txt ./ +RUN pip install --no-cache-dir -r requirements-web.txt +COPY web/ ./ +RUN DJANGO_DEBUG=true python manage.py collectstatic --noinput \ + && groupadd --gid 10001 himp \ + && useradd --uid 10001 --gid himp --no-create-home himp \ + && mkdir -p private_media && chown himp:himp private_media +USER himp +EXPOSE 8000 +CMD ["gunicorn", "himp.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "60", "--access-logfile", "-"] diff --git a/README.md b/README.md index 16153b5..afd124a 100644 --- a/README.md +++ b/README.md @@ -1,408 +1,127 @@ -# HiMP Project +# HIMP Web -**HiMP Project** is a desktop application built with **Tkinter** originally designed to help law offices manage clients, cases and documents — but it is intentionally generic and can be adapted to other domains. -Key capabilities: +Aplicação Web Python/Django com interface responsiva em português. Uma instalação serve os navegadores de Windows, macOS e dispositivos móveis, sem compilar executáveis para cada plataforma. O desktop fica como referência durante a transição; a documentação original está em [docs/DESKTOP.md](docs/DESKTOP.md). O ponto de entrada Web é `web/manage.py`. -- Client management (personal data, multiple processes per client) -- Case / process management (statuses, phases, attachments) -- Payments management (status, references, amounts) -- Google Calendar integration for scheduling (create / sync events) -- Automatic document generation from `.docx` templates -- Backed by a **Supabase (PostgreSQL)** database +## Funcionalidades ---- +- Painel com agenda, documentos pendentes e pagamentos em atraso. +- Relatórios financeiros com filtros por período/cliente, evolução mensal, distribuição dos valores, recebimentos por motivo e tempo de atraso. Gráficos acessíveis e valores consultáveis no navegador, sem exportação obrigatória. +- Pesquisa, criação, consulta, edição e eliminação protegida de clientes, processos, etapas, pagamentos, agendamentos e documentos solicitados. +- Histórico de etapas e registos associados ao cliente. +- Listas na base: estados civis, géneros, motivos/durações de agenda e documentos solicitados. Entidades, tipos de processo, fases, motivos e estados de pagamento reutilizam as tabelas atuais. +- Modelos DOCX na administração, download autenticado e renderização num ambiente de templates restrito. +- Google Calendar por OAuth Web, credenciais cifradas e sincronização explícita por agendamento. +- Django Auth: senhas protegidas, sessões no servidor, CSRF, limite de tentativas, permissões e auditoria. +- Montantes decimais e referências textuais para preservar zeros iniciais em novos registos. -## Table of contents +## Desenvolvimento local -1. [Requirements](#requirements) -2. [Install Python dependencies](#install-python-dependencies) -3. [Project layout / important files](#project-layout--important-files) -4. [Google Calendar setup (credentials.json)](#google-calendar-setup-credentialsjson) -5. [Supabase (PostgreSQL) setup — database tables](#supabase-postgresql-setup---database-tables) -6. [Environment variables (`.env`) - example](#environment-variables-env---example) -7. [Document generator configuration](#document-generator-configuration) -8. [Application configuration (pagina.py)](#application-configuration-paginapy) -9. [Packaging / building an executable (PyInstaller)](#packaging--building-an-executable-pyinstaller) -10. [Run / Usage](#run--usage) -11. [Troubleshooting & notes](#troubleshooting--notes) -12. [Security, privacy & license notes](#security-privacy--license-notes) - ---- - -## Requirements - -- Python 3.10+ (recommend latest stable 3.x) -- Internet access during runtime for Supabase and Google Calendar integration -- A Supabase project with PostgreSQL (tables must be created — see SQL below) -- Google Cloud Console access to create `credentials.json` for Calendar API - -### Python libraries - -Install the required Python packages: - -```bash -pip3 install tkinter google-auth google-auth-oauthlib google-api-python-client pytz python-docx docxtpl pydantic email-validator supabase num2words python-dotenv -``` - -> Note: `tkinter` is included with many Python distributions; on some Linux systems you may need to install the OS package (for example `sudo apt install python3-tk`). - ---- - -## Install / Setup - -1. Clone your repository (or copy files) into a local folder: - -```bash -git clone -cd -``` - -2. Create and activate a virtual environment (recommended): - -```bash -python3 -m venv .venv -source .venv/bin/activate # Linux / macOS -# .venv\Scripts\activate # Windows PowerShell -pip install -r requirements.txt # if you maintain this file; otherwise use the pip line above -``` - -3. Create a `.env` file in the project root and populate the required environment variables (example below). - -4. Create the database tables in your Supabase project using the SQL in the next section. - -5. Place the Google Calendar `credentials.json` file inside the `Calendar/` folder. - -6. Configure `gerador_documentos.py` and `pagina.py` as described below. - ---- - -## Project layout / important files - -``` -HIMP_PROJECT/ -├── calendar/ -│ └── calendar.py # Google Calendar integration logic -│ -├── gerador_documentos/ -│ └── gerador_docs.py # Document generator configuration & logic -│ -├── Home_Page/ -│ ├── crud_support.py # CRUD helper functions for main app -│ └── pagina.py # Main application UI (home page) -│ -├── Login_Page/ -│ ├── loginpage_support.py # Login utility functions -│ └── loginpage.py # Login UI -│ -├── database.py # Supabase/PostgreSQL database connector -├── main.py # Application entry point -├── .env # Environment variables (Supabase keys, etc.) -└── requirements.txt # Python dependencies +Python 3.13 recomendado. SQLite é permitido para desenvolvimento sem dados reais quando `DJANGO_DEBUG=true` e `DATABASE_URL` está vazia. +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install -r requirements-web.txt +Copy-Item web/.env.example web/.env +python web/manage.py migrate +python web/manage.py bootstrap_roles +python web/manage.py createsuperuser +python web/manage.py runserver ``` ---- - -## Google Calendar setup (credentials.json) - -1. In the Google Cloud Console: - - - Create a project (or use an existing one). - - Enable the **Google Calendar API** for that project. - - Under **APIs & Services → Credentials**, create an OAuth 2.0 Client ID (choose Desktop app). - - Download the JSON file and save it as: `Calendar/credentials.json`. - -2. Place `credentials.json` at exactly: - `Calendar/credentials.json` (relative to project root). The app uses that file to perform OAuth and create tokens to access the Calendar API. - -3. On first run, the app will typically open a browser window to complete the OAuth flow and save a token file (commonly `token.json` or similar) — keep that token file in the `Calendar/` folder or as the app expects. - -> If you get `scopes` or permissions errors, re-check the OAuth credentials and ensure your OAuth consent screen is configured (external/internal as needed). - ---- - -## Supabase (PostgreSQL) setup — database tables - -Create the following tables in your Supabase project. You can paste this SQL into the Supabase SQL editor and run it. - -> NOTE: This SQL is provided exactly as given — adjust identifiers or column types if your Supabase/Postgres settings require different naming conventions. - -```sql -create table public.cliente ( - passaporte text null, - nif text null, - niss text null, - bi_cc_titulo_residência text null, - data_nascimento date null, - nome_completo text null, - cliente_id integer generated by default as identity not null, - gênero text null, - rua text null, - numero_rua text null, - complemento text null, - localidade text null, - código_postal text null, - profissão text null, - validade_passaporte date null, - validade_bi_cc date null, - email text null, - emissão_passaporte date null, - ddi bigint null, - contato text null, - nacionalidade text null, - local_emissão_passaporte text null, - naturalidade text null, - notas_documento text null, - estado_civil text null, - emissão_bi_cc date null, - constraint cliente_pkey primary key (cliente_id), - constraint cliente_nif_key unique (nif), - constraint cliente_niss_key unique (niss), - constraint cliente_passaporte_key unique (passaporte), - constraint cliente_titulo_residencia_key unique ("bi_cc_titulo_residência") -) TABLESPACE pg_default; - -create table public.agendamento ( - evento_id bigint generated by default as identity not null, - data_inicio timestamp without time zone null, - duracao text null, - motivo text null, - descricao text null, - google_event_id text null, - cliente_id integer null, - titulo text null, - constraint agendamento_pkey primary key (evento_id), - constraint agendamento_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id) -) TABLESPACE pg_default; - -create table public.documentos_cliente ( - id serial not null, - cliente_id integer not null, - documento_nome text not null, - entregue boolean null default false, - constraint documentos_cliente_pkey primary key (id), - constraint documentos_cliente_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id) on delete CASCADE -) TABLESPACE pg_default; - -create table public.entidade ( - entidade_id bigint generated by default as identity not null, - entidade text not null, - constraint entidade_pkey primary key (entidade_id) -) TABLESPACE pg_default; - -create table public.etapa_processo ( - etapa_id bigint generated by default as identity not null, - processo_id bigint not null, - fase_id bigint null, - data_fase date null, - observação text null, - constraint etapa_processo_pkey primary key (etapa_id), - constraint etapa_processo_cliente_processo_id_fkey foreign KEY (processo_id) references processo (processo_id), - constraint etapa_processo_fase_id_fkey foreign KEY (fase_id) references lista_fases_processo (fase_id) -) TABLESPACE pg_default; - -create table public.lista_fases_processo ( - fase_id bigint generated by default as identity not null, - fase text not null, - constraint lista_fases_processo_pkey primary key (fase_id) -) TABLESPACE pg_default; - -create table public.motivo ( - motivo_id bigint generated by default as identity not null, - motivo text not null, - constraint motivo_pkey primary key (motivo_id) -) TABLESPACE pg_default; - -create table public.pagamento ( - pagamento_id bigint generated by default as identity not null, - entidade integer null, - referencia integer null, - montante real null, - data_limite date null, - data_conclusao date null, - status_id bigint null, - motivo_id bigint null, - cliente_id bigint null, - constraint pagamento_pkey primary key (pagamento_id), - constraint pagamento_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id), - constraint pagamento_motivo_id_fkey foreign KEY (motivo_id) references motivo (motivo_id), - constraint pagamento_status_id_fkey foreign KEY (status_id) references status_pagamento (status_id) -) TABLESPACE pg_default; - -create table public.processo ( - entidade_id bigint null, - juiz text null, - processo_anexo_principal text null, - processo_id bigint generated by default as identity not null, - numero_processo text null, - cliente_id bigint not null, - tipo_do_processo_id bigint null, - constraint processo_pkey primary key (processo_id), - constraint processo_numero_processo_key unique (numero_processo), - constraint processo_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id), - constraint processo_entidade_id_fkey foreign KEY (entidade_id) references entidade (entidade_id), - constraint processo_tipo_do_processo_id_fkey foreign KEY (tipo_do_processo_id) references tipo_do_processo (tipo_do_processo_id) -) TABLESPACE pg_default; - -create table public.status_pagamento ( - status_id bigint generated by default as identity not null, - status text not null, - constraint status_pagamento_pkey primary key (status_id) -) TABLESPACE pg_default; - -create table public.tipo_do_processo ( - tipo_do_processo_id bigint generated by default as identity not null, - tipo_do_processo text not null, - constraint tipo_do_processo_pkey primary key (tipo_do_processo_id) -) TABLESPACE pg_default; - -create table public.users ( - id bigint generated by default as identity not null, - nif bigint not null, - password text not null, - constraint Users_pkey primary key (id), - constraint Users_nif_key unique (nif) -) TABLESPACE pg_default; -``` +Em macOS/Linux, use `source .venv/bin/activate` e `cp web/.env.example web/.env`; os comandos Python são iguais. Abra `http://127.0.0.1:8000`. A chave temporária de desenvolvimento só funciona com DEBUG e não deve ser usada em produção. -After creating tables, insert any needed static lists (e.g., `lista_fases_processo`, `motivo`, `status_pagamento`, `tipo_do_processo`, `entidade`) that your app expects. +Não há registo público de contas. O administrador cria utilizadores em `/admin/`, atribui funções e define senhas temporárias de pelo menos 12 caracteres; cada pessoa altera a senha no menu da conta. ---- +| Função | Acesso | +|---|---| +| Consulta | Consultar as seis áreas de gestão | +| Gestão | Consulta + criar/editar registos, gerar DOCX e sincronizar agenda | +| Administração | Gestão + eliminar registos e gerir listas | -## Environment variables (`.env`) — example +O acesso ao admin exige também `is_staff`. Só o responsável técnico deve ser superuser: upload/edição dos modelos e ligação Google estão restritos a superusers de confiança. Contas sem função não veem registos. Esta versão assume **um único escritório**, com permissões por área; não implementa isolamento por organização, cliente ou advogado. -Create a file named `.env` in the project root and **do not commit it to git**. +## Relatórios financeiros -Example `.env` template (edit values to match your Supabase project and any other keys used by your app): +Abra **Relatórios** no menu (exige `office.view_pagamento`). Escolha este mês, últimos seis meses, este ano ou um intervalo personalizado de até 36 meses; também pode filtrar por cliente. -```ini -# Supabase -SUPABASE_URL=https://your-project-ref.supabase.co -SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +Recebimentos usam a **data de conclusão**. Valores por receber usam a **data limite**, e os atrasados são um subconjunto dos pendentes, não um valor adicional a somar. A previsão inclui todos os pagamentos com vencimento no período, mesmo os já recebidos. Por isso, um pagamento recebido num mês diferente do vencimento aparece em meses distintos nas duas séries. -# Google Calendar -# Path to the credentials file (relative to project root) -CALENDAR_CREDENTIALS_PATH=Calendar/credentials.json +Os gráficos não dependem dos nomes hardcoded dos estados. Registos sem montante/data ou com montantes negativos são identificados; não são inventados valores. Há valores por mês acessíveis abaixo do gráfico. Despesas e lucro não são calculados, pois a aplicação só tem pagamentos de clientes. -``` +## Base de dados e listas -> Which Supabase key to use: -> -> - For client-side-like operations use the **anon** key. -> - For server-side privileged operations (insert/update that require bypassing RLS / elevated permissions), the **service role** key is required. Check your code to see which key the application uses. Keep the service role key secret. +Consulte [docs/MIGRATION.md](docs/MIGRATION.md) antes de ligar uma base Supabase existente. O ORM preserva nomes de tabelas e colunas. A Web liga diretamente ao PostgreSQL no servidor, sem chave Supabase de serviço no navegador. O `.env` do desktop continha exemplos e foi retirado do controlo de versões; `web/.env` é separado e não carrega esse ficheiro. ---- +As listas são editadas em **Opções configuráveis**. Durações precisam de `minutes` para sincronizar com Google. Desativar uma opção mantém o valor histórico no respetivo registo; renomeá-la não reescreve dados antigos. `legacy_defaults.json` é o snapshot da migração `0004`, não a fonte em execução. Não o altere depois de aplicar migrações; faça mudanças futuras no admin/base. -## Document generator configuration +## Documentos -The document generator reads a dictionary `documentos_info` inside `gerador_docs.py` +O repositório original não inclui ficheiros DOCX. Os nove modelos são importados **inativos**; um superuser deve carregar os ficheiros reais (até 5 MB) e ativá-los. Configure `required_fields` como lista JSON, por exemplo `["nome", "nif", "endereço", "data_documento"]`. Os campos dos modelos originais são preservados. Dados do cliente são preenchidos automaticamente; contratos também aceitam valor, prestações e início. Documentos são gerados em memória e descarregados por uma rota autenticada. -```python -documentos_info = { - "Modelo de Documento": { - "arquivo": "modelo_documento.docx", - "campos": ["nome", "nif", "endereço"] - } -} -``` +`processo_anexo_principal` conserva uma referência textual. A área Documentos controla entregas, gera DOCX e permite guardar ficheiros de clientes no Google Drive. Valores por prestação são arredondados a cêntimos; confirme eventuais acertos no contrato. -### Steps to add templates +## Google Drive por cliente -1. Put your `.docx` template in `gerador_documentos/` (for example `gerador_documentos/modelo_documento.docx`). +Ative a **Google Drive API** no mesmo projeto Google Cloud e adicione o escopo `https://www.googleapis.com/auth/drive.file` à configuração OAuth. Nas credenciais **Web application**, registe também o URI exato `https://seu-dominio/google/drive/callback/`. Configure `GOOGLE_DRIVE_REDIRECT_URI` no `web/.env` local ou no gestor de segredos da instalação, além de `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` e `TOKEN_ENCRYPTION_KEY` usados pela integração Google. Reinicie o servidor após alterar variáveis. Para testar em localhost, registe exatamente `http://127.0.0.1:8017/google/drive/callback/` e use esse valor; em produção use HTTPS. -2. In `gerador_docs.py`, add an entry to `documentos_info` for each template: +Um superuser liga a conta em **Visão geral → Integração Google Drive → Ligar Google Drive**. A autorização do Drive é separada da agenda. O HIMP cria uma pasta principal **HIMP**, com subpastas **cliente_id - Nome**. No detalhe do cliente, **Criar pasta Google Drive** prepara a pasta; quando já existe, **Atualizar nome da pasta** conserva o ID e ajusta o nome. O primeiro upload também cria as pastas automaticamente. - - Key = display name (this is the name shown in the UI) - - `arquivo` = file name inside the `gerador_documentos/` folder - - `campos` = list of field names the template expects (these must match the fields you pass when rendering) +Ao criar um registo em Documentos, pode enviar um ficheiro opcional; no detalhe de um registo sem ficheiro também existe **Guardar no Google Drive**. Aceita PDF, DOCX sem macros, JPG e PNG até 10 MB. O ficheiro é enviado pelo servidor; a base de dados guarda apenas IDs/referências e nome. **Gerar documento**, a partir do cliente, permite guardar o DOCX diretamente no Drive. Documentos são acedidos pela ficha do cliente: não existe entrada geral no menu lateral e os endereços de listagem/criação sem cliente encaminham para Clientes. No fluxo Cliente → Documentos → Ver todos → Novo registo, o cliente já fica associado e não precisa de ser escolhido novamente. Pesquisa, cancelamento e retorno à lista mantêm o contexto. -3. Example: +Os tokens OAuth são cifrados e não aparecem nos formulários/admin. Não torne públicas as pastas: abrir ficheiros no Drive exige uma conta Google com acesso. A integração trabalha com ficheiros criados pelo HIMP, sem importar automaticamente ficheiros adicionados manualmente pelo Drive nem gerir partilhas. Se o upload falhar, o registo local é preservado e pode repetir o envio; referências e hash permitem recuperar um envio remoto concluído sem duplicar o mesmo ficheiro. Cada registo aceita um ficheiro; para nova versão crie outro registo. Um documento ligado ao Drive não pode mudar de cliente pelo formulário. Eliminar um registo local preserva o ficheiro/pasta no Drive. -```python -documentos_info = { - "Power of Attorney - Example": { - "arquivo": "procuracao_example.docx", - "campos": ["nome", "passaporte", "nif", "endereço", "data_documento"] - } -} -``` +A migração `0005` adiciona as referências de pastas/ficheiros e a tabela privada de ligação. Reveja também `docs/supabase_hardening.sql` para bloquear acesso público à nova tabela. Nenhuma conta Google é ligada automaticamente; é necessário configurar as credenciais e autorizar a conta do escritório. A aplicação continua a funcionar sem Drive para os registos locais. ---- +## Pagamentos parcelados -## Application configuration (`pagina.py`) +Em **Pagamentos → Novo registo**, introduza o **valor total** e o **número de parcelas** (1 a 120). O sistema divide o total em cêntimos exatos e distribui eventuais cêntimos restantes pelas primeiras parcelas: 1.000 € em três parcelas resulta em 333,34 €, 333,33 € e 333,33 €. Use 1 para um pagamento único. -`pagina.py` contains UI configuration and a list of available document templates. You must add the document display name (exactly as used in `documentos_info`) to the `self.modelos_de_documento` list. Example excerpt: +Com várias parcelas, a **data limite da primeira** é obrigatória. O **intervalo em dias** é opcional: em branco, as restantes parcelas ficam sem vencimento; com 30, os vencimentos são calculados a cada 30 dias desde a primeira data. São dias corridos, não meses de calendário. Depois pode definir ou alterar individualmente os vencimentos. A primeira parcela mantém uma data limite obrigatória. -```python -self.modelos_de_documento = [ - "Modelo de Documento" -] -``` +Cliente, entidade, referência, motivo e estado inicial são aplicados ao plano. A conclusão de cada parcela é registada individualmente depois da criação; as parcelas futuras não são marcadas como pagas em conjunto. O cliente de um plano é preservado na edição. A lista identifica cada parcela como **1 de N**, e o detalhe permite abrir as restantes parcelas do mesmo plano. Os valores, referências e datas podem ser editados por parcela. Todas as parcelas e a auditoria são criadas numa única transação; falhas não deixam um plano parcial. -### Fixed-value boxes +Parcelas sem vencimento não entram na previsão financeira por data: os relatórios avisam sobre pagamentos em aberto sem data limite. A migração `0006` acrescenta apenas a identificação do plano e a numeração das parcelas; pagamentos existentes mantêm os seus valores e não são agrupados automaticamente. -Some UI boxes contain fixed/default values that are safe to change directly in `pagina.py`. If you need to localize labels or change default values, edit `pagina.py` accordingly. The README can't list every editable box — inspect `pagina.py` for constants and default values and adjust to your needs. +## Google Calendar ---- +No Google Cloud, crie um OAuth client **Web application** com o URI HTTPS exato `/google/callback/` da instalação. Configure no servidor `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, `GOOGLE_CALENDAR_ID` e `TOKEN_ENCRYPTION_KEY`. Gere a chave com: -## Packaging / Building an executable (PyInstaller) - -You can compile the app into a single executable with PyInstaller. Example command: - -```bash -python -m PyInstaller --onefile --windowed \ - --add-data "gerador_documentos/modelo_documento.docx;gerador_documentos" \ - --add-data ".env;." \ - --add-data "Calendar/credentials.json;Calendar" \ - main.py +```sh +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` -Notes: - -- On Windows, PyInstaller `--add-data` uses a different separator; the format above works when run inside a bash-like shell. If on Windows native shell, you may need to change the `;` to `;` still but the first path style may need quotes. If packaging on Windows, test and adapt the `--add-data` arguments as PyInstaller docs specify. -- The resulting executable will be in `dist/` (e.g., `dist/main.exe` on Windows or `dist/main` on macOS / Linux). -- You can compile on mac and windows — remember you must compile on each platform or use cross-compilation methods (recommended: build on each target platform). +Guarde a chave num gestor de segredos e backup seguro; sem ela os tokens não são recuperáveis. O superuser liga a conta pelo painel; a agenda é partilhada pelo escritório. O escopo é apenas `calendar.events`. No detalhe, **Sincronizar agendamento** envia criação/alteração para Google. Falhas preservam o registo local; IDs determinísticos tornam reenvios mais seguros. ---- +Não há sincronização automática/inversa das alterações feitas no Google. Eliminar localmente não elimina o evento remoto; a confirmação informa-o. IDs legados do Google são preservados. -## Run / Usage +## Publicação -While developing or running from source: - -```bash -source .venv/bin/activate -python main.py +```sh +docker compose build +docker compose run --rm web python manage.py migrate +docker compose run --rm web python manage.py bootstrap_roles +docker compose run --rm web python manage.py createsuperuser +docker compose up -d ``` -When running the built executable: +Para bases existentes use o procedimento de migração documentado. Migrações não correm no arranque. O container usa Gunicorn e um utilizador sem privilégios; o volume de modelos é privado. -- On macOS / Linux: +Em `web/.env` defina `DJANGO_DEBUG=false`, uma `DJANGO_SECRET_KEY` aleatória, `DATABASE_URL` PostgreSQL com TLS (`sslmode=verify-full` e CA apropriada), `DJANGO_ALLOWED_HOSTS` com o domínio real e `DJANGO_CSRF_TRUSTED_ORIGINS=https://seu-dominio`. Use HTTPS num reverse proxy; Compose publica HTTP apenas em `127.0.0.1`. `TRUST_HTTPS_PROXY=true` só deve ser ativado quando o proxy remove o cabeçalho recebido do cliente e define `X-Forwarded-Proto` corretamente. HSTS inclui subdomínios, que também devem usar HTTPS. -```bash -./dist/main -``` +Use uma role PostgreSQL de runtime com acesso apenas às tabelas necessárias, sem DDL nem superuser; execute migrações com outra role. Depois da transição, reveja [docs/supabase_hardening.sql](docs/supabase_hardening.sql) para retirar acesso público às tabelas HIMP e Django pela API Supabase. As permissões Django não protegem outros canais de acesso à base. -- On Windows: +Faça backups da base, de `private_media` e da chave de cifragem. Execute `clearsessions` e `axes_reset_logs` segundo a política de retenção. `/health/` confirma que o processo responde, não verifica a base. Não versione credenciais, modelos reais nem logs privados. -Double-click `dist\main.exe` or run in PowerShell / cmd: +## Validação -```powershell -.\dist\main.exe +```sh +python web/manage.py test office +python web/manage.py makemigrations --check --dry-run +python web/manage.py collectstatic --noinput +python web/manage.py check --deploy --fail-level WARNING ``` -First run will often require: - -- Completing Google OAuth flow (browser will open) -- Confirming / allowing Calendar permissions -- Ensuring `.env` keys are correct and Supabase is reachable - ---- - -## Troubleshooting & common gotchas +O último comando deve usar configurações de produção. O workflow GitHub Actions testa PostgreSQL 16. Consulte a [lista oficial de publicação do Django](https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/). -- **Missing `credentials.json`**: The calendar functions will fail. Ensure `Calendar/credentials.json` exists and is valid. The app expects it there. -- **Supabase auth / permission errors**: Check which Supabase key you used. If operations require elevated privileges, provide the service role key in `.env`, but keep it secret. -- **Token / OAuth errors**: If Google OAuth fails, delete any saved token files in `Calendar/` (e.g., `token.json`) and re-run to reauthorize. -- **PyInstaller missing files**: If templates or `.env` are not found after building, confirm `--add-data` paths and that runtime code uses relative paths. -- **Database constraints / insertion errors**: The SQL schema includes unique constraints (e.g., `nif`, `niss`, `passaporte`). Ensure data you insert does not violate them. -- **Locale / encoding issues**: Some column names include non-ASCII characters (e.g., `bi_cc_titulo_residência`, `código_postal`, `gênero`, `observação`). If you face issues, consider renaming columns to ASCII-only identifiers and update the code accordingly. +Antes de usar dados reais: testar a migração numa cópia, verificar contas/permissões, carregar DOCX e testar OAuth com a conta do escritório. MFA, recuperação por email, anexos e isolamento entre escritórios são evoluções adicionais. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..49f669d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,20 @@ +services: + web: + build: . + env_file: web/.env + environment: + DJANGO_DEBUG: "false" + ports: + - "127.0.0.1:8000:8000" + volumes: + - private_media:/app/private_media + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL +volumes: + private_media: diff --git a/docs/DESKTOP.md b/docs/DESKTOP.md new file mode 100644 index 0000000..16153b5 --- /dev/null +++ b/docs/DESKTOP.md @@ -0,0 +1,408 @@ +# HiMP Project + +**HiMP Project** is a desktop application built with **Tkinter** originally designed to help law offices manage clients, cases and documents — but it is intentionally generic and can be adapted to other domains. +Key capabilities: + +- Client management (personal data, multiple processes per client) +- Case / process management (statuses, phases, attachments) +- Payments management (status, references, amounts) +- Google Calendar integration for scheduling (create / sync events) +- Automatic document generation from `.docx` templates +- Backed by a **Supabase (PostgreSQL)** database + +--- + +## Table of contents + +1. [Requirements](#requirements) +2. [Install Python dependencies](#install-python-dependencies) +3. [Project layout / important files](#project-layout--important-files) +4. [Google Calendar setup (credentials.json)](#google-calendar-setup-credentialsjson) +5. [Supabase (PostgreSQL) setup — database tables](#supabase-postgresql-setup---database-tables) +6. [Environment variables (`.env`) - example](#environment-variables-env---example) +7. [Document generator configuration](#document-generator-configuration) +8. [Application configuration (pagina.py)](#application-configuration-paginapy) +9. [Packaging / building an executable (PyInstaller)](#packaging--building-an-executable-pyinstaller) +10. [Run / Usage](#run--usage) +11. [Troubleshooting & notes](#troubleshooting--notes) +12. [Security, privacy & license notes](#security-privacy--license-notes) + +--- + +## Requirements + +- Python 3.10+ (recommend latest stable 3.x) +- Internet access during runtime for Supabase and Google Calendar integration +- A Supabase project with PostgreSQL (tables must be created — see SQL below) +- Google Cloud Console access to create `credentials.json` for Calendar API + +### Python libraries + +Install the required Python packages: + +```bash +pip3 install tkinter google-auth google-auth-oauthlib google-api-python-client pytz python-docx docxtpl pydantic email-validator supabase num2words python-dotenv +``` + +> Note: `tkinter` is included with many Python distributions; on some Linux systems you may need to install the OS package (for example `sudo apt install python3-tk`). + +--- + +## Install / Setup + +1. Clone your repository (or copy files) into a local folder: + +```bash +git clone +cd +``` + +2. Create and activate a virtual environment (recommended): + +```bash +python3 -m venv .venv +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows PowerShell +pip install -r requirements.txt # if you maintain this file; otherwise use the pip line above +``` + +3. Create a `.env` file in the project root and populate the required environment variables (example below). + +4. Create the database tables in your Supabase project using the SQL in the next section. + +5. Place the Google Calendar `credentials.json` file inside the `Calendar/` folder. + +6. Configure `gerador_documentos.py` and `pagina.py` as described below. + +--- + +## Project layout / important files + +``` +HIMP_PROJECT/ +├── calendar/ +│ └── calendar.py # Google Calendar integration logic +│ +├── gerador_documentos/ +│ └── gerador_docs.py # Document generator configuration & logic +│ +├── Home_Page/ +│ ├── crud_support.py # CRUD helper functions for main app +│ └── pagina.py # Main application UI (home page) +│ +├── Login_Page/ +│ ├── loginpage_support.py # Login utility functions +│ └── loginpage.py # Login UI +│ +├── database.py # Supabase/PostgreSQL database connector +├── main.py # Application entry point +├── .env # Environment variables (Supabase keys, etc.) +└── requirements.txt # Python dependencies + +``` + +--- + +## Google Calendar setup (credentials.json) + +1. In the Google Cloud Console: + + - Create a project (or use an existing one). + - Enable the **Google Calendar API** for that project. + - Under **APIs & Services → Credentials**, create an OAuth 2.0 Client ID (choose Desktop app). + - Download the JSON file and save it as: `Calendar/credentials.json`. + +2. Place `credentials.json` at exactly: + `Calendar/credentials.json` (relative to project root). The app uses that file to perform OAuth and create tokens to access the Calendar API. + +3. On first run, the app will typically open a browser window to complete the OAuth flow and save a token file (commonly `token.json` or similar) — keep that token file in the `Calendar/` folder or as the app expects. + +> If you get `scopes` or permissions errors, re-check the OAuth credentials and ensure your OAuth consent screen is configured (external/internal as needed). + +--- + +## Supabase (PostgreSQL) setup — database tables + +Create the following tables in your Supabase project. You can paste this SQL into the Supabase SQL editor and run it. + +> NOTE: This SQL is provided exactly as given — adjust identifiers or column types if your Supabase/Postgres settings require different naming conventions. + +```sql +create table public.cliente ( + passaporte text null, + nif text null, + niss text null, + bi_cc_titulo_residência text null, + data_nascimento date null, + nome_completo text null, + cliente_id integer generated by default as identity not null, + gênero text null, + rua text null, + numero_rua text null, + complemento text null, + localidade text null, + código_postal text null, + profissão text null, + validade_passaporte date null, + validade_bi_cc date null, + email text null, + emissão_passaporte date null, + ddi bigint null, + contato text null, + nacionalidade text null, + local_emissão_passaporte text null, + naturalidade text null, + notas_documento text null, + estado_civil text null, + emissão_bi_cc date null, + constraint cliente_pkey primary key (cliente_id), + constraint cliente_nif_key unique (nif), + constraint cliente_niss_key unique (niss), + constraint cliente_passaporte_key unique (passaporte), + constraint cliente_titulo_residencia_key unique ("bi_cc_titulo_residência") +) TABLESPACE pg_default; + +create table public.agendamento ( + evento_id bigint generated by default as identity not null, + data_inicio timestamp without time zone null, + duracao text null, + motivo text null, + descricao text null, + google_event_id text null, + cliente_id integer null, + titulo text null, + constraint agendamento_pkey primary key (evento_id), + constraint agendamento_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id) +) TABLESPACE pg_default; + +create table public.documentos_cliente ( + id serial not null, + cliente_id integer not null, + documento_nome text not null, + entregue boolean null default false, + constraint documentos_cliente_pkey primary key (id), + constraint documentos_cliente_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id) on delete CASCADE +) TABLESPACE pg_default; + +create table public.entidade ( + entidade_id bigint generated by default as identity not null, + entidade text not null, + constraint entidade_pkey primary key (entidade_id) +) TABLESPACE pg_default; + +create table public.etapa_processo ( + etapa_id bigint generated by default as identity not null, + processo_id bigint not null, + fase_id bigint null, + data_fase date null, + observação text null, + constraint etapa_processo_pkey primary key (etapa_id), + constraint etapa_processo_cliente_processo_id_fkey foreign KEY (processo_id) references processo (processo_id), + constraint etapa_processo_fase_id_fkey foreign KEY (fase_id) references lista_fases_processo (fase_id) +) TABLESPACE pg_default; + +create table public.lista_fases_processo ( + fase_id bigint generated by default as identity not null, + fase text not null, + constraint lista_fases_processo_pkey primary key (fase_id) +) TABLESPACE pg_default; + +create table public.motivo ( + motivo_id bigint generated by default as identity not null, + motivo text not null, + constraint motivo_pkey primary key (motivo_id) +) TABLESPACE pg_default; + +create table public.pagamento ( + pagamento_id bigint generated by default as identity not null, + entidade integer null, + referencia integer null, + montante real null, + data_limite date null, + data_conclusao date null, + status_id bigint null, + motivo_id bigint null, + cliente_id bigint null, + constraint pagamento_pkey primary key (pagamento_id), + constraint pagamento_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id), + constraint pagamento_motivo_id_fkey foreign KEY (motivo_id) references motivo (motivo_id), + constraint pagamento_status_id_fkey foreign KEY (status_id) references status_pagamento (status_id) +) TABLESPACE pg_default; + +create table public.processo ( + entidade_id bigint null, + juiz text null, + processo_anexo_principal text null, + processo_id bigint generated by default as identity not null, + numero_processo text null, + cliente_id bigint not null, + tipo_do_processo_id bigint null, + constraint processo_pkey primary key (processo_id), + constraint processo_numero_processo_key unique (numero_processo), + constraint processo_cliente_id_fkey foreign KEY (cliente_id) references cliente (cliente_id), + constraint processo_entidade_id_fkey foreign KEY (entidade_id) references entidade (entidade_id), + constraint processo_tipo_do_processo_id_fkey foreign KEY (tipo_do_processo_id) references tipo_do_processo (tipo_do_processo_id) +) TABLESPACE pg_default; + +create table public.status_pagamento ( + status_id bigint generated by default as identity not null, + status text not null, + constraint status_pagamento_pkey primary key (status_id) +) TABLESPACE pg_default; + +create table public.tipo_do_processo ( + tipo_do_processo_id bigint generated by default as identity not null, + tipo_do_processo text not null, + constraint tipo_do_processo_pkey primary key (tipo_do_processo_id) +) TABLESPACE pg_default; + +create table public.users ( + id bigint generated by default as identity not null, + nif bigint not null, + password text not null, + constraint Users_pkey primary key (id), + constraint Users_nif_key unique (nif) +) TABLESPACE pg_default; +``` + +After creating tables, insert any needed static lists (e.g., `lista_fases_processo`, `motivo`, `status_pagamento`, `tipo_do_processo`, `entidade`) that your app expects. + +--- + +## Environment variables (`.env`) — example + +Create a file named `.env` in the project root and **do not commit it to git**. + +Example `.env` template (edit values to match your Supabase project and any other keys used by your app): + +```ini +# Supabase +SUPABASE_URL=https://your-project-ref.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key + +# Google Calendar +# Path to the credentials file (relative to project root) +CALENDAR_CREDENTIALS_PATH=Calendar/credentials.json + +``` + +> Which Supabase key to use: +> +> - For client-side-like operations use the **anon** key. +> - For server-side privileged operations (insert/update that require bypassing RLS / elevated permissions), the **service role** key is required. Check your code to see which key the application uses. Keep the service role key secret. + +--- + +## Document generator configuration + +The document generator reads a dictionary `documentos_info` inside `gerador_docs.py` + +```python +documentos_info = { + "Modelo de Documento": { + "arquivo": "modelo_documento.docx", + "campos": ["nome", "nif", "endereço"] + } +} +``` + +### Steps to add templates + +1. Put your `.docx` template in `gerador_documentos/` (for example `gerador_documentos/modelo_documento.docx`). + +2. In `gerador_docs.py`, add an entry to `documentos_info` for each template: + + - Key = display name (this is the name shown in the UI) + - `arquivo` = file name inside the `gerador_documentos/` folder + - `campos` = list of field names the template expects (these must match the fields you pass when rendering) + +3. Example: + +```python +documentos_info = { + "Power of Attorney - Example": { + "arquivo": "procuracao_example.docx", + "campos": ["nome", "passaporte", "nif", "endereço", "data_documento"] + } +} +``` + +--- + +## Application configuration (`pagina.py`) + +`pagina.py` contains UI configuration and a list of available document templates. You must add the document display name (exactly as used in `documentos_info`) to the `self.modelos_de_documento` list. Example excerpt: + +```python +self.modelos_de_documento = [ + "Modelo de Documento" +] +``` + +### Fixed-value boxes + +Some UI boxes contain fixed/default values that are safe to change directly in `pagina.py`. If you need to localize labels or change default values, edit `pagina.py` accordingly. The README can't list every editable box — inspect `pagina.py` for constants and default values and adjust to your needs. + +--- + +## Packaging / Building an executable (PyInstaller) + +You can compile the app into a single executable with PyInstaller. Example command: + +```bash +python -m PyInstaller --onefile --windowed \ + --add-data "gerador_documentos/modelo_documento.docx;gerador_documentos" \ + --add-data ".env;." \ + --add-data "Calendar/credentials.json;Calendar" \ + main.py +``` + +Notes: + +- On Windows, PyInstaller `--add-data` uses a different separator; the format above works when run inside a bash-like shell. If on Windows native shell, you may need to change the `;` to `;` still but the first path style may need quotes. If packaging on Windows, test and adapt the `--add-data` arguments as PyInstaller docs specify. +- The resulting executable will be in `dist/` (e.g., `dist/main.exe` on Windows or `dist/main` on macOS / Linux). +- You can compile on mac and windows — remember you must compile on each platform or use cross-compilation methods (recommended: build on each target platform). + +--- + +## Run / Usage + +While developing or running from source: + +```bash +source .venv/bin/activate +python main.py +``` + +When running the built executable: + +- On macOS / Linux: + +```bash +./dist/main +``` + +- On Windows: + +Double-click `dist\main.exe` or run in PowerShell / cmd: + +```powershell +.\dist\main.exe +``` + +First run will often require: + +- Completing Google OAuth flow (browser will open) +- Confirming / allowing Calendar permissions +- Ensuring `.env` keys are correct and Supabase is reachable + +--- + +## Troubleshooting & common gotchas + +- **Missing `credentials.json`**: The calendar functions will fail. Ensure `Calendar/credentials.json` exists and is valid. The app expects it there. +- **Supabase auth / permission errors**: Check which Supabase key you used. If operations require elevated privileges, provide the service role key in `.env`, but keep it secret. +- **Token / OAuth errors**: If Google OAuth fails, delete any saved token files in `Calendar/` (e.g., `token.json`) and re-run to reauthorize. +- **PyInstaller missing files**: If templates or `.env` are not found after building, confirm `--add-data` paths and that runtime code uses relative paths. +- **Database constraints / insertion errors**: The SQL schema includes unique constraints (e.g., `nif`, `niss`, `passaporte`). Ensure data you insert does not violate them. +- **Locale / encoding issues**: Some column names include non-ASCII characters (e.g., `bi_cc_titulo_residência`, `código_postal`, `gênero`, `observação`). If you face issues, consider renaming columns to ASCII-only identifiers and update the code accordingly. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..69a22e2 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,43 @@ +# Migrar HIMP desktop para Web + +## Preparar staging + +1. Exporte a base com dados, constraints, sequences e políticas; teste o restauro. +2. Crie uma cópia isolada de staging. Mantenha o desktop na base original durante os testes. +3. Configure `DATABASE_URL` para a cópia, usando PostgreSQL diretamente, não a chave Supabase de serviço. +4. Execute `python web/manage.py check_legacy_schema`. É só de leitura: verifica tabelas/colunas e montantes inválidos. Compare também tipos, constraints e políticas reais com `0001_legacy_schema`. + +## Aplicar as migrações + +```sh +python web/manage.py migrate office 0001 --fake-initial +python web/manage.py migrate --plan +python web/manage.py migrate +python web/manage.py bootstrap_roles +python web/manage.py createsuperuser +``` + +`0001` contém apenas as onze tabelas originais de negócio. `--fake-initial` reconhece-as sem recriar. `0002` cria as tabelas Web; Django cria Auth/Sessions; `0003` altera tipos financeiros/horários; `0004` importa catálogos e metadados de modelos; `0005` adiciona referências de pastas/ficheiros Google Drive e a tabela privada de ligação Google Drive. `0006` adiciona identificação e numeração dos planos parcelados aos pagamentos, sem alterar pagamentos anteriores. A pré-verificação compara as colunas de `0001`, sem exigir antecipadamente os novos campos Web. A tabela antiga `users` não é usada pela Web. + +Numa **base vazia**, execute apenas `migrate`. Num esquema parcial/diferente, não use `--fake` para contornar erros nem apague tabelas: prepare uma migração explícita para a estrutura real. + +## Alterações a verificar + +- `pagamento.montante`: `real` passa a `numeric(14,2)`, com arredondamento a cêntimos. Exporte/reconcilie valores e totais. Corrija NaN, infinitos, negativos e montantes fora do limite antes de migrar. +- `pagamento.referencia` e `entidade`: inteiros passam a texto. Novos valores preservam zeros; zeros históricos já perdidos exigem consulta à fonte original. +- `agendamento.data_inicio`: timestamps sem fuso passam a `timestamptz`, interpretados como hora local **Europe/Lisbon**, conforme o código desktop. Confirme esta hipótese; se eram UTC/outro fuso, ajuste a migração antes de executá-la. Revise transições de horário de verão. +- Catálogos recebem valores fixos e históricos sem reescrever dados dos clientes. Os campos textuais mantêm a compatibilidade com o esquema atual. +- Não se importam as senhas em texto simples de `public.users`. Recrie contas no Django Auth, atribua funções e entregue novas senhas por um canal seguro. A mudança inicial de senha é um procedimento operacional, não uma obrigação automática da aplicação. +- Os nove modelos DOCX são criados inativos; carregue os ficheiros reais no admin, pois não constam do repositório. + +`0003` é deliberadamente irreversível. Voltar aos tipos antigos perde precisão/referências; use backup/restauro para recuperação. + +## Validar e fazer a transição + +Compare quantidades e IDs de todas as tabelas, relações, valores financeiros, acentos, identificadores vazios e datas antes/depois. Teste as funções Consulta/Gestão/Administração e contas sem função. Valide HTTPS/cookies, DOCX reais e OAuth em staging. Esta versão tem um único escritório e sincronização Google explícita de criação/alteração; a remoção local não apaga o evento remoto. + +Agende uma janela de manutenção, pare escritas no desktop e faça um novo backup. Aplique o procedimento validado na base final. Publique sob HTTPS e distribua contas/endereço. + +Reveja `supabase_hardening.sql`: retire acesso público HIMP/Django pela API Supabase, incluindo grants herdados, funções RPC e RLS. O script não configura roles nem altera senhas; a role Web precisa de grants e políticas adequados, sem privilégios globais. Retire a chave de serviço do desktop de circulação. Arquive/remova a tabela `public.users` com senhas antigas depois de confirmar todas as contas novas. + +Em caso de erro, pare as escritas Web e restaure o backup pré-migração numa base isolada antes de redirecionar o desktop. Reconcile alterações feitas depois da transição; restaurar sem reconciliação perde dados. Ensaie esse procedimento em staging. diff --git a/docs/supabase_hardening.sql b/docs/supabase_hardening.sql new file mode 100644 index 0000000..8b367d7 --- /dev/null +++ b/docs/supabase_hardening.sql @@ -0,0 +1,34 @@ +-- Run only after cutover, on the HIMP database, reviewed with your DB administrator. +-- The desktop used the service-role API; the Web app uses direct PostgreSQL. +-- Block public Supabase Data API access to HIMP and Django authentication tables. +-- This does not alter other applications' tables, create users, or change passwords. +DO $$ +DECLARE + table_name text; + role_name text; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'cliente','processo','etapa_processo','entidade','tipo_do_processo', + 'lista_fases_processo','pagamento','motivo','status_pagamento', + 'agendamento','documentos_cliente','users', + 'office_configuration','office_documenttemplate','office_calendarconnection', + 'office_driveconnection','office_auditevent','auth_user','auth_group','auth_permission', + 'auth_user_groups','auth_user_user_permissions','auth_group_permissions', + 'django_session','django_admin_log','django_content_type','django_migrations', + 'axes_accessattempt','axes_accesslog','axes_accessfailurelog' + ] LOOP + IF to_regclass(format('public.%I', table_name)) IS NOT NULL THEN + EXECUTE format('REVOKE ALL ON TABLE public.%I FROM PUBLIC', table_name); + FOREACH role_name IN ARRAY ARRAY['anon','authenticated'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = role_name) THEN + EXECUTE format('REVOKE ALL ON TABLE public.%I FROM %I', table_name, role_name); + END IF; + END LOOP; + END IF; + END LOOP; +END $$; +-- Use a dedicated runtime role with SELECT/INSERT/UPDATE/DELETE + sequence USAGE +-- on these tables only, no DDL, no superuser, and no Supabase service-role key. +-- Apply migrations using a separate schema-owner role. Explicit RLS policies +-- must be reviewed if RLS is already enabled; this script does not disable RLS. +-- Retire public.users after all accounts have been recreated in Django Auth. diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..2f0a3f9 --- /dev/null +++ b/requirements-web.txt @@ -0,0 +1,12 @@ +Django>=5.2.12,<5.3 +django-axes>=8,<9 +dj-database-url>=3,<4 +psycopg[binary]>=3.2,<4 +whitenoise>=6.9,<7 +gunicorn>=23,<24; sys_platform != 'win32' +python-dotenv>=1.1,<2 +docxtpl>=0.20,<1 +cryptography>=46,<48 +google-auth-oauthlib>=1.2,<2 +google-api-python-client>=2.180,<3 +num2words>=0.5.14,<1 diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..87dd999 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,14 @@ +DJANGO_DEBUG=true +DJANGO_SECRET_KEY= +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +# Leave empty for a local SQLite database with no real client data. +# Production: use a dedicated PostgreSQL role, SSL verification and a staging copy first. +DATABASE_URL= +DJANGO_CSRF_TRUSTED_ORIGINS= +TRUST_HTTPS_PROXY=false +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +GOOGLE_CALENDAR_ID=primary +GOOGLE_DRIVE_REDIRECT_URI= +TOKEN_ENCRYPTION_KEY= diff --git a/web/himp/__init__.py b/web/himp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/himp/settings.py b/web/himp/settings.py new file mode 100644 index 0000000..b9a0762 --- /dev/null +++ b/web/himp/settings.py @@ -0,0 +1,109 @@ +import os +from pathlib import Path + +import dj_database_url +from django.core.exceptions import ImproperlyConfigured +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent +# Never load the desktop .env or expose its Supabase service role key. +load_dotenv(BASE_DIR / '.env') +DEBUG = os.getenv('DJANGO_DEBUG', 'false').lower() == 'true' +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', '') +if not SECRET_KEY: + if not DEBUG: + raise ImproperlyConfigured('Defina DJANGO_SECRET_KEY antes de iniciar a aplicação.') + SECRET_KEY = 'local-development-only-do-not-use-in-production' +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') +CSRF_TRUSTED_ORIGINS = [v for v in os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if v] +INSTALLED_APPS = [ + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', + 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'axes', 'office', +] +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'axes.middleware.AxesMiddleware', 'office.middleware.SecurityHeadersMiddleware', +] +ROOT_URLCONF = 'himp.urls' +WSGI_APPLICATION = 'himp.wsgi.application' +TEMPLATES = [{ + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, + 'OPTIONS': {'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'office.context.navigation', + ]}, +}] +database_url = os.getenv('DATABASE_URL') +if not DEBUG and not database_url: + raise ImproperlyConfigured('DATABASE_URL é obrigatória em produção.') +DATABASES = {'default': dj_database_url.parse( + database_url or f'sqlite:///{BASE_DIR / "db.sqlite3"}', conn_max_age=60, + conn_health_checks=True, +)} +AUTHENTICATION_BACKENDS = ['axes.backends.AxesStandaloneBackend', 'django.contrib.auth.backends.ModelBackend'] +AXES_FAILURE_LIMIT = 5 +AXES_COOLOFF_TIME = 1 +AXES_LOCKOUT_PARAMETERS = ['username', 'ip_address'] +AXES_RESET_ON_SUCCESS = True +AXES_ENABLE_ACCESS_FAILURE_LOG = False +AXES_VERBOSE = False +AXES_HTTP_RESPONSE_CODE = 429 +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 12}}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] +LANGUAGE_CODE = 'pt-pt' +TIME_ZONE = 'Europe/Lisbon' +USE_I18N = True +USE_TZ = True +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [BASE_DIR / 'static'] +STORAGES = { + 'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'}, + 'staticfiles': {'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage'}, +} +MEDIA_ROOT = BASE_DIR / 'private_media' +# Uploaded templates are only read by authenticated views, never served publicly. +LOGIN_URL = '/entrar/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/entrar/' +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' +CSRF_COOKIE_HTTPONLY = True +SESSION_COOKIE_AGE = 3600 +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SECURE_SSL_REDIRECT = not DEBUG +SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = not DEBUG +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = 'same-origin' +X_FRAME_OPTIONS = 'DENY' +if os.getenv('TRUST_HTTPS_PROXY', 'false').lower() == 'true': + # Enable only behind a trusted proxy which strips incoming forwarded headers. + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +DATA_UPLOAD_MAX_MEMORY_SIZE = 6 * 1024 * 1024 +FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID', '') +GOOGLE_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET', '') +GOOGLE_REDIRECT_URI = os.getenv('GOOGLE_REDIRECT_URI', '') +GOOGLE_CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary') +GOOGLE_DRIVE_REDIRECT_URI = os.getenv('GOOGLE_DRIVE_REDIRECT_URI', '') +TOKEN_ENCRYPTION_KEY = os.getenv('TOKEN_ENCRYPTION_KEY', '') diff --git a/web/himp/urls.py b/web/himp/urls.py new file mode 100644 index 0000000..00e9bae --- /dev/null +++ b/web/himp/urls.py @@ -0,0 +1,28 @@ +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import path +from office import views, google_calendar, google_drive + +urlpatterns = [ + path('admin/', admin.site.urls), + path('entrar/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'), + path('sair/', auth_views.LogoutView.as_view(), name='logout'), + path('conta/senha/', auth_views.PasswordChangeView.as_view(template_name='registration/password_change.html'), name='password_change'), + path('conta/senha/alterada/', auth_views.PasswordChangeDoneView.as_view(template_name='registration/password_done.html'), name='password_change_done'), + path('', views.dashboard, name='dashboard'), + path('health/', views.health, name='health'), + path('relatorios/financeiro/', views.financial_report, name='financial_report'), + path('gerar-documento/', views.generate_document, name='generate_document'), + path('google/ligar/', google_calendar.connect, name='google_connect'), + path('google/callback/', google_calendar.callback, name='google_callback'), + path('google/drive/ligar/', google_drive.connect, name='drive_connect'), + path('google/drive/callback/', google_drive.callback, name='drive_callback'), + path('clientes//pasta-drive/', views.client_drive_folder, name='client_drive_folder'), + path('documentos//enviar-drive/', views.document_upload, name='document_upload'), + path('agenda//sincronizar/', views.calendar_sync, name='calendar_sync'), + path('gestao//', views.record_list, name='record_list'), + path('gestao//novo/', views.record_edit, name='record_create'), + path('gestao///', views.record_detail, name='record_detail'), + path('gestao///editar/', views.record_edit, name='record_edit'), + path('gestao///eliminar/', views.record_delete, name='record_delete'), +] diff --git a/web/himp/wsgi.py b/web/himp/wsgi.py new file mode 100644 index 0000000..a7a2703 --- /dev/null +++ b/web/himp/wsgi.py @@ -0,0 +1,4 @@ +import os +from django.core.wsgi import get_wsgi_application +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'himp.settings') +application = get_wsgi_application() diff --git a/web/manage.py b/web/manage.py new file mode 100644 index 0000000..7fd56c2 --- /dev/null +++ b/web/manage.py @@ -0,0 +1,7 @@ +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'himp.settings') + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/web/office/__init__.py b/web/office/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/office/admin.py b/web/office/admin.py new file mode 100644 index 0000000..f31c227 --- /dev/null +++ b/web/office/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from .forms import TemplateAdminForm +from .models import AuditEvent, Configuration, DocumentTemplate, Entidade, Fase, Motivo, StatusPagamento, TipoProcesso + +admin.site.site_header = 'HIMP · Administração' +admin.site.site_title = 'HIMP' +admin.site.index_title = 'Configurações e acessos' + +@admin.register(Configuration) +class ConfigurationAdmin(admin.ModelAdmin): + list_display = ['label', 'category', 'active', 'minutes', 'order'] + list_filter = ['category', 'active'] + search_fields = ['label'] + list_editable = ['active', 'order'] + +@admin.register(DocumentTemplate) +class TemplateAdmin(admin.ModelAdmin): + form = TemplateAdminForm + list_display = ['name', 'active'] + search_fields = ['name'] + # Templates execute a constrained template language: upload/edit is restricted + # to trusted system administrators, even if a user is granted model permissions. + def has_add_permission(self, request): + return request.user.is_superuser + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +@admin.register(AuditEvent) +class AuditAdmin(admin.ModelAdmin): + list_display = ['created_at', 'actor', 'action', 'model', 'object_id'] + list_filter = ['action', 'model'] + def has_add_permission(self, request): + return False + def has_change_permission(self, request, obj=None): + return False + def has_delete_permission(self, request, obj=None): + return False + +for model in [Entidade, TipoProcesso, Fase, Motivo, StatusPagamento]: + admin.site.register(model) diff --git a/web/office/apps.py b/web/office/apps.py new file mode 100644 index 0000000..f7766d8 --- /dev/null +++ b/web/office/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class OfficeConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'office' + verbose_name = 'Gestão HIMP' diff --git a/web/office/context.py b/web/office/context.py new file mode 100644 index 0000000..8b3c8a0 --- /dev/null +++ b/web/office/context.py @@ -0,0 +1,9 @@ +def navigation(request): + items = [ + ('clientes', 'Clientes', 'office.view_cliente'), + ('processos', 'Processos', 'office.view_processo'), + ('etapas', 'Etapas', 'office.view_etapa'), + ('pagamentos', 'Pagamentos', 'office.view_pagamento'), + ('agenda', 'Agenda', 'office.view_agendamento'), + ] + return {'navigation': [(key, label) for key, label, perm in items if request.user.has_perm(perm)]} diff --git a/web/office/financial_reports.py b/web/office/financial_reports.py new file mode 100644 index 0000000..267045b --- /dev/null +++ b/web/office/financial_reports.py @@ -0,0 +1,119 @@ +"""Payment reporting: receipt dates for revenue, due dates for receivables.""" +from calendar import monthrange +from datetime import date +from decimal import Decimal + +from django.db.models import Count, Q, Sum +from django.db.models.functions import TruncMonth +from django.utils.formats import date_format, number_format + +from .models import Pagamento + +ZERO = Decimal('0.00') + + +def month_start(value, offset=0): + index = value.year * 12 + value.month - 1 + offset + year, month = divmod(index, 12) + return date(year, month + 1, 1) + + +def preset_dates(period, today): + last = today.replace(day=monthrange(today.year, today.month)[1]) + if period == 'mes': + return today.replace(day=1), last + if period == 'ano': + return date(today.year, 1, 1), date(today.year, 12, 31) + return month_start(today, -5), last + + +def money(value): + return number_format(value, 2) + ' €' + + +def total(query): + return query.aggregate(total=Sum('montante'))['total'] or ZERO + + +def build_report(start, end, today, client_id=None): + base = Pagamento.objects.all() + if client_id: + base = base.filter(cliente_id=client_id) + scope = Q(data_conclusao__range=(start, end)) | Q(data_conclusao__isnull=True, data_limite__range=(start, end)) + missing_amount = base.filter(scope, montante__isnull=True).count() + invalid_amount = base.filter(scope, montante__lt=0).count() + missing_dates = base.filter(data_conclusao__isnull=True, data_limite__isnull=True).count() + valued = base.filter(montante__gte=0) + received = valued.filter(data_conclusao__range=(start, end)) + expected = valued.filter(data_limite__range=(start, end)) + pending = expected.filter(data_conclusao__isnull=True) + overdue = pending.filter(data_limite__lt=today) + upcoming = pending.filter(data_limite__gte=today) + received_total, pending_total, overdue_total = total(received), total(pending), total(overdue) + expected_total = total(expected) + received_months = {item['month'].date() if hasattr(item['month'], 'date') else item['month']: item['total'] for item in received.order_by().annotate(month=TruncMonth('data_conclusao')).values('month').annotate(total=Sum('montante'))} + expected_months = {item['month'].date() if hasattr(item['month'], 'date') else item['month']: item['total'] for item in expected.order_by().annotate(month=TruncMonth('data_limite')).values('month').annotate(total=Sum('montante'))} + months = [] + current = month_start(start) + while current <= end: + months.append({'date': current, 'label': date_format(current, 'M y'), 'received': received_months.get(current, ZERO), 'expected': expected_months.get(current, ZERO)}) + if current.year == end.year and current.month == end.month: + break + current = month_start(current, 1) + maximum = max([v for m in months for v in (m['received'], m['expected'])] + [ZERO]) + # A round axis ceiling using Decimal throughout financial calculations. + if maximum: + step = Decimal(10) ** (maximum.adjusted() - 1) + ceiling = ((maximum / (step * 5)).to_integral_value(rounding='ROUND_CEILING')) * step * 5 + else: + ceiling = Decimal('100') + width = max(720, len(months) * 84 + 100) + chart_height, plot_height, top, left = 310, 220, 30, 75 + plot_width = width - left - 30 + group_width = plot_width / len(months) + bar_width = min(24, group_width * .25) + for index, month in enumerate(months): + center = left + group_width * (index + .5) + month.update({'x': round(center, 2), 'label': date_format(month['date'], 'M y')}) + month['bars'] = [] + for key, offset in [('received', -bar_width - 2), ('expected', 2)]: + height = float(month[key] / ceiling) * plot_height + month['bars'].append({'kind': key, 'x': round(center + offset, 2), 'y': round(top + plot_height - height, 2), 'height': round(height, 2), 'width': bar_width, 'amount': month[key]}) + ticks = [{'y': top + plot_height * (1 - i / 4), 'label': number_format(ceiling * i / 4, 0)} for i in range(5)] + distribution = [ + {'label': 'Recebidos', 'kind': 'received', 'amount': received_total}, + {'label': 'Por receber · no prazo', 'kind': 'upcoming', 'amount': total(upcoming)}, + {'label': 'Por receber · em atraso', 'kind': 'overdue', 'amount': overdue_total}, + ] + distribution_total = sum((item['amount'] for item in distribution), ZERO) + offset = 0 + for item in distribution: + percent = float(item['amount'] / distribution_total * 100) if distribution_total else 0 + item.update({'percent': round(percent, 1), 'length': round(percent, 5), 'offset': round(-offset, 5)}) + offset += percent + breakdown = list(received.order_by().values('motivo__motivo').annotate(total=Sum('montante'), count=Count('pk')).order_by('-total', 'motivo__motivo')) + categories = [{'label': item['motivo__motivo'] or 'Sem motivo definido', 'amount': item['total']} for item in breakdown[:5]] + if len(breakdown) > 5: + categories.append({'label': 'Outros motivos', 'amount': sum((item['total'] for item in breakdown[5:]), ZERO)}) + top_category = max([item['amount'] for item in categories] + [ZERO]) + for item in categories: + item['width'] = round(float(item['amount'] / top_category * 100), 2) if top_category else 0 + aging = [] + from datetime import timedelta + for label, lower, upper in [('Até 30 dias', 1, 30), ('31–60 dias', 31, 60), ('61–90 dias', 61, 90), ('Mais de 90 dias', 91, None)]: + records = overdue.filter(data_limite__lte=today - timedelta(days=lower)) + if upper: + records = records.filter(data_limite__gte=today - timedelta(days=upper)) + aging.append({'label': label, 'amount': total(records), 'count': records.count()}) + aging_max = max([item['amount'] for item in aging] + [ZERO]) + for item in aging: + item['width'] = round(float(item['amount'] / aging_max * 100), 2) if aging_max else 0 + return { + 'received_total': received_total, 'pending_total': pending_total, 'overdue_total': overdue_total, + 'expected_total': expected_total, 'received_count': received.count(), 'pending_count': pending.count(), + 'overdue_count': overdue.count(), 'months': months, 'ticks': ticks, + 'chart_width': width, 'chart_height': chart_height, 'distribution': distribution, + 'distribution_total': distribution_total, 'categories': categories, 'aging': aging, + 'has_amounts': any(m['received'] or m['expected'] for m in months), + 'missing_amount': missing_amount, 'invalid_amount': invalid_amount, 'missing_dates': missing_dates, + } diff --git a/web/office/forms.py b/web/office/forms.py new file mode 100644 index 0000000..d18a8ce --- /dev/null +++ b/web/office/forms.py @@ -0,0 +1,179 @@ +import re +import zipfile +from copy import deepcopy +from datetime import date, timedelta +from io import BytesIO + +from django import forms +from django.core.exceptions import ValidationError +from django.db import models +from .models import Configuration, DocumentTemplate, Cliente, DocumentoCliente, Pagamento + + +def validate_drive_file(file): + if file.size > 10 * 1024 * 1024 or file.size == 0: + raise ValidationError('Envie um ficheiro com até 10 MB, não vazio.') + suffix = file.name.lower().rsplit('.', 1)[-1] + file.seek(0) + head = file.read(8) + file.seek(0) + valid = (suffix == 'pdf' and head.startswith(b'%PDF-') or + suffix == 'png' and head.startswith(b'\x89PNG\r\n\x1a\n') or + suffix in ('jpg', 'jpeg') and head.startswith(b'\xff\xd8\xff')) + if suffix == 'docx': + try: + with zipfile.ZipFile(file) as archive: + valid = ('word/document.xml' in archive.namelist() and + sum(m.file_size for m in archive.infolist()) <= 50 * 1024 * 1024 and + not any('vbaproject' in m.filename.lower() for m in archive.infolist())) + except (zipfile.BadZipFile, OSError): + valid = False + finally: + file.seek(0) + if not valid: + raise ValidationError('Use PDF, DOCX sem macros, JPG ou PNG válidos.') + + +class DriveUploadForm(forms.Form): + ficheiro = forms.FileField(label='Ficheiro para guardar no Google Drive', validators=[validate_drive_file], + help_text='PDF, DOCX, JPG ou PNG · até 10 MB.') + + +class RecordForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for name, field in self.fields.items(): + model_field = self._meta.model._meta.get_field(name) + if isinstance(model_field, models.DateTimeField): + field.widget = forms.DateTimeInput(format='%Y-%m-%dT%H:%M', attrs={'type': 'datetime-local'}) + elif isinstance(model_field, models.DateField): + field.widget = forms.DateInput(format='%Y-%m-%d', attrs={'type': 'date'}) + elif isinstance(field.widget, forms.Textarea): + if name in ('notas_documento', 'observacao', 'descricao'): + field.widget.attrs['rows'] = 4 + else: + field.widget = forms.TextInput() + if isinstance(field, forms.CharField): + field.max_length = field.max_length or 2000 + categories = { + 'estado_civil': Configuration.Category.MARITAL, 'genero': Configuration.Category.GENDER, + 'duracao': Configuration.Category.DURATION, 'motivo': Configuration.Category.REASON, + 'documento_nome': Configuration.Category.DOCUMENT, + } + for name, category in categories.items(): + # Pagamento.motivo is an existing foreign key, not a text catalogue. + if name not in self.fields or not isinstance(self._meta.model._meta.get_field(name), models.TextField): + continue + labels = list(Configuration.objects.filter(category=category, active=True).values_list('label', flat=True)) + old = getattr(self.instance, name, None) + if old and old not in labels: + labels.append(old) # Preserve inactive/historical values when editing. + self.fields[name] = forms.ChoiceField( + label=self.fields[name].label, required=self.fields[name].required, + choices=[('', 'Selecione…')] + [(v, v) for v in labels], + ) + for name in ('nome_completo', 'titulo', 'cliente', 'documento_nome', 'processo', 'montante', 'data_inicio', 'duracao'): + if name in self.fields: + self.fields[name].required = True + if self._meta.model == DocumentoCliente and not self.instance.drive_file_id: + self.fields['ficheiro'] = deepcopy(DriveUploadForm.base_fields['ficheiro']) + self.fields['ficheiro'].required = False + self.fields['ficheiro'].help_text += ' Opcional; requer ligação Google do escritório.' + if self._meta.model == Pagamento and not self.instance.pk: + self.fields['montante'].label = 'Valor total (€)' + self.fields['montante'].help_text = 'O valor será dividido pelas parcelas, com acerto dos cêntimos para manter o total.' + self.fields['numero_parcelas'] = forms.IntegerField(label='Número de parcelas', min_value=1, max_value=120, initial=1, required=False, + help_text='Use 1 para pagamento único. Pode criar até 120 parcelas de uma vez.') + self.fields['intervalo_dias'] = forms.IntegerField(label='Intervalo entre parcelas (dias)', min_value=1, max_value=365, required=False, + widget=forms.NumberInput(attrs={'placeholder': 'Opcional · exemplo: 30'}), + help_text='Em branco: só a primeira tem vencimento; as restantes ficam sem data. Ex.: 30 cria vencimentos a cada 30 dias.') + self.fields['data_limite'].label = 'Data limite da primeira parcela' + self.fields['data_limite'].help_text = 'Obrigatória quando existem várias parcelas. As restantes datas podem ser definidas individualmente depois.' + self.fields['data_conclusao'].help_text = 'Para pagamento parcelado, registe a conclusão de cada parcela depois de criar o plano.' + self.fields['status'].help_text = 'Estado inicial aplicado a todas as parcelas.' + self.fields['referencia'].help_text = 'Aplicada a todas as parcelas; pode alterar a referência de cada uma depois.' + self.order_fields(['cliente', 'montante', 'numero_parcelas', 'data_limite', 'intervalo_dias', 'entidade', 'referencia', 'motivo', 'status', 'data_conclusao']) + elif self._meta.model == Pagamento and self.instance.numero_parcela == 1: + self.fields['data_limite'].required = True + self.fields['data_limite'].help_text = 'A primeira parcela do plano tem de manter uma data limite.' + + def clean(self): + data = super().clean() + # Unique optional identifiers must be NULL, not an empty unique string. + for name in ('nif', 'niss', 'passaporte', 'titulo_residencia', 'numero_processo'): + if name in data and not data[name]: + data[name] = None + for name, length in [('nif', 9), ('niss', 11)]: + value = data.get(name) + if value and not re.fullmatch(rf'[0-9]{{{length}}}', value): + self.add_error(name, f'Introduza {length} algarismos.') + if data.get('montante') is not None and data['montante'] < 0: + self.add_error('montante', 'O montante não pode ser negativo.') + if self._meta.model == Pagamento and not self.instance.pk: + count = data.get('numero_parcelas') or 1 + data['numero_parcelas'] = count + if count > 1: + if not data.get('data_limite'): + self.add_error('data_limite', 'Defina a data limite da primeira parcela.') + if data.get('montante') is not None and data['montante'] * 100 < count: + self.add_error('montante', 'O total deve permitir pelo menos 0,01 € por parcela.') + if data.get('data_conclusao'): + self.add_error('data_conclusao', 'Registe a conclusão de cada parcela individualmente, depois de criar o plano.') + if data.get('data_limite') and data.get('intervalo_dias'): + try: + data['data_limite'] + timedelta(days=data['intervalo_dias'] * (count - 1)) + except OverflowError: + self.add_error('intervalo_dias', 'O último vencimento ultrapassa o limite de datas. Reduza o intervalo ou o número de parcelas.') + elif data.get('intervalo_dias'): + self.add_error('intervalo_dias', 'O intervalo só é usado quando existem várias parcelas.') + if data.get('data_nascimento') and data['data_nascimento'] > date.today(): + self.add_error('data_nascimento', 'A data de nascimento não pode ser futura.') + for start, end in [('emissao_passaporte', 'validade_passaporte'), ('emissao_bi_cc', 'validade_bi_cc')]: + if data.get(start) and data.get(end) and data[start] > data[end]: + self.add_error(end, 'A validade deve ser posterior à emissão.') + return data + + +def record_form(resource): + return forms.modelform_factory(resource.model, form=RecordForm, fields=resource.fields) + + +class TemplateAdminForm(forms.ModelForm): + class Meta: + model = DocumentTemplate + fields = '__all__' + + def clean_required_fields(self): + fields = self.cleaned_data['required_fields'] + if not isinstance(fields, list) or len(fields) > 100 or any( + not isinstance(f, str) or not re.fullmatch(r'[\w]{1,80}', f) for f in fields + ): + raise ValidationError('Use uma lista JSON de nomes de campos simples (máximo 100).') + return fields + + def clean_file(self): + file = self.cleaned_data['file'] + if not file.name.lower().endswith('.docx') or file.size > 5 * 1024 * 1024: + raise ValidationError('Envie um DOCX com até 5 MB.') + try: + file.open('rb') + with zipfile.ZipFile(BytesIO(file.read())) as archive: + members = archive.infolist() + if sum(m.file_size for m in members) > 25 * 1024 * 1024: + raise ValidationError('O conteúdo descomprimido excede o limite.') + if 'word/document.xml' not in archive.namelist() or any('vbaProject' in m.filename for m in members): + raise ValidationError('O ficheiro não é um DOCX válido sem macros.') + except (zipfile.BadZipFile, OSError): + raise ValidationError('O ficheiro DOCX está danificado.') + finally: + file.seek(0) + return file + + +class GenerationForm(forms.Form): + cliente = forms.ModelChoiceField(queryset=Cliente.objects.all(), label='Cliente') + modelo = forms.ModelChoiceField(queryset=DocumentTemplate.objects.filter(active=True), label='Modelo') + guardar_drive = forms.BooleanField(label='Guardar na pasta Google Drive do cliente', required=False) + valor_contrato = forms.DecimalField(label='Valor do contrato (€)', max_digits=12, decimal_places=2, min_value=0, required=False) + numero_parcelas = forms.IntegerField(label='Número de prestações', min_value=1, max_value=120, required=False) + inicio_prestacao = forms.DateField(label='Início das prestações', widget=forms.DateInput(attrs={'type': 'date'}), required=False) diff --git a/web/office/google_calendar.py b/web/office/google_calendar.py new file mode 100644 index 0000000..527bbdb --- /dev/null +++ b/web/office/google_calendar.py @@ -0,0 +1,108 @@ +import json +import secrets +from datetime import timedelta, timezone as datetime_timezone + +import httplib2 +from cryptography.fernet import Fernet +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.utils import timezone +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_httplib2 import AuthorizedHttp +from google_auth_oauthlib.flow import Flow +from googleapiclient.discovery import build + +from .models import CalendarConnection, Configuration + +SCOPES = ['https://www.googleapis.com/auth/calendar.events'] + + +def flow(state=None): + if not all([settings.GOOGLE_CLIENT_ID, settings.GOOGLE_CLIENT_SECRET, settings.GOOGLE_REDIRECT_URI, settings.TOKEN_ENCRYPTION_KEY]): + raise ValueError('Integração Google não configurada') + return Flow.from_client_config({'web': { + 'client_id': settings.GOOGLE_CLIENT_ID, 'client_secret': settings.GOOGLE_CLIENT_SECRET, + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + }}, scopes=SCOPES, state=state, redirect_uri=settings.GOOGLE_REDIRECT_URI) + + +@login_required +def connect(request): + if not request.user.is_superuser: + raise PermissionDenied + if request.method != 'POST': + from django.http import HttpResponseNotAllowed + return HttpResponseNotAllowed(['POST']) + try: + oauth = flow() + url, state = oauth.authorization_url(access_type='offline', prompt='consent') + except ValueError: + messages.error(request, 'Configure as credenciais Google Web e a chave de cifragem no servidor.') + return redirect('dashboard') + request.session['google_state'] = state + request.session['google_started_at'] = timezone.now().timestamp() + return redirect(url) + + +@login_required +def callback(request): + if not request.user.is_superuser: + raise PermissionDenied + expected = request.session.pop('google_state', None) + started = request.session.pop('google_started_at', 0) + supplied = request.GET.get('state', '') + if not expected or not secrets.compare_digest(expected, supplied) or timezone.now().timestamp() - started > 600: + raise PermissionDenied('Ligação expirada ou inválida.') + try: + oauth = flow(expected) + # Exchange only the returned code; never trust the request Host for the callback URL. + oauth.fetch_token(code=request.GET.get('code', ''), timeout=15) + if not oauth.credentials.refresh_token: + raise ValueError('Falta refresh token') + encrypted = Fernet(settings.TOKEN_ENCRYPTION_KEY.encode()).encrypt(oauth.credentials.to_json().encode()).decode() + CalendarConnection.objects.update_or_create(key='office', defaults={'encrypted_credentials': encrypted}) + except Exception: + messages.error(request, 'Não foi possível ligar o Google Calendar. Tente novamente.') + else: + messages.success(request, 'Google Calendar ligado com sucesso.') + return redirect('dashboard') + + +def sync_event(event): + connection = CalendarConnection.objects.get(key='office') + cipher = Fernet(settings.TOKEN_ENCRYPTION_KEY.encode()) + credentials = Credentials.from_authorized_user_info(json.loads(cipher.decrypt(connection.encrypted_credentials.encode())), scopes=SCOPES) + if credentials.expired and credentials.refresh_token: + transport = Request() + credentials.refresh(lambda *args, **kwargs: transport(*args, **{**kwargs, 'timeout': 15})) + connection.encrypted_credentials = cipher.encrypt(credentials.to_json().encode()).decode() + connection.save() + duration = Configuration.objects.get(category=Configuration.Category.DURATION, label=event.duracao) + if not duration.minutes or not event.data_inicio: + raise ValueError('Data/duração inválida') + start = timezone.localtime(event.data_inicio) + body = { + 'summary': event.titulo, 'description': event.descricao or '', + 'start': {'dateTime': start.isoformat(), 'timeZone': settings.TIME_ZONE}, + 'end': {'dateTime': timezone.localtime(start.astimezone(datetime_timezone.utc) + timedelta(minutes=duration.minutes)).isoformat(), 'timeZone': settings.TIME_ZONE}, + } + service = build('calendar', 'v3', http=AuthorizedHttp(credentials, http=httplib2.Http(timeout=15)), cache_discovery=False) + # Deterministic ID makes retry safe when Google succeeded but saving locally failed. + event_id = event.google_event_id or f'himp{event.pk:x}' + events = service.events() + from googleapiclient.errors import HttpError + try: + events.get(calendarId=settings.GOOGLE_CALENDAR_ID, eventId=event_id).execute() + except HttpError as exc: + if exc.resp.status != 404: + raise + result = events.insert(calendarId=settings.GOOGLE_CALENDAR_ID, body={**body, 'id': event_id}).execute() + else: + result = events.update(calendarId=settings.GOOGLE_CALENDAR_ID, eventId=event_id, body=body).execute() + event.google_event_id = result['id'] + event.save(update_fields=['google_event_id']) diff --git a/web/office/google_drive.py b/web/office/google_drive.py new file mode 100644 index 0000000..7094e59 --- /dev/null +++ b/web/office/google_drive.py @@ -0,0 +1,166 @@ +"""Office-owned Drive documents; credentials and file contents stay server-side.""" +import json +from hashlib import sha256 +import secrets +from io import BytesIO + +import httplib2 +from cryptography.fernet import Fernet +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.shortcuts import redirect +from django.utils import timezone +from django.views.decorators.http import require_POST +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_httplib2 import AuthorizedHttp +from google_auth_oauthlib.flow import Flow +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseUpload + +from .models import Cliente, DocumentoCliente, DriveConnection + +SCOPES = ['https://www.googleapis.com/auth/drive.file'] +FOLDER = 'application/vnd.google-apps.folder' + + +def service_for(credentials): + return build('drive', 'v3', http=AuthorizedHttp(credentials, http=httplib2.Http(timeout=30)), cache_discovery=False) + + +@login_required +@require_POST +def connect(request): + if not request.user.is_superuser: + raise PermissionDenied + if not all([settings.GOOGLE_CLIENT_ID, settings.GOOGLE_CLIENT_SECRET, + settings.GOOGLE_DRIVE_REDIRECT_URI, settings.TOKEN_ENCRYPTION_KEY]): + messages.error(request, 'Configure as credenciais Google, o retorno do Drive e a chave de cifragem no servidor.') + return redirect('dashboard') + oauth = flow() + url, state = oauth.authorization_url(access_type='offline', prompt='consent') + request.session['drive_state'] = state + request.session['drive_started_at'] = timezone.now().timestamp() + return redirect(url) + + +def flow(state=None): + return Flow.from_client_config({'web': { + 'client_id': settings.GOOGLE_CLIENT_ID, 'client_secret': settings.GOOGLE_CLIENT_SECRET, + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + }}, scopes=SCOPES, state=state, redirect_uri=settings.GOOGLE_DRIVE_REDIRECT_URI) + + +@login_required +def callback(request): + if not request.user.is_superuser: + raise PermissionDenied + expected = request.session.pop('drive_state', None) + started = request.session.pop('drive_started_at', 0) + if not expected or not secrets.compare_digest(expected, request.GET.get('state', '')) or timezone.now().timestamp() - started > 600: + raise PermissionDenied('Ligação expirada ou inválida.') + try: + oauth = flow(expected) + oauth.fetch_token(code=request.GET.get('code', ''), timeout=30) + if not oauth.credentials.refresh_token or not oauth.credentials.has_scopes(SCOPES): + raise ValueError('Falta refresh token') + existing = DriveConnection.objects.filter(key='office').first() + # Reconnecting another account must not strand existing client folders. + if existing and existing.root_folder_id: + service_for(oauth.credentials).files().get(fileId=existing.root_folder_id, fields='id').execute() + encrypted = Fernet(settings.TOKEN_ENCRYPTION_KEY.encode()).encrypt(oauth.credentials.to_json().encode()).decode() + DriveConnection.objects.update_or_create(key='office', defaults={'encrypted_credentials': encrypted}) + except Exception: + messages.error(request, 'Não foi possível ligar o Drive. Se já existem pastas, volte a autorizar a mesma conta Google.') + else: + messages.success(request, 'Google Drive ligado. A pasta HIMP será criada ao preparar a primeira pasta de cliente.') + return redirect('dashboard') + + +def get_service(connection): + cipher = Fernet(settings.TOKEN_ENCRYPTION_KEY.encode()) + credentials = Credentials.from_authorized_user_info(json.loads(cipher.decrypt(connection.encrypted_credentials.encode())), scopes=SCOPES) + if credentials.expired and credentials.refresh_token: + transport = Request() + credentials.refresh(lambda *args, **kwargs: transport(*args, **{**kwargs, 'timeout': 30})) + connection.encrypted_credentials = cipher.encrypt(credentials.to_json().encode()).decode() + connection.save(update_fields=['encrypted_credentials', 'updated_at']) + return service_for(credentials) + + +def find(files, query): + result = files.list(q=query + ' and trashed = false', spaces='drive', fields='files(id)', pageSize=2).execute()['files'] + if len(result) > 1: + raise ValueError('Referências duplicadas no Drive; peça ao administrador para verificar as pastas.') + return result[0]['id'] if result else None + + +def folder_name(client): + name = ' '.join((client.nome_completo or 'Sem nome').split()) + return f'{client.pk} - {name}'[:200] + + +def ensure_folder(files, connection, client): + if not connection.root_folder_id: + root = find(files, "mimeType = 'application/vnd.google-apps.folder' and appProperties has { key='himpRoot' and value='1' }") + if not root: + root = files.create(body={'name': 'HIMP', 'mimeType': FOLDER, 'appProperties': {'himpRoot': '1'}}, fields='id').execute()['id'] + connection.root_folder_id = root + connection.save(update_fields=['root_folder_id']) + root = files.get(fileId=connection.root_folder_id, fields='id,trashed').execute() + if root.get('trashed'): + raise ValueError('A pasta principal está no lixo.') + name = folder_name(client) + if not client.drive_folder_id: + folder = find(files, f"'{connection.root_folder_id}' in parents and mimeType = '{FOLDER}' and appProperties has {{ key='himpClient' and value='{client.pk}' }}") + if not folder: + folder = files.create(body={'name': name, 'mimeType': FOLDER, 'parents': [connection.root_folder_id], + 'appProperties': {'himpClient': str(client.pk)}}, fields='id').execute()['id'] + client.drive_folder_id = folder + client.save(update_fields=['drive_folder_id']) + folder = files.get(fileId=client.drive_folder_id, fields='id,name,trashed').execute() + if folder.get('trashed'): + raise ValueError('A pasta do cliente está no lixo.') + if folder.get('name') != name: + files.update(fileId=client.drive_folder_id, body={'name': name}, fields='id').execute() + return client.drive_folder_id + + +@transaction.atomic +def create_client_folder(client_id): + connection = DriveConnection.objects.select_for_update().get(key='office') + client = Cliente.objects.select_for_update().get(pk=client_id) + return ensure_folder(get_service(connection).files(), connection, client) + + +@transaction.atomic +def upload_document(document_id, content, filename, mimetype): + # Serialize office folder creation and retries. appProperties recovers remote + # success if the local commit failed, without uploading duplicate documents. + connection = DriveConnection.objects.select_for_update().get(key='office') + document = DocumentoCliente.objects.select_for_update().get(pk=document_id) + client = Cliente.objects.select_for_update().get(pk=document.cliente_id) + if document.drive_file_id: + raise ValueError('Este registo já tem um ficheiro. Crie outro registo para uma nova versão.') + files = get_service(connection).files() + folder = ensure_folder(files, connection, client) + file_id = find(files, f"'{folder}' in parents and appProperties has {{ key='himpDocument' and value='{document.pk}' }}") + digest = sha256(content).hexdigest() + if file_id: + existing = files.get(fileId=file_id, fields='name,appProperties').execute() + if existing.get('appProperties', {}).get('himpHash') != digest: + raise ValueError('Existe um envio anterior com conteúdo diferente. Verifique a pasta do cliente.') + filename = existing['name'] + if not file_id: + media = MediaIoBaseUpload(BytesIO(content), mimetype=mimetype, resumable=False) + file_id = files.create(body={'name': filename, 'parents': [folder], + 'appProperties': {'himpDocument': str(document.pk), 'himpHash': digest}}, media_body=media, fields='id').execute()['id'] + document.drive_file_id = file_id + document.drive_filename = filename + document.entregue = True + document.save(update_fields=['drive_file_id', 'drive_filename', 'entregue']) + return document diff --git a/web/office/legacy_defaults.json b/web/office/legacy_defaults.json new file mode 100644 index 0000000..f663b62 --- /dev/null +++ b/web/office/legacy_defaults.json @@ -0,0 +1,189 @@ +{ + "estado_civil": [ + "Solteiro(a)", + "Casado(a)", + "Divorciado(a)", + "Viúvo(a)", + "União de Facto" + ], + "motivo_agenda": [ + "Consulta", + "Atendimento em Loja Externa", + "Andamento de Processo" + ], + "duracao_agenda": [ + "30 minutos", + "1 Hora", + "2 Horas", + "3 Horas" + ], + "documento": [ + "primeira página do passaporte e página de assinatura (first page and signature page of passport)", + "comprovativo de morada (proof of address)", + "visto e carimbo (visit visa and stamp of entry into Europe)", + "Título de residência (residence card)", + "finanças/nif/contribuinte (finance/nif/contributor)", + "cartão de embarque ou bilhete (boarding pass or bus ticket)", + "declaração de matrícula (declaration from school)", + "procuração assinada (signed power of attorney)", + "segurança social/niss (social security)", + "utente (sns hospital number)", + "extrato bancário (bank statement of 3000 euros)", + "junta de freguesia (proof of address)", + "contrato/atividade (self employment activity or contract)", + "recibo verde (salary slip/or green slip)", + "declaração não dívida (no debt certificate from finance)", + "declaração nao aplicação de sanções (no debt certificate from social)", + "declaração de frequencia (from school)", + "certidão criminal (police clearance certificate)", + "bilhete de hotel (hotel bill)", + "Certificado de língua portuguesa(Certificate of Portuguese Language)", + "Certidão de nascimento(Birth certificate)", + "Certidão de casamento(Marriage certificate)" + ], + "genero": [ + "Masculino", + "Feminino" + ], + "templates": { + "Procuração - Representação Aima": { + "arquivo": "Procuracao_Representacao_Aima.docx", + "campos": [ + "nome", + "nacionalidade", + "naturalidade", + "passaporte", + "validade_passaporte", + "nif", + "endereço", + "data_documento", + "nome_upper" + ] + }, + "Contrato de Prestaçao de Serviços Jurídicos- Ação Intimação": { + "arquivo": "Contrato_de_Prestacao_de_Serviços_Jurídicos_Acao_Intimacao.docx", + "campos": [ + "nome", + "nacionalidade", + "passaporte", + "validade_passaporte", + "nif", + "endereço", + "data_documento", + "estado_civil", + "valor_contrato", + "numero_parcelas", + "valor_contrato_div_parcelas" + ] + }, + "Procuração - Naturalização- Art.º 6.º, n.º 1": { + "arquivo": "Procuracao_Naturalização_Art_6_n_1.docx", + "campos": [ + "nome", + "nacionalidade", + "passaporte", + "validade_passaporte", + "nif", + "endereço", + "data_documento", + "titulo_residencia", + "validade_titulo_residencia", + "nome_upper", + "estado_civil" + ] + }, + "Procuração - Casamento - Art.º 3.º, n.º 1": { + "arquivo": "Procuracao_Casamento_Art_3_n_1.docx", + "campos": [ + "nome", + "nacionalidade", + "naturalidade", + "endereço", + "passaporte", + "emissao_passaporte", + "validade_passaporte", + "local_emissao_passaporte", + "data_documento", + "nome_upper", + "estado_civil" + ] + }, + "Procuração - Ação Intimação": { + "arquivo": "Procuracao_Acao_Intimacao.docx", + "campos": [ + "nome", + "nacionalidade", + "naturalidade", + "passaporte", + "validade_passaporte", + "nif", + "data_documento", + "endereço", + "nome_upper" + ] + }, + "Procuração - Filho Originário - Art.º 1º CPR": { + "arquivo": "Procuracao_Filho_Originário_Art_1_C.docx", + "campos": [ + "nome", + "nacionalidade", + "naturalidade", + "passaporte", + "validade_passaporte", + "emissao_passaporte", + "local_emissao_passaporte", + "data_documento", + "endereço", + "nome_upper", + "estado_civil" + ] + }, + "Procuração - (mãe) - Netos de Portugueses - Art.º 1º D": { + "arquivo": "Procuracao_mae_Netos_de_Portugueses_Art_1_D.docx", + "campos": [ + "nome", + "nacionalidade", + "naturalidade", + "passaporte", + "validade_passaporte", + "emissao_passaporte", + "local_emissao_passaporte", + "data_documento", + "endereço", + "nome_upper", + "estado_civil" + ] + }, + "Procuração - (pai) - Netos de Portugueses - Art.º 1º D": { + "arquivo": "Procuracao_pai_Netos_de_Portugueses_Art_1_D.docx", + "campos": [ + "nome", + "nacionalidade", + "naturalidade", + "passaporte", + "validade_passaporte", + "emissao_passaporte", + "local_emissao_passaporte", + "data_documento", + "endereço", + "nome_upper", + "estado_civil" + ] + }, + "Contrato de Prestação de Serviços Jurídicos - Nacionalidade": { + "arquivo": "Contrato_de_Prestacao_de_Servicos_Juridicos_Nacionalidade.docx", + "campos": [ + "nome", + "nacionalidade", + "valor_contrato", + "numero_parcelas", + "valor_contrato_div_parcelas", + "valor_contrato_string", + "mes_ano_inicio_prestacao", + "data_documento", + "endereço", + "valor_contrato_div_parcelas_string" + ] + } + } +} diff --git a/web/office/management/__init__.py b/web/office/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/office/management/commands/__init__.py b/web/office/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/office/management/commands/bootstrap_roles.py b/web/office/management/commands/bootstrap_roles.py new file mode 100644 index 0000000..69bd63e --- /dev/null +++ b/web/office/management/commands/bootstrap_roles.py @@ -0,0 +1,22 @@ +from django.contrib.auth.models import Group, Permission +from django.core.management.base import BaseCommand +from office.registry import RESOURCES + + +class Command(BaseCommand): + help = 'Cria funções de consulta, gestão e administração sem criar contas nem senhas.' + + def handle(self, *args, **options): + models = {r.model._meta.model_name for r in RESOURCES.values()} + viewer = [f'view_{name}' for name in models] + editor = viewer + [f'{action}_{name}' for name in models for action in ('add', 'change')] + editor += ['generate_document', 'sync_calendar'] + manager = editor + [f'delete_{name}' for name in models] + catalogues = ['configuration', 'entidade', 'tipoprocesso', 'fase', 'motivo', 'statuspagamento'] + manager += [f'{action}_{name}' for name in catalogues for action in ('view', 'add', 'change', 'delete')] + manager += ['view_auditevent', 'view_documenttemplate'] + for name, codenames in [('Consulta', viewer), ('Gestão', editor), ('Administração', manager)]: + group, _ = Group.objects.get_or_create(name=name) + group.permissions.set(Permission.objects.filter(content_type__app_label='office', codename__in=codenames)) + self.stdout.write(self.style.SUCCESS(f'Função configurada: {name}')) + self.stdout.write('Atribua as funções no admin. Apenas administradores de confiança devem ter is_staff; apenas o responsável técnico deve ser superuser.') diff --git a/web/office/management/commands/check_legacy_schema.py b/web/office/management/commands/check_legacy_schema.py new file mode 100644 index 0000000..dd49b66 --- /dev/null +++ b/web/office/management/commands/check_legacy_schema.py @@ -0,0 +1,38 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db import connection +from django.db.migrations.loader import MigrationLoader +from office import models + +LEGACY = [models.Cliente, models.Entidade, models.TipoProcesso, models.Fase, models.Processo, models.Etapa, models.Motivo, models.StatusPagamento, models.Pagamento, models.Agendamento, models.DocumentoCliente] + + +class Command(BaseCommand): + help = 'Verifica tabelas/colunas do desktop antes de migrate --fake-initial; não altera dados.' + + def handle(self, *args, **options): + failures = [] + # Compare the original desktop schema, before later Web migrations add + # fields such as Drive references to those same business tables. + legacy_apps = MigrationLoader(connection).project_state([('office', '0001_legacy_schema')]).apps + with connection.cursor() as cursor: + tables = set(connection.introspection.table_names(cursor)) + for model in LEGACY: + table = model._meta.db_table + if table not in tables: + failures.append(f'Falta a tabela {table}') + continue + actual = {col.name for col in connection.introspection.get_table_description(cursor, table)} + original = legacy_apps.get_model('office', model._meta.model_name) + required = {field.column for field in original._meta.fields} + missing = required - actual + if missing: + failures.append(f'{table}: faltam colunas {sorted(missing)}') + else: + self.stdout.write(f'{table}: colunas presentes') + if 'pagamento' in tables and connection.vendor == 'postgresql': + cursor.execute("SELECT count(*) FROM pagamento WHERE montante < 0 OR montante::text IN ('NaN', 'Infinity', '-Infinity') OR abs(montante) >= 1000000000000") + if cursor.fetchone()[0]: + failures.append('Há montantes inválidos ou fora do limite. Corrija-os na cópia de staging antes da migração.') + if failures: + raise CommandError('\n'.join(failures)) + self.stdout.write(self.style.SUCCESS('Pré-verificação concluída. Ainda é necessário comparar tipos, constraints, timezone e dados numa cópia de staging.')) diff --git a/web/office/middleware.py b/web/office/middleware.py new file mode 100644 index 0000000..9be039a --- /dev/null +++ b/web/office/middleware.py @@ -0,0 +1,18 @@ +class SecurityHeadersMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + # Django admin uses inline styles/scripts; its templates and CSRF controls + # are maintained by Django. The public office UI requires no inline code. + if not request.path.startswith('/admin/'): + response['Content-Security-Policy'] = ( + "default-src 'self'; script-src 'self'; style-src 'self'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; " + "frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + ) + if request.user.is_authenticated or request.path.startswith('/entrar/'): + response['Cache-Control'] = 'no-store, private' + response['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()' + return response diff --git a/web/office/migrations/0001_legacy_schema.py b/web/office/migrations/0001_legacy_schema.py new file mode 100644 index 0000000..2f2c53d --- /dev/null +++ b/web/office/migrations/0001_legacy_schema.py @@ -0,0 +1,200 @@ +# Generated by Django 5.2.18 on 2026-10-07 13:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Cliente', + fields=[ + ('cliente_id', models.AutoField(primary_key=True, serialize=False)), + ('nome_completo', models.TextField(blank=True, null=True, verbose_name='Nome completo')), + ('nif', models.TextField(blank=True, null=True, unique=True, verbose_name='NIF')), + ('niss', models.TextField(blank=True, null=True, unique=True, verbose_name='NISS')), + ('passaporte', models.TextField(blank=True, null=True, unique=True, verbose_name='Passaporte')), + ('titulo_residencia', models.TextField(blank=True, db_column='bi_cc_titulo_residência', null=True, unique=True, verbose_name='BI / CC / título de residência')), + ('data_nascimento', models.DateField(blank=True, null=True, verbose_name='Data de nascimento')), + ('genero', models.TextField(blank=True, db_column='gênero', null=True, verbose_name='Género')), + ('estado_civil', models.TextField(blank=True, null=True, verbose_name='Estado civil')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), + ('ddi', models.BigIntegerField(blank=True, null=True, verbose_name='Indicativo telefónico')), + ('contato', models.TextField(blank=True, null=True, verbose_name='Telefone')), + ('rua', models.TextField(blank=True, null=True, verbose_name='Rua')), + ('numero_rua', models.TextField(blank=True, null=True, verbose_name='Número')), + ('complemento', models.TextField(blank=True, null=True, verbose_name='Complemento')), + ('localidade', models.TextField(blank=True, null=True, verbose_name='Localidade')), + ('codigo_postal', models.TextField(blank=True, db_column='código_postal', null=True, verbose_name='Código postal')), + ('profissao', models.TextField(blank=True, db_column='profissão', null=True, verbose_name='Profissão')), + ('nacionalidade', models.TextField(blank=True, null=True, verbose_name='Nacionalidade')), + ('naturalidade', models.TextField(blank=True, null=True, verbose_name='Naturalidade')), + ('validade_passaporte', models.DateField(blank=True, null=True, verbose_name='Validade do passaporte')), + ('emissao_passaporte', models.DateField(blank=True, db_column='emissão_passaporte', null=True, verbose_name='Emissão do passaporte')), + ('local_emissao_passaporte', models.TextField(blank=True, db_column='local_emissão_passaporte', null=True, verbose_name='Local de emissão')), + ('validade_bi_cc', models.DateField(blank=True, null=True, verbose_name='Validade do BI / CC')), + ('emissao_bi_cc', models.DateField(blank=True, db_column='emissão_bi_cc', null=True, verbose_name='Emissão do BI / CC')), + ('notas_documento', models.TextField(blank=True, null=True, verbose_name='Notas')), + ], + options={ + 'verbose_name': 'Cliente', + 'db_table': 'cliente', + 'ordering': ['nome_completo', 'cliente_id'], + }, + ), + migrations.CreateModel( + name='Entidade', + fields=[ + ('entidade_id', models.BigAutoField(primary_key=True, serialize=False)), + ('entidade', models.TextField(verbose_name='Nome')), + ], + options={ + 'verbose_name': 'Entidade', + 'db_table': 'entidade', + 'ordering': ['entidade'], + }, + ), + migrations.CreateModel( + name='Fase', + fields=[ + ('fase_id', models.BigAutoField(primary_key=True, serialize=False)), + ('fase', models.TextField(verbose_name='Nome')), + ], + options={ + 'verbose_name': 'Fase de processo', + 'verbose_name_plural': 'Fases de processo', + 'db_table': 'lista_fases_processo', + 'ordering': ['fase'], + }, + ), + migrations.CreateModel( + name='Motivo', + fields=[ + ('motivo_id', models.BigAutoField(primary_key=True, serialize=False)), + ('motivo', models.TextField(verbose_name='Nome')), + ], + options={ + 'verbose_name': 'Motivo de pagamento', + 'verbose_name_plural': 'Motivos de pagamento', + 'db_table': 'motivo', + 'ordering': ['motivo'], + }, + ), + migrations.CreateModel( + name='StatusPagamento', + fields=[ + ('status_id', models.BigAutoField(primary_key=True, serialize=False)), + ('status', models.TextField(verbose_name='Nome')), + ], + options={ + 'verbose_name': 'Estado de pagamento', + 'verbose_name_plural': 'Estados de pagamento', + 'db_table': 'status_pagamento', + 'ordering': ['status'], + }, + ), + migrations.CreateModel( + name='TipoProcesso', + fields=[ + ('tipo_do_processo_id', models.BigAutoField(primary_key=True, serialize=False)), + ('tipo_do_processo', models.TextField(verbose_name='Nome')), + ], + options={ + 'verbose_name': 'Tipo de processo', + 'verbose_name_plural': 'Tipos de processo', + 'db_table': 'tipo_do_processo', + 'ordering': ['tipo_do_processo'], + }, + ), + migrations.CreateModel( + name='Agendamento', + fields=[ + ('evento_id', models.BigAutoField(primary_key=True, serialize=False)), + ('titulo', models.TextField(blank=True, null=True, verbose_name='Título')), + ('data_inicio', models.DateTimeField(blank=True, null=True, verbose_name='Data e hora de início')), + ('duracao', models.TextField(blank=True, null=True, verbose_name='Duração')), + ('motivo', models.TextField(blank=True, null=True, verbose_name='Motivo')), + ('descricao', models.TextField(blank=True, null=True, verbose_name='Descrição')), + ('google_event_id', models.TextField(blank=True, null=True)), + ('cliente', models.ForeignKey(blank=True, db_column='cliente_id', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='agendamentos', to='office.cliente')), + ], + options={ + 'verbose_name': 'Agendamento', + 'db_table': 'agendamento', + 'ordering': ['data_inicio', 'evento_id'], + 'permissions': [('sync_calendar', 'Pode sincronizar a agenda Google')], + }, + ), + migrations.CreateModel( + name='DocumentoCliente', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('documento_nome', models.TextField(verbose_name='Documento')), + ('entregue', models.BooleanField(blank=True, default=False, null=True, verbose_name='Entregue')), + ('cliente', models.ForeignKey(db_column='cliente_id', on_delete=django.db.models.deletion.PROTECT, related_name='documentos', to='office.cliente')), + ], + options={ + 'verbose_name': 'Documento do cliente', + 'verbose_name_plural': 'Documentos do cliente', + 'db_table': 'documentos_cliente', + 'ordering': ['documento_nome'], + }, + ), + migrations.CreateModel( + name='Processo', + fields=[ + ('processo_id', models.BigAutoField(primary_key=True, serialize=False)), + ('juiz', models.TextField(blank=True, null=True, verbose_name='Juiz')), + ('numero_processo', models.TextField(blank=True, null=True, unique=True, verbose_name='Número do processo')), + ('processo_anexo_principal', models.TextField(blank=True, null=True, verbose_name='Referência do anexo principal')), + ('cliente', models.ForeignKey(db_column='cliente_id', on_delete=django.db.models.deletion.PROTECT, related_name='processos', to='office.cliente')), + ('entidade', models.ForeignKey(blank=True, db_column='entidade_id', null=True, on_delete=django.db.models.deletion.PROTECT, to='office.entidade')), + ('tipo', models.ForeignKey(blank=True, db_column='tipo_do_processo_id', null=True, on_delete=django.db.models.deletion.PROTECT, to='office.tipoprocesso')), + ], + options={ + 'verbose_name': 'Processo', + 'db_table': 'processo', + 'ordering': ['-processo_id'], + }, + ), + migrations.CreateModel( + name='Etapa', + fields=[ + ('etapa_id', models.BigAutoField(primary_key=True, serialize=False)), + ('data_fase', models.DateField(blank=True, null=True, verbose_name='Data da fase')), + ('observacao', models.TextField(blank=True, db_column='observação', null=True, verbose_name='Observação')), + ('fase', models.ForeignKey(blank=True, db_column='fase_id', null=True, on_delete=django.db.models.deletion.PROTECT, to='office.fase')), + ('processo', models.ForeignKey(db_column='processo_id', on_delete=django.db.models.deletion.PROTECT, related_name='etapas', to='office.processo')), + ], + options={ + 'verbose_name': 'Etapa', + 'db_table': 'etapa_processo', + 'ordering': ['-data_fase', '-etapa_id'], + }, + ), + migrations.CreateModel( + name='Pagamento', + fields=[ + ('pagamento_id', models.BigAutoField(primary_key=True, serialize=False)), + ('entidade', models.IntegerField(blank=True, null=True, verbose_name='Entidade de pagamento')), + ('referencia', models.IntegerField(blank=True, null=True, verbose_name='Referência')), + ('montante', models.FloatField(blank=True, null=True, verbose_name='Montante (€)')), + ('data_limite', models.DateField(blank=True, null=True, verbose_name='Data limite')), + ('data_conclusao', models.DateField(blank=True, null=True, verbose_name='Data de conclusão')), + ('cliente', models.ForeignKey(blank=True, db_column='cliente_id', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pagamentos', to='office.cliente')), + ('motivo', models.ForeignKey(blank=True, db_column='motivo_id', null=True, on_delete=django.db.models.deletion.PROTECT, to='office.motivo')), + ('status', models.ForeignKey(blank=True, db_column='status_id', null=True, on_delete=django.db.models.deletion.PROTECT, to='office.statuspagamento')), + ], + options={ + 'verbose_name': 'Pagamento', + 'db_table': 'pagamento', + 'ordering': ['data_limite', '-pagamento_id'], + }, + ), + ] diff --git a/web/office/migrations/0002_web_tables.py b/web/office/migrations/0002_web_tables.py new file mode 100644 index 0000000..d536a1f --- /dev/null +++ b/web/office/migrations/0002_web_tables.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.18 on 2026-10-07 13:26 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '__first__'), + ('office', '0001_legacy_schema'), + ] + + operations = [ + migrations.CreateModel( + name='CalendarConnection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(default='office', max_length=30, unique=True)), + ('encrypted_credentials', models.TextField()), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='DocumentTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Nome')), + ('file', models.FileField(upload_to='templates/%Y/%m/', verbose_name='Modelo DOCX')), + ('required_fields', models.JSONField(default=list, verbose_name='Campos obrigatórios')), + ('active', models.BooleanField(default=True, verbose_name='Ativo')), + ], + options={ + 'verbose_name': 'Modelo de documento', + 'verbose_name_plural': 'Modelos de documento', + 'ordering': ['name'], + 'permissions': [('generate_document', 'Pode gerar documentos')], + }, + ), + migrations.CreateModel( + name='AuditEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=30)), + ('model', models.CharField(max_length=80)), + ('object_id', models.CharField(max_length=80)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Registo de auditoria', + 'verbose_name_plural': 'Registos de auditoria', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Configuration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('estado_civil', 'Estados civis'), ('genero', 'Géneros'), ('motivo_agenda', 'Motivos de agendamento'), ('duracao_agenda', 'Durações de agendamento'), ('documento', 'Documentos solicitados')], max_length=30, verbose_name='Categoria')), + ('label', models.CharField(max_length=300, verbose_name='Nome')), + ('minutes', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Minutos (para durações)')), + ('active', models.BooleanField(default=True, verbose_name='Ativo')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Ordem')), + ], + options={ + 'verbose_name': 'Opção configurável', + 'verbose_name_plural': 'Opções configuráveis', + 'ordering': ['order', 'label'], + 'constraints': [models.UniqueConstraint(fields=('category', 'label'), name='unique_configuration_label')], + }, + ), + ] diff --git a/web/office/migrations/0003_financial_and_timezone.py b/web/office/migrations/0003_financial_and_timezone.py new file mode 100644 index 0000000..c46fc22 --- /dev/null +++ b/web/office/migrations/0003_financial_and_timezone.py @@ -0,0 +1,25 @@ +from django.db import migrations, models +import django.core.validators + + +def normalize_timezone(apps, schema_editor): + if schema_editor.connection.vendor != 'postgresql': + return + with schema_editor.connection.cursor() as cursor: + cursor.execute("SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'agendamento' AND column_name = 'data_inicio'") + row = cursor.fetchone() + if row and row[0] == 'timestamp without time zone': + # The desktop stores Lisbon local wall time. Audit DST boundary records + # on a staging copy before this explicit timezone interpretation. + cursor.execute("ALTER TABLE agendamento ALTER COLUMN data_inicio TYPE timestamptz USING data_inicio AT TIME ZONE 'Europe/Lisbon'") + + +class Migration(migrations.Migration): + dependencies = [('office', '0002_web_tables')] + operations = [ + migrations.AlterField(model_name='pagamento', name='entidade', field=models.CharField('Entidade de pagamento', max_length=20, null=True, blank=True)), + migrations.AlterField(model_name='pagamento', name='referencia', field=models.CharField('Referência', max_length=50, null=True, blank=True)), + migrations.AlterField(model_name='pagamento', name='montante', field=models.DecimalField('Montante (€)', max_digits=14, decimal_places=2, null=True, blank=True, validators=[django.core.validators.MinValueValidator(0)])), + # Deliberately irreversible: reverting financial types loses references and cents. + migrations.RunPython(normalize_timezone), + ] diff --git a/web/office/migrations/0004_database_catalogues.py b/web/office/migrations/0004_database_catalogues.py new file mode 100644 index 0000000..a8ddf8a --- /dev/null +++ b/web/office/migrations/0004_database_catalogues.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path +from django.db import migrations + + +def seed_catalogues(apps, schema_editor): + Configuration = apps.get_model('office', 'Configuration') + DocumentTemplate = apps.get_model('office', 'DocumentTemplate') + alias = schema_editor.connection.alias + defaults = json.loads((Path(__file__).parent.parent / 'legacy_defaults.json').read_text(encoding='utf-8')) + for category, labels in defaults.items(): + if category == 'templates': + continue + for index, label in enumerate(labels): + minutes = None + if category == 'duracao_agenda': + minutes = int(label.split()[0]) * (60 if 'Hora' in label else 1) + Configuration.objects.using(alias).get_or_create(category=category, label=label, defaults={'order': index, 'minutes': minutes}) + # Include values already stored by desktop users, preserving their exact spelling. + for model, field, category in [('Cliente', 'estado_civil', 'estado_civil'), ('Cliente', 'genero', 'genero'), ('Agendamento', 'motivo', 'motivo_agenda'), ('Agendamento', 'duracao', 'duracao_agenda'), ('DocumentoCliente', 'documento_nome', 'documento')]: + for label in apps.get_model('office', model).objects.using(alias).exclude(**{f'{field}__isnull': True}).exclude(**{field: ''}).values_list(field, flat=True).distinct(): + Configuration.objects.using(alias).get_or_create(category=category, label=label) + for name, metadata in defaults['templates'].items(): + # This repository contains no DOCX files. Keep imported models inactive + # until a trusted administrator uploads the real template. + DocumentTemplate.objects.using(alias).get_or_create(name=name, defaults={'file': '', 'active': False, 'required_fields': metadata['campos']}) + + +class Migration(migrations.Migration): + dependencies = [('office', '0003_financial_and_timezone')] + operations = [migrations.RunPython(seed_catalogues, migrations.RunPython.noop)] diff --git a/web/office/migrations/0005_driveconnection_cliente_drive_folder_id_and_more.py b/web/office/migrations/0005_driveconnection_cliente_drive_folder_id_and_more.py new file mode 100644 index 0000000..f9dc2ca --- /dev/null +++ b/web/office/migrations/0005_driveconnection_cliente_drive_folder_id_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.18 on 2026-10-07 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('office', '0004_database_catalogues'), + ] + + operations = [ + migrations.CreateModel( + name='DriveConnection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(default='office', max_length=30, unique=True)), + ('encrypted_credentials', models.TextField()), + ('root_folder_id', models.CharField(blank=True, max_length=200)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddField( + model_name='cliente', + name='drive_folder_id', + field=models.CharField(blank=True, editable=False, max_length=200), + ), + migrations.AddField( + model_name='documentocliente', + name='drive_file_id', + field=models.CharField(blank=True, editable=False, max_length=200), + ), + migrations.AddField( + model_name='documentocliente', + name='drive_filename', + field=models.CharField(blank=True, editable=False, max_length=255), + ), + ] diff --git a/web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py b/web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py new file mode 100644 index 0000000..a6f54f0 --- /dev/null +++ b/web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.18 on 2026-10-07 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('office', '0005_driveconnection_cliente_drive_folder_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='pagamento', + name='numero_parcela', + field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True, verbose_name='Parcela'), + ), + migrations.AddField( + model_name='pagamento', + name='plano_id', + field=models.UUIDField(blank=True, db_index=True, editable=False, null=True), + ), + migrations.AddField( + model_name='pagamento', + name='total_parcelas', + field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True), + ), + ] diff --git a/web/office/migrations/__init__.py b/web/office/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/office/models.py b/web/office/models.py new file mode 100644 index 0000000..7bbd420 --- /dev/null +++ b/web/office/models.py @@ -0,0 +1,249 @@ +from django.conf import settings +from django.core.validators import MinValueValidator +from django.db import models + + +class Configuration(models.Model): + class Category(models.TextChoices): + MARITAL = 'estado_civil', 'Estados civis' + GENDER = 'genero', 'Géneros' + REASON = 'motivo_agenda', 'Motivos de agendamento' + DURATION = 'duracao_agenda', 'Durações de agendamento' + DOCUMENT = 'documento', 'Documentos solicitados' + category = models.CharField('Categoria', max_length=30, choices=Category.choices) + label = models.CharField('Nome', max_length=300) + minutes = models.PositiveIntegerField('Minutos (para durações)', null=True, blank=True, validators=[MinValueValidator(1)]) + active = models.BooleanField('Ativo', default=True) + order = models.PositiveIntegerField('Ordem', default=0) + + class Meta: + ordering = ['order', 'label'] + constraints = [models.UniqueConstraint(fields=['category', 'label'], name='unique_configuration_label')] + verbose_name = 'Opção configurável' + verbose_name_plural = 'Opções configuráveis' + + def __str__(self): + return self.label + + +class Cliente(models.Model): + drive_folder_id = models.CharField(max_length=200, blank=True, editable=False) + cliente_id = models.AutoField(primary_key=True) + nome_completo = models.TextField('Nome completo', null=True, blank=True) + nif = models.TextField('NIF', null=True, blank=True, unique=True) + niss = models.TextField('NISS', null=True, blank=True, unique=True) + passaporte = models.TextField('Passaporte', null=True, blank=True, unique=True) + titulo_residencia = models.TextField('BI / CC / título de residência', db_column='bi_cc_titulo_residência', null=True, blank=True, unique=True) + data_nascimento = models.DateField('Data de nascimento', null=True, blank=True) + genero = models.TextField('Género', db_column='gênero', null=True, blank=True) + estado_civil = models.TextField('Estado civil', null=True, blank=True) + email = models.EmailField('Email', null=True, blank=True) + ddi = models.BigIntegerField('Indicativo telefónico', null=True, blank=True) + contato = models.TextField('Telefone', null=True, blank=True) + rua = models.TextField('Rua', null=True, blank=True) + numero_rua = models.TextField('Número', null=True, blank=True) + complemento = models.TextField('Complemento', null=True, blank=True) + localidade = models.TextField('Localidade', null=True, blank=True) + codigo_postal = models.TextField('Código postal', db_column='código_postal', null=True, blank=True) + profissao = models.TextField('Profissão', db_column='profissão', null=True, blank=True) + nacionalidade = models.TextField('Nacionalidade', null=True, blank=True) + naturalidade = models.TextField('Naturalidade', null=True, blank=True) + validade_passaporte = models.DateField('Validade do passaporte', null=True, blank=True) + emissao_passaporte = models.DateField('Emissão do passaporte', db_column='emissão_passaporte', null=True, blank=True) + local_emissao_passaporte = models.TextField('Local de emissão', db_column='local_emissão_passaporte', null=True, blank=True) + validade_bi_cc = models.DateField('Validade do BI / CC', null=True, blank=True) + emissao_bi_cc = models.DateField('Emissão do BI / CC', db_column='emissão_bi_cc', null=True, blank=True) + notas_documento = models.TextField('Notas', null=True, blank=True) + + class Meta: + db_table = 'cliente' + ordering = ['nome_completo', 'cliente_id'] + verbose_name = 'Cliente' + + def __str__(self): + return self.nome_completo or f'Cliente {self.pk}' + + +class Entidade(models.Model): + entidade_id = models.BigAutoField(primary_key=True) + entidade = models.TextField('Nome') + class Meta: + db_table = 'entidade' + ordering = ['entidade'] + verbose_name = 'Entidade' + def __str__(self): + return self.entidade + + +class TipoProcesso(models.Model): + tipo_do_processo_id = models.BigAutoField(primary_key=True) + tipo_do_processo = models.TextField('Nome') + class Meta: + db_table = 'tipo_do_processo' + ordering = ['tipo_do_processo'] + verbose_name = 'Tipo de processo' + verbose_name_plural = 'Tipos de processo' + def __str__(self): + return self.tipo_do_processo + + +class Fase(models.Model): + fase_id = models.BigAutoField(primary_key=True) + fase = models.TextField('Nome') + class Meta: + db_table = 'lista_fases_processo' + ordering = ['fase'] + verbose_name = 'Fase de processo' + verbose_name_plural = 'Fases de processo' + def __str__(self): + return self.fase + + +class Processo(models.Model): + processo_id = models.BigAutoField(primary_key=True) + cliente = models.ForeignKey(Cliente, on_delete=models.PROTECT, db_column='cliente_id', related_name='processos') + entidade = models.ForeignKey(Entidade, on_delete=models.PROTECT, db_column='entidade_id', null=True, blank=True) + tipo = models.ForeignKey(TipoProcesso, on_delete=models.PROTECT, db_column='tipo_do_processo_id', null=True, blank=True) + juiz = models.TextField('Juiz', null=True, blank=True) + numero_processo = models.TextField('Número do processo', null=True, blank=True, unique=True) + processo_anexo_principal = models.TextField('Referência do anexo principal', null=True, blank=True) + class Meta: + db_table = 'processo' + ordering = ['-processo_id'] + verbose_name = 'Processo' + def __str__(self): + return self.numero_processo or f'Processo {self.pk}' + + +class Etapa(models.Model): + etapa_id = models.BigAutoField(primary_key=True) + processo = models.ForeignKey(Processo, on_delete=models.PROTECT, db_column='processo_id', related_name='etapas') + fase = models.ForeignKey(Fase, on_delete=models.PROTECT, db_column='fase_id', null=True, blank=True) + data_fase = models.DateField('Data da fase', null=True, blank=True) + observacao = models.TextField('Observação', db_column='observação', null=True, blank=True) + class Meta: + db_table = 'etapa_processo' + ordering = ['-data_fase', '-etapa_id'] + verbose_name = 'Etapa' + def __str__(self): + return f'{self.processo} · {self.fase or "Sem fase"}' + + +class Motivo(models.Model): + motivo_id = models.BigAutoField(primary_key=True) + motivo = models.TextField('Nome') + class Meta: + db_table = 'motivo' + ordering = ['motivo'] + verbose_name = 'Motivo de pagamento' + verbose_name_plural = 'Motivos de pagamento' + def __str__(self): + return self.motivo + + +class StatusPagamento(models.Model): + status_id = models.BigAutoField(primary_key=True) + status = models.TextField('Nome') + class Meta: + db_table = 'status_pagamento' + ordering = ['status'] + verbose_name = 'Estado de pagamento' + verbose_name_plural = 'Estados de pagamento' + def __str__(self): + return self.status + + +class Pagamento(models.Model): + plano_id = models.UUIDField(null=True, blank=True, editable=False, db_index=True) + numero_parcela = models.PositiveSmallIntegerField('Parcela', null=True, blank=True, editable=False) + total_parcelas = models.PositiveSmallIntegerField(null=True, blank=True, editable=False) + pagamento_id = models.BigAutoField(primary_key=True) + cliente = models.ForeignKey(Cliente, on_delete=models.PROTECT, db_column='cliente_id', null=True, blank=True, related_name='pagamentos') + entidade = models.CharField('Entidade de pagamento', max_length=20, null=True, blank=True) + referencia = models.CharField('Referência', max_length=50, null=True, blank=True) + montante = models.DecimalField('Montante (€)', max_digits=14, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0)]) + data_limite = models.DateField('Data limite', null=True, blank=True) + data_conclusao = models.DateField('Data de conclusão', null=True, blank=True) + status = models.ForeignKey(StatusPagamento, on_delete=models.PROTECT, db_column='status_id', null=True, blank=True) + motivo = models.ForeignKey(Motivo, on_delete=models.PROTECT, db_column='motivo_id', null=True, blank=True) + class Meta: + db_table = 'pagamento' + ordering = ['data_limite', '-pagamento_id'] + verbose_name = 'Pagamento' + def __str__(self): + label = f'Parcela {self.numero_parcela} de {self.total_parcelas}' if self.plano_id else f'Pagamento {self.pk}' + return f'{label} · {self.cliente or "Sem cliente"}' + + +class Agendamento(models.Model): + evento_id = models.BigAutoField(primary_key=True) + cliente = models.ForeignKey(Cliente, on_delete=models.PROTECT, db_column='cliente_id', null=True, blank=True, related_name='agendamentos') + titulo = models.TextField('Título', null=True, blank=True) + data_inicio = models.DateTimeField('Data e hora de início', null=True, blank=True) + duracao = models.TextField('Duração', null=True, blank=True) + motivo = models.TextField('Motivo', null=True, blank=True) + descricao = models.TextField('Descrição', null=True, blank=True) + google_event_id = models.TextField(null=True, blank=True) + class Meta: + db_table = 'agendamento' + ordering = ['data_inicio', 'evento_id'] + verbose_name = 'Agendamento' + permissions = [('sync_calendar', 'Pode sincronizar a agenda Google')] + def __str__(self): + return self.titulo or f'Agendamento {self.pk}' + + +class DocumentoCliente(models.Model): + drive_file_id = models.CharField(max_length=200, blank=True, editable=False) + drive_filename = models.CharField(max_length=255, blank=True, editable=False) + id = models.AutoField(primary_key=True) + cliente = models.ForeignKey(Cliente, on_delete=models.PROTECT, db_column='cliente_id', related_name='documentos') + documento_nome = models.TextField('Documento') + entregue = models.BooleanField('Entregue', null=True, blank=True, default=False) + class Meta: + db_table = 'documentos_cliente' + ordering = ['documento_nome'] + verbose_name = 'Documento do cliente' + verbose_name_plural = 'Documentos do cliente' + def __str__(self): + return self.documento_nome + + +class DocumentTemplate(models.Model): + name = models.CharField('Nome', max_length=200, unique=True) + file = models.FileField('Modelo DOCX', upload_to='templates/%Y/%m/') + required_fields = models.JSONField('Campos obrigatórios', default=list) + active = models.BooleanField('Ativo', default=True) + class Meta: + ordering = ['name'] + verbose_name = 'Modelo de documento' + verbose_name_plural = 'Modelos de documento' + permissions = [('generate_document', 'Pode gerar documentos')] + def __str__(self): + return self.name + + +class CalendarConnection(models.Model): + # A shared office calendar; tokens must never be displayed in admin/forms. + key = models.CharField(max_length=30, unique=True, default='office') + encrypted_credentials = models.TextField() + updated_at = models.DateTimeField(auto_now=True) + + +class DriveConnection(models.Model): + key = models.CharField(max_length=30, unique=True, default='office') + encrypted_credentials = models.TextField() + root_folder_id = models.CharField(max_length=200, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + +class AuditEvent(models.Model): + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + action = models.CharField(max_length=30) + model = models.CharField(max_length=80) + object_id = models.CharField(max_length=80) + created_at = models.DateTimeField(auto_now_add=True) + class Meta: + ordering = ['-created_at'] + verbose_name = 'Registo de auditoria' + verbose_name_plural = 'Registos de auditoria' diff --git a/web/office/payment_installments.py b/web/office/payment_installments.py new file mode 100644 index 0000000..c511849 --- /dev/null +++ b/web/office/payment_installments.py @@ -0,0 +1,29 @@ +from datetime import timedelta +from decimal import Decimal +from uuid import uuid4 + +from .models import Pagamento + + +def create_installments(form): + """Called inside the view transaction, after validating the creation form.""" + data = form.cleaned_data + count = data['numero_parcelas'] + if count == 1: + return [form.save()] + # Work in integer cents; distribute the remainder over the first parcels. + cents, remainder = divmod(int(data['montante'] * 100), count) + plan = uuid4() + payments = [] + for index in range(count): + due = data['data_limite'] if index == 0 else None + if data.get('intervalo_dias'): + due = data['data_limite'] + timedelta(days=index * data['intervalo_dias']) + payments.append(Pagamento.objects.create( + cliente=data['cliente'], entidade=data.get('entidade'), referencia=data.get('referencia'), + motivo=data.get('motivo'), status=data.get('status'), + montante=Decimal(cents + (1 if index < remainder else 0)) / 100, + data_limite=due, data_conclusao=None, plano_id=plan, + numero_parcela=index + 1, total_parcelas=count, + )) + return payments diff --git a/web/office/registry.py b/web/office/registry.py new file mode 100644 index 0000000..2085f57 --- /dev/null +++ b/web/office/registry.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from . import models + +@dataclass(frozen=True) +class Resource: + model: type + title: str + description: str + fields: tuple + columns: tuple + search: tuple + +RESOURCES = { + 'clientes': Resource(models.Cliente, 'Clientes', 'Pessoas, contactos e documentação num só lugar.', + tuple(f.name for f in models.Cliente._meta.fields if not f.primary_key and f.editable), + ('nome_completo', 'nif', 'email', 'contato', 'localidade'), + ('nome_completo', 'nif', 'passaporte', 'titulo_residencia', 'email', 'processos__numero_processo')), + 'processos': Resource(models.Processo, 'Processos', 'Acompanhe cada processo e o seu histórico de etapas.', + ('cliente', 'numero_processo', 'tipo', 'entidade', 'juiz', 'processo_anexo_principal'), + ('numero_processo', 'cliente', 'tipo', 'entidade'), ('numero_processo', 'cliente__nome_completo')), + 'etapas': Resource(models.Etapa, 'Etapas', 'Registe a evolução dos processos.', + ('processo', 'fase', 'data_fase', 'observacao'), ('processo', 'fase', 'data_fase', 'observacao'), + ('processo__numero_processo', 'processo__cliente__nome_completo', 'fase__fase')), + 'pagamentos': Resource(models.Pagamento, 'Pagamentos', 'Prazos, referências e valores com precisão de cêntimos.', + ('cliente', 'entidade', 'referencia', 'montante', 'data_limite', 'data_conclusao', 'status', 'motivo'), + ('cliente', 'numero_parcela', 'montante', 'data_limite', 'status', 'referencia'), ('cliente__nome_completo', 'referencia')), + 'agenda': Resource(models.Agendamento, 'Agenda', 'Organize os atendimentos e sincronize com o Google Calendar.', + ('cliente', 'titulo', 'data_inicio', 'duracao', 'motivo', 'descricao'), + ('titulo', 'cliente', 'data_inicio', 'duracao', 'motivo'), ('titulo', 'cliente__nome_completo', 'motivo')), + 'documentos': Resource(models.DocumentoCliente, 'Documentos', 'Controle os documentos recebidos e por entregar.', + ('cliente', 'documento_nome', 'entregue'), ('cliente', 'documento_nome', 'entregue'), + ('cliente__nome_completo', 'documento_nome')), +} diff --git a/web/office/report_forms.py b/web/office/report_forms.py new file mode 100644 index 0000000..50a682b --- /dev/null +++ b/web/office/report_forms.py @@ -0,0 +1,18 @@ +from django import forms +from .models import Cliente + + +class FinancialReportForm(forms.Form): + inicio = forms.DateField(label='De', widget=forms.DateInput(format='%Y-%m-%d', attrs={'type': 'date'})) + fim = forms.DateField(label='Até', widget=forms.DateInput(format='%Y-%m-%d', attrs={'type': 'date'})) + cliente = forms.ModelChoiceField(label='Cliente', queryset=Cliente.objects.all(), required=False, empty_label='Todos os clientes') + + def clean(self): + data = super().clean() + start, end = data.get('inicio'), data.get('fim') + if start and end: + if start > end: + self.add_error('fim', 'A data final deve ser igual ou posterior à data inicial.') + elif (end.year - start.year) * 12 + end.month - start.month >= 36: + self.add_error('fim', 'Escolha um período de até 36 meses.') + return data diff --git a/web/office/templatetags/__init__.py b/web/office/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/office/templatetags/office_tags.py b/web/office/templatetags/office_tags.py new file mode 100644 index 0000000..12a8c76 --- /dev/null +++ b/web/office/templatetags/office_tags.py @@ -0,0 +1,27 @@ +from datetime import date, datetime +from decimal import Decimal +from django import template +from django.utils.formats import date_format, number_format +from django.utils import timezone + +register = template.Library() + +@register.filter +def display_value(value): + if value is None or value == '': + return '—' + if isinstance(value, bool): + return 'Sim' if value else 'Não' + if isinstance(value, datetime): + return date_format(timezone.localtime(value), 'd/m/Y H:i') + if isinstance(value, date): + return date_format(value, 'd/m/Y') + if isinstance(value, Decimal): + return number_format(value, 2) + ' €' + return str(value) + +@register.simple_tag(takes_context=True) +def page_query(context, number): + params = context['request'].GET.copy() + params['page'] = number + return params.urlencode() diff --git a/web/office/test_financial_reports.py b/web/office/test_financial_reports.py new file mode 100644 index 0000000..5a9d12a --- /dev/null +++ b/web/office/test_financial_reports.py @@ -0,0 +1,121 @@ +from datetime import date +from decimal import Decimal +from unittest.mock import patch + +from django.contrib.auth.models import User, Permission +from django.test import TestCase, override_settings + +from .financial_reports import build_report, preset_dates +from .models import Cliente, Motivo, Pagamento + + +@override_settings(STORAGES={'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'}, 'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'}}) +class FinancialReportTests(TestCase): + def setUp(self): + self.user = User.objects.create_user('relatorios', password='Senha-para-testes-2026!') + self.user.user_permissions.add(Permission.objects.get(content_type__app_label='office', codename='view_pagamento')) + self.person = Cliente.objects.create(nome_completo='Cliente dos relatórios') + self.client.force_login(self.user) + self.start, self.end, self.today = date(2026, 10, 1), date(2026, 10, 31), date(2026, 10, 7) + + def payment(self, amount, due=None, completed=None, **kwargs): + return Pagamento.objects.create(cliente=self.person, montante=amount, data_limite=due, data_conclusao=completed, **kwargs) + + def test_receipt_date_and_due_date_have_distinct_meanings(self): + self.payment(Decimal('25.10'), date(2026, 9, 15), date(2026, 10, 2)) + self.payment(Decimal('100'), date(2026, 10, 10), date(2026, 9, 20)) + self.payment(Decimal('19.99'), date(2026, 10, 1)) + self.payment(Decimal('80'), date(2026, 10, 7)) + self.payment(Decimal('500'), date(2026, 11, 1)) + report = build_report(self.start, self.end, self.today) + self.assertEqual(report['received_total'], Decimal('25.10')) + self.assertEqual(report['pending_total'], Decimal('99.99')) + self.assertEqual(report['overdue_total'], Decimal('19.99')) + self.assertEqual(report['expected_total'], Decimal('199.99')) + self.assertEqual(report['distribution_total'], Decimal('125.09')) + self.assertEqual(report['months'][0]['received'], Decimal('25.10')) + self.assertEqual(report['months'][0]['expected'], Decimal('199.99')) + + def test_client_filter_is_applied_to_all_totals(self): + other = Cliente.objects.create(nome_completo='Outro cliente') + self.payment(Decimal('20'), date(2026, 10, 2)) + Pagamento.objects.create(cliente=other, montante=Decimal('1000'), data_limite=date(2026, 10, 2)) + report = build_report(self.start, self.end, self.today, self.person.pk) + self.assertEqual(report['pending_total'], Decimal('20')) + response = self.client.get('/relatorios/financeiro/', {'inicio': '2026-10-01', 'fim': '2026-10-31', 'cliente': self.person.pk}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['report']['pending_total'], Decimal('20')) + + def test_empty_report_and_month_gaps_are_safe(self): + self.payment(Decimal('10'), completed=date(2026, 7, 1)) + report = build_report(date(2026, 5, 1), self.end, self.today) + self.assertEqual(len(report['months']), 6) + self.assertEqual(report['months'][0]['received'], Decimal('0')) + self.assertEqual(report['months'][2]['received'], Decimal('10')) + empty = build_report(self.start, self.end, self.today) + self.assertFalse(empty['has_amounts']) + self.assertEqual(empty['distribution_total'], Decimal('0')) + self.assertEqual(self.client.get('/relatorios/financeiro/').status_code, 200) + + def test_dates_invalid_partial_reversed_and_excessive_rejected(self): + for query in [ + {'inicio': 'invalid', 'fim': '2026-10-31'}, + {'inicio': '2026-10-01'}, + {'inicio': '2026-11-01', 'fim': '2026-10-31'}, + {'inicio': '2020-01-01', 'fim': '2026-10-31'}, + {'inicio': '2026-10-01', 'fim': '2026-10-31', 'cliente': '999999'}, + ]: + with self.subTest(query=query), patch('office.financial_reports.build_report') as builder: + response = self.client.get('/relatorios/financeiro/', query) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.context['report']) + self.assertTrue(response.context['form'].errors) + builder.assert_not_called() + + def test_unauthorized_access_and_navigation(self): + self.client.logout() + self.assertEqual(self.client.get('/relatorios/financeiro/').status_code, 302) + user = User.objects.create_user('sem-financas', password='Senha-para-testes-2026!') + self.client.force_login(user) + self.assertEqual(self.client.get('/relatorios/financeiro/').status_code, 403) + self.assertNotContains(self.client.get('/'), 'href="/relatorios/financeiro/"') + self.client.force_login(self.user) + self.assertContains(self.client.get('/'), 'href="/relatorios/financeiro/"') + + def test_aging_bucket_boundaries(self): + from datetime import timedelta + for days in [1, 30, 31, 60, 61, 90, 91]: + self.payment(Decimal('10'), self.today - timedelta(days=days)) + report = build_report(date(2026, 1, 1), self.end, self.today) + self.assertEqual([item['count'] for item in report['aging']], [2, 2, 2, 1]) + self.assertEqual(sum((item['amount'] for item in report['aging']), Decimal('0')), report['overdue_total']) + + def test_missing_dates_and_amounts_explained_without_inventing_revenue(self): + self.payment(None, date(2026, 10, 2)) + self.payment(Decimal('90')) + self.payment(Decimal('-10'), date(2026, 10, 2)) + report = build_report(self.start, self.end, self.today) + self.assertEqual(report['missing_amount'], 1) + self.assertEqual(report['missing_dates'], 1) + self.assertEqual(report['invalid_amount'], 1) + self.assertEqual(report['received_total'], Decimal('0')) + self.assertEqual(report['pending_total'], Decimal('0')) + + def test_category_totals_and_escaping(self): + for index in range(7): + reason = Motivo.objects.create(motivo=f'Motivo {index}') + self.payment(Decimal(index + 1), completed=date(2026, 10, 2), motivo=reason) + malicious = Motivo.objects.create(motivo='') + self.payment(Decimal('99'), completed=date(2026, 10, 3), motivo=malicious) + report = build_report(self.start, self.end, self.today) + self.assertEqual(len(report['categories']), 6) + self.assertEqual(sum((item['amount'] for item in report['categories']), Decimal('0')), report['received_total']) + response = self.client.get('/relatorios/financeiro/', {'inicio': '2026-10-01', 'fim': '2026-10-31'}) + self.assertNotContains(response, '') + self.assertContains(response, '<script>alert(1)</script>') + self.assertEqual(response['Cache-Control'], 'no-store, private') + + def test_presets_use_calendar_boundaries(self): + self.assertEqual(preset_dates('mes', date(2024, 2, 15)), (date(2024, 2, 1), date(2024, 2, 29))) + self.assertEqual(preset_dates('6m', date(2026, 1, 4)), (date(2025, 8, 1), date(2026, 1, 31))) + self.assertEqual(preset_dates('ano', self.today), (date(2026, 1, 1), date(2026, 12, 31))) diff --git a/web/office/test_google_drive.py b/web/office/test_google_drive.py new file mode 100644 index 0000000..754c0b4 --- /dev/null +++ b/web/office/test_google_drive.py @@ -0,0 +1,218 @@ +from io import BytesIO, StringIO +from types import SimpleNamespace +from hashlib import sha256 +import tempfile +from cryptography.fernet import Fernet +from unittest.mock import MagicMock, patch + +from django.contrib.auth.models import User, Permission +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.db import connection +from django.test import TestCase, override_settings +from docx import Document + +from .forms import DriveUploadForm, record_form +from .google_drive import create_client_folder, upload_document +from .models import Cliente, Configuration, DocumentoCliente, DriveConnection, DocumentTemplate +from .registry import RESOURCES + + +class DriveTests(TestCase): + def setUp(self): + self.admin = User.objects.create_superuser('admin', password='Test-local-2026!') + self.viewer = User.objects.create_user('viewer') + self.viewer.user_permissions.add(*Permission.objects.filter(codename__startswith='view_', content_type__app_label='office')) + self.person = Cliente.objects.create(nome_completo='Ana Teste') + self.other = Cliente.objects.create(nome_completo='Outro Cliente') + Configuration.objects.create(category='documento', label='Passaporte') + self.document = DocumentoCliente.objects.create(cliente=self.person, documento_nome='Passaporte') + self.client.force_login(self.admin) + + def pdf(self): + return SimpleUploadedFile('passaporte.pdf', b'%PDF-1.7\nexample', content_type='application/pdf') + + def test_legacy_preflight_does_not_require_new_drive_columns(self): + from .management.commands.check_legacy_schema import LEGACY + tables = {model._meta.db_table: model for model in LEGACY} + def columns(cursor, table): + return [SimpleNamespace(name=f.column) for f in tables[table]._meta.fields if not f.name.startswith('drive_')] + with patch.object(connection.introspection, 'table_names', return_value=list(tables)), \ + patch.object(connection.introspection, 'get_table_description', side_effect=columns), \ + patch.object(connection, 'cursor') as cursor: + cursor.return_value.__enter__.return_value.fetchone.return_value = (0,) + output = StringIO() + call_command('check_legacy_schema', stdout=output) + self.assertIn('Pré-verificação concluída', output.getvalue()) + + def test_client_flow_keeps_context_and_ignores_forged_client(self): + url = f'/gestao/documentos/novo/?cliente={self.person.pk}' + listing = self.client.get(f'/gestao/documentos/?cliente={self.person.pk}') + self.assertContains(listing, url) + form = self.client.get(url) + self.assertContains(form, 'Ana Teste') + self.assertNotContains(form, '{% if request.GET.cliente %}{% endif %}{% if request.GET.filtro %}{% endif %}Limpar{{ page.paginator.count }} registo{{ page.paginator.count|pluralize:'s' }} +
{% for header in headers %}{% endfor %}{% for row in rows %}{% for cell in row.cells %}{% endfor %}{% empty %}{% endfor %}
{{ header }}Ação
{{ cell|display_value }}Abrir
◇

{% if query %}Nenhum resultado{% else %}Ainda sem registos{% endif %}

{% if query %}Experimente pesquisar por outro nome ou identificador.{% else %}Os registos adicionados aparecerão nesta lista.{% endif %}

+ +{% endblock %} diff --git a/web/templates/registration/login.html b/web/templates/registration/login.html new file mode 100644 index 0000000..a03ddd8 --- /dev/null +++ b/web/templates/registration/login.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block title %}Entrar · HIMP{% endblock %}{% block content %}{% endblock %} diff --git a/web/templates/registration/password_change.html b/web/templates/registration/password_change.html new file mode 100644 index 0000000..d809857 --- /dev/null +++ b/web/templates/registration/password_change.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block content %}

Alterar senha.

{% csrf_token %}{% include 'office/form_fields.html' %}
{% endblock %} diff --git a/web/templates/registration/password_done.html b/web/templates/registration/password_done.html new file mode 100644 index 0000000..cf695f5 --- /dev/null +++ b/web/templates/registration/password_done.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block content %}

Senha alterada

A sua nova senha está ativa.

Voltar ao escritório
{% endblock %}