From c99f7484f1e476357929df39c46a1df463b42a3c Mon Sep 17 00:00:00 2001 From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:50:14 +0100 Subject: [PATCH 1/7] Migrate HIMP to a web application with configurable catalogues and protected access --- .dockerignore | 9 + .env | 2 - .gitattributes | 1 + .github/workflows/web.yml | 44 ++ .gitignore | 13 + Dockerfile | 13 + README.md | 434 ++--------- compose.yaml | 20 + docs/DESKTOP.md | 408 +++++++++++ docs/MIGRATION.md | 43 ++ docs/supabase_hardening.sql | 34 + requirements-web.txt | 12 + web/.env.example | 13 + web/himp/__init__.py | 0 web/himp/settings.py | 108 +++ web/himp/urls.py | 23 + web/himp/wsgi.py | 4 + web/manage.py | 7 + web/office/__init__.py | 0 web/office/admin.py | 42 ++ web/office/apps.py | 6 + web/office/context.py | 10 + web/office/forms.py | 110 +++ web/office/google_calendar.py | 108 +++ web/office/legacy_defaults.json | 189 +++++ web/office/management/__init__.py | 0 web/office/management/commands/__init__.py | 0 .../management/commands/bootstrap_roles.py | 22 + .../commands/check_legacy_schema.py | 33 + web/office/middleware.py | 18 + web/office/migrations/0001_legacy_schema.py | 200 +++++ web/office/migrations/0002_web_tables.py | 75 ++ .../migrations/0003_financial_and_timezone.py | 25 + .../migrations/0004_database_catalogues.py | 31 + web/office/migrations/__init__.py | 0 web/office/models.py | 235 ++++++ web/office/registry.py | 33 + web/office/templatetags/__init__.py | 0 web/office/templatetags/office_tags.py | 27 + web/office/tests.py | 228 ++++++ web/office/views.py | 237 ++++++ web/static/office/app.css | 692 ++++++++++++++++++ web/templates/403.html | 1 + web/templates/404.html | 1 + web/templates/500.html | 1 + web/templates/office/base.html | 31 + web/templates/office/dashboard.html | 13 + web/templates/office/delete.html | 1 + web/templates/office/detail.html | 9 + web/templates/office/form.html | 9 + web/templates/office/form_fields.html | 2 + web/templates/office/generate.html | 1 + web/templates/office/list.html | 8 + web/templates/registration/login.html | 1 + .../registration/password_change.html | 1 + web/templates/registration/password_done.html | 1 + 56 files changed, 3214 insertions(+), 375 deletions(-) create mode 100644 .dockerignore delete mode 100644 .env create mode 100644 .gitattributes create mode 100644 .github/workflows/web.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docs/DESKTOP.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/supabase_hardening.sql create mode 100644 requirements-web.txt create mode 100644 web/.env.example create mode 100644 web/himp/__init__.py create mode 100644 web/himp/settings.py create mode 100644 web/himp/urls.py create mode 100644 web/himp/wsgi.py create mode 100644 web/manage.py create mode 100644 web/office/__init__.py create mode 100644 web/office/admin.py create mode 100644 web/office/apps.py create mode 100644 web/office/context.py create mode 100644 web/office/forms.py create mode 100644 web/office/google_calendar.py create mode 100644 web/office/legacy_defaults.json create mode 100644 web/office/management/__init__.py create mode 100644 web/office/management/commands/__init__.py create mode 100644 web/office/management/commands/bootstrap_roles.py create mode 100644 web/office/management/commands/check_legacy_schema.py create mode 100644 web/office/middleware.py create mode 100644 web/office/migrations/0001_legacy_schema.py create mode 100644 web/office/migrations/0002_web_tables.py create mode 100644 web/office/migrations/0003_financial_and_timezone.py create mode 100644 web/office/migrations/0004_database_catalogues.py create mode 100644 web/office/migrations/__init__.py create mode 100644 web/office/models.py create mode 100644 web/office/registry.py create mode 100644 web/office/templatetags/__init__.py create mode 100644 web/office/templatetags/office_tags.py create mode 100644 web/office/tests.py create mode 100644 web/office/views.py create mode 100644 web/static/office/app.css create mode 100644 web/templates/403.html create mode 100644 web/templates/404.html create mode 100644 web/templates/500.html create mode 100644 web/templates/office/base.html create mode 100644 web/templates/office/dashboard.html create mode 100644 web/templates/office/delete.html create mode 100644 web/templates/office/detail.html create mode 100644 web/templates/office/form.html create mode 100644 web/templates/office/form_fields.html create mode 100644 web/templates/office/generate.html create mode 100644 web/templates/office/list.html create mode 100644 web/templates/registration/login.html create mode 100644 web/templates/registration/password_change.html create mode 100644 web/templates/registration/password_done.html 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..4f7cc98 100644 --- a/README.md +++ b/README.md @@ -1,408 +1,96 @@ -# 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. +- 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 - -``` - ---- - -## 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 +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 ``` -> 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. - ---- +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. -## Document generator configuration +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. -The document generator reads a dictionary `documentos_info` inside `gerador_docs.py` +| 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 | -```python -documentos_info = { - "Modelo de Documento": { - "arquivo": "modelo_documento.docx", - "campos": ["nome", "nif", "endereço"] - } -} -``` +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. -### Steps to add templates +## Base de dados e listas -1. Put your `.docx` template in `gerador_documentos/` (for example `gerador_documentos/modelo_documento.docx`). +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. -2. In `gerador_docs.py`, add an entry to `documentos_info` for each template: +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. - - 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) +## Documentos -3. Example: - -```python -documentos_info = { - "Power of Attorney - Example": { - "arquivo": "procuracao_example.docx", - "campos": ["nome", "passaporte", "nif", "endereço", "data_documento"] - } -} -``` +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. ---- +`processo_anexo_principal` conserva uma referência textual. Upload/download de anexos de processos ou de ficheiros de clientes ainda não faz parte desta versão. A área Documentos controla entregas e gera DOCX. Valores por prestação são arredondados a cêntimos; confirme eventuais acertos no contrato. -## Application configuration (`pagina.py`) +## Google Calendar -`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: +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: -```python -self.modelos_de_documento = [ - "Modelo de Documento" -] +```sh +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` -### Fixed-value boxes +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. -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. +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. ---- +## Publicação -## 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 +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 ``` -Notes: +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 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). +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. ---- +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. -## Run / Usage +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. -While developing or running from source: +## Validação -```bash -source .venv/bin/activate -python main.py +```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 ``` -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 +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..321a2e6 --- /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. 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..83f1b2a --- /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_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..a66c985 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,13 @@ +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 +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..8011b28 --- /dev/null +++ b/web/himp/settings.py @@ -0,0 +1,108 @@ +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') +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..1d3bb25 --- /dev/null +++ b/web/himp/urls.py @@ -0,0 +1,23 @@ +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 + +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('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('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..30c9bf7 --- /dev/null +++ b/web/office/context.py @@ -0,0 +1,10 @@ +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'), + ('documentos', 'Documentos', 'office.view_documentocliente'), + ] + return {'navigation': [(key, label) for key, label, perm in items if request.user.has_perm(perm)]} diff --git a/web/office/forms.py b/web/office/forms.py new file mode 100644 index 0000000..9de8972 --- /dev/null +++ b/web/office/forms.py @@ -0,0 +1,110 @@ +import re +import zipfile +from datetime import date +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 + + +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 + + 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 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') + 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/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..912a971 --- /dev/null +++ b/web/office/management/commands/check_legacy_schema.py @@ -0,0 +1,33 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db import connection +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 = [] + 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)} + required = {field.column for field in model._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/__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..b822c9b --- /dev/null +++ b/web/office/models.py @@ -0,0 +1,235 @@ +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): + 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): + 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): + return f'Pagamento {self.pk} · {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): + 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 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/registry.py b/web/office/registry.py new file mode 100644 index 0000000..c3ce466 --- /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), + ('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', '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/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/tests.py b/web/office/tests.py new file mode 100644 index 0000000..fcc24ce --- /dev/null +++ b/web/office/tests.py @@ -0,0 +1,228 @@ +import tempfile +from datetime import timedelta +from decimal import Decimal +from io import BytesIO +from pathlib import Path +from unittest.mock import patch, MagicMock + +from django.contrib.auth.models import User, Permission +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, Client, override_settings +from django.urls import reverse +from django.utils import timezone +from docx import Document + +from .forms import record_form, TemplateAdminForm +from .models import Cliente, Processo, Pagamento, Agendamento, AuditEvent, Configuration, DocumentTemplate +from .registry import RESOURCES + + +@override_settings(STORAGES={'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'}, 'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'}}) +class OfficeTests(TestCase): + def setUp(self): + self.admin = User.objects.create_superuser('responsavel', 'admin@example.invalid', 'Senha-segura-para-testes-2026') + self.viewer = User.objects.create_user('consulta', password='Senha-segura-para-testes-2026') + self.viewer.user_permissions.add(*Permission.objects.filter(content_type__app_label='office', codename__startswith='view_')) + self.client_record = Cliente.objects.create(nome_completo='Cliente de Teste', nif='123456789') + + def login(self, user=None): + self.client.force_login(user or self.admin) + + def test_anonymous_cannot_read_records(self): + for url in ['/', '/gestao/clientes/', '/gestao/clientes/1/', '/gerar-documento/']: + self.assertEqual(self.client.get(url).status_code, 302) + + def test_every_resource_renders_and_form_renders(self): + self.login() + for resource in RESOURCES: + with self.subTest(resource=resource): + self.assertEqual(self.client.get(reverse('record_list', args=[resource])).status_code, 200) + self.assertEqual(self.client.get(reverse('record_create', args=[resource])).status_code, 200) + self.assertEqual(self.client.get('/').status_code, 200) + + def test_read_only_user_cannot_mutate_or_manage_configuration(self): + self.login(self.viewer) + self.assertEqual(self.client.get('/gestao/clientes/').status_code, 200) + self.assertEqual(self.client.post('/gestao/clientes/novo/', {'nome_completo': 'Intrusão'}).status_code, 403) + self.assertEqual(self.client.post(f'/gestao/clientes/{self.client_record.pk}/editar/', {'nome_completo': 'Intrusão'}).status_code, 403) + self.assertEqual(self.client.post(f'/gestao/clientes/{self.client_record.pk}/eliminar/').status_code, 403) + self.assertEqual(self.client.post('/google/ligar/').status_code, 403) + self.assertEqual(self.client.get('/gerar-documento/').status_code, 403) + self.assertEqual(Cliente.objects.get(pk=self.client_record.pk).nome_completo, 'Cliente de Teste') + + def test_authenticated_without_permissions_sees_no_data(self): + user = User.objects.create_user('sem-acesso', password='Senha-segura-para-testes-2026') + self.login(user) + response = self.client.get('/') + self.assertNotContains(response, 'Cliente de Teste') + self.assertEqual(self.client.get('/gestao/clientes/').status_code, 403) + + def test_csrf_enforced_and_delete_get_is_safe(self): + secure = Client(enforce_csrf_checks=True) + secure.force_login(self.admin) + self.assertEqual(secure.post('/gestao/clientes/novo/', {'nome_completo': 'Sem token'}).status_code, 403) + self.assertEqual(secure.get(f'/gestao/clientes/{self.client_record.pk}/eliminar/').status_code, 200) + self.assertTrue(Cliente.objects.filter(pk=self.client_record.pk).exists()) + + def test_password_hashed_and_invalid_login_generic(self): + self.assertNotEqual(self.admin.password, 'Senha-segura-para-testes-2026') + self.assertTrue(self.admin.check_password('Senha-segura-para-testes-2026')) + response = self.client.post('/entrar/', {'username': 'responsavel', 'password': 'wrong'}) + self.assertEqual(response.status_code, 200) + self.assertNotIn('_auth_user_id', self.client.session) + + def test_login_rate_limit(self): + for _ in range(5): + self.client.post('/entrar/', {'username': 'responsavel', 'password': 'wrong'}) + response = self.client.post('/entrar/', {'username': 'responsavel', 'password': 'Senha-segura-para-testes-2026'}) + self.assertEqual(response.status_code, 429) + self.assertNotIn('_auth_user_id', self.client.session) + + def test_dynamic_catalogue_changes_available_without_code_change(self): + Configuration.objects.create(category='estado_civil', label='Novo estado de teste') + form = record_form(RESOURCES['clientes'])() + self.assertIn(('Novo estado de teste', 'Novo estado de teste'), form.fields['estado_civil'].choices) + config = Configuration.objects.get(label='Novo estado de teste') + config.active = False + config.save() + self.assertNotIn(('Novo estado de teste', 'Novo estado de teste'), record_form(RESOURCES['clientes'])().fields['estado_civil'].choices) + self.client_record.estado_civil = 'Novo estado de teste' + self.client_record.save() + self.assertIn(('Novo estado de teste', 'Novo estado de teste'), record_form(RESOURCES['clientes'])(instance=self.client_record).fields['estado_civil'].choices) + + def test_client_create_update_search_and_audit(self): + self.login() + response = self.client.post('/gestao/clientes/novo/', {'nome_completo': 'Pessoa Nova', 'email': 'nova@example.invalid'}) + self.assertEqual(response.status_code, 302) + record = Cliente.objects.get(nome_completo='Pessoa Nova') + self.assertIsNone(record.nif) + response = self.client.post(f'/gestao/clientes/{record.pk}/editar/', {'nome_completo': 'Pessoa Atualizada', 'email': 'nova@example.invalid'}) + self.assertEqual(response.status_code, 302) + self.assertContains(self.client.get('/gestao/clientes/?q=Atualizada'), 'Pessoa Atualizada') + self.assertEqual(AuditEvent.objects.filter(object_id=str(record.pk)).count(), 2) + + def test_duplicate_identifiers_and_date_validation(self): + form = record_form(RESOURCES['clientes'])({'nome_completo': 'Outro cliente', 'nif': '123456789'}) + self.assertFalse(form.is_valid()) + form = record_form(RESOURCES['clientes'])({'nome_completo': 'Outro cliente', 'nif': '123', 'data_nascimento': '2999-01-01'}) + self.assertFalse(form.is_valid()) + self.assertIn('nif', form.errors) + self.assertIn('data_nascimento', form.errors) + + def test_financial_precision_and_reference_zeroes(self): + self.login() + response = self.client.post('/gestao/pagamentos/novo/', {'cliente': self.client_record.pk, 'entidade': '00012', 'referencia': '000123456', 'montante': '19.99', 'data_limite': '2026-01-01'}) + self.assertEqual(response.status_code, 302) + payment = Pagamento.objects.get() + self.assertEqual(payment.montante, Decimal('19.99')) + self.assertEqual(payment.referencia, '000123456') + self.assertEqual(payment.entidade, '00012') + self.assertContains(self.client.get('/gestao/pagamentos/'), '19,99') + + def test_negative_amount_rejected(self): + form = record_form(RESOURCES['pagamentos'])({'cliente': self.client_record.pk, 'montante': '-1'}) + self.assertFalse(form.is_valid()) + + def test_linked_client_deletion_blocked_and_audit_rolled_back(self): + self.login() + Processo.objects.create(cliente=self.client_record, numero_processo='TESTE-1') + self.client.post(f'/gestao/clientes/{self.client_record.pk}/eliminar/') + self.assertTrue(Cliente.objects.filter(pk=self.client_record.pk).exists()) + self.assertFalse(AuditEvent.objects.filter(action='delete').exists()) + + def test_xss_escaped_and_private_cache_headers(self): + self.client_record.nome_completo = '' + self.client_record.save() + self.login() + response = self.client.get('/gestao/clientes/') + self.assertNotContains(response, '') + self.assertContains(response, '<script>') + self.assertEqual(response['Cache-Control'], 'no-store, private') + self.assertIn("frame-ancestors 'none'", response['Content-Security-Policy']) + + def test_document_generation_escapes_xml_and_requires_permissions(self): + self.login() + with tempfile.TemporaryDirectory() as temp, override_settings(MEDIA_ROOT=temp): + doc = Document() + doc.add_paragraph('Cliente: {{ nome }}; NIF: {{ nif }}') + buffer = BytesIO() + doc.save(buffer) + template = DocumentTemplate.objects.create(name='Modelo teste', file=SimpleUploadedFile('modelo.docx', buffer.getvalue()), required_fields=['nome', 'nif']) + self.client_record.nome_completo = 'Pessoa & Filhos ' + self.client_record.save() + response = self.client.post('/gerar-documento/', {'cliente': self.client_record.pk, 'modelo': template.pk}) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.streaming) + result = Document(BytesIO(b''.join(response.streaming_content))) + self.assertIn('Pessoa & Filhos ', result.paragraphs[0].text) + self.assertIn('attachment;', response['Content-Disposition']) + + def test_invalid_template_and_missing_fields_rejected(self): + form = TemplateAdminForm(data={'name': 'Inválido', 'required_fields': '["nome"]', 'active': True}, files={'file': SimpleUploadedFile('fake.docx', b'not-a-zip')}) + self.assertFalse(form.is_valid()) + self.assertIn('file', form.errors) + + def test_oauth_state_rejection_and_connect_get_safe(self): + self.login() + self.assertEqual(self.client.get('/google/ligar/').status_code, 405) + self.assertEqual(self.client.get('/google/callback/?state=forged&code=forged').status_code, 403) + + def test_sync_failure_preserves_local_record(self): + self.login() + event = Agendamento.objects.create(cliente=self.client_record, titulo='Teste', data_inicio=timezone.now(), duracao='30 minutos') + with patch('office.google_calendar.sync_event', side_effect=RuntimeError('private error')): + response = self.client.post(f'/agenda/{event.pk}/sincronizar/', follow=True) + self.assertContains(response, 'A sincronização falhou') + self.assertNotContains(response, 'private error') + self.assertTrue(Agendamento.objects.filter(pk=event.pk).exists()) + + def test_all_core_records_create_edit_delete(self): + self.login() + data = { + 'processos': {'cliente': self.client_record.pk, 'numero_processo': 'TESTE-2'}, + 'agenda': {'cliente': self.client_record.pk, 'titulo': 'Consulta teste', 'data_inicio': '2026-10-15T14:00', 'duracao': '30 minutos', 'motivo': 'Consulta'}, + 'documentos': {'cliente': self.client_record.pk, 'documento_nome': Configuration.objects.filter(category='documento').first().label, 'entregue': 'on'}, + } + for resource, payload in data.items(): + with self.subTest(resource=resource): + response = self.client.post(reverse('record_create', args=[resource]), payload) + self.assertEqual(response.status_code, 302) + record = RESOURCES[resource].model.objects.get() + self.assertEqual(self.client.get(reverse('record_detail', args=[resource, record.pk])).status_code, 200) + self.assertEqual(self.client.post(reverse('record_edit', args=[resource, record.pk]), payload).status_code, 302) + self.assertEqual(self.client.post(reverse('record_delete', args=[resource, record.pk])).status_code, 302) + self.assertFalse(RESOURCES[resource].model.objects.exists()) + + def test_step_creation_and_case_history(self): + from .models import Etapa, Fase + self.login() + process = Processo.objects.create(cliente=self.client_record, numero_processo='ETAPA-1') + phase = Fase.objects.create(fase='Análise') + payload = {'processo': process.pk, 'fase': phase.pk, 'data_fase': '2026-10-07', 'observacao': 'Recebido'} + self.assertEqual(self.client.post('/gestao/etapas/novo/', payload).status_code, 302) + step = Etapa.objects.get() + self.assertContains(self.client.get(f'/gestao/processos/{process.pk}/'), 'Análise') + self.assertEqual(self.client.post(f'/gestao/etapas/{step.pk}/editar/', payload).status_code, 302) + self.assertEqual(self.client.post(f'/gestao/etapas/{step.pk}/eliminar/').status_code, 302) + + def test_google_sync_uses_minimal_scope_and_stable_event_id(self): + from .google_calendar import sync_event, SCOPES + from .models import CalendarConnection + from cryptography.fernet import Fernet + import json + from googleapiclient.errors import HttpError + import httplib2 + key = Fernet.generate_key() + token = {'token': 'test-token', 'refresh_token': 'test-refresh', 'token_uri': 'https://oauth2.googleapis.com/token', 'client_id': 'test', 'client_secret': 'test', 'expiry': '2999-01-01T00:00:00Z'} + CalendarConnection.objects.create(encrypted_credentials=Fernet(key).encrypt(json.dumps(token).encode()).decode()) + event = Agendamento.objects.create(titulo='Teste', data_inicio=timezone.now(), duracao='30 minutos') + events = MagicMock() + events.get.return_value.execute.side_effect = HttpError(httplib2.Response({'status': '404'}), b'{}') + events.insert.return_value.execute.return_value = {'id': f'himp{event.pk:x}'} + service = MagicMock() + service.events.return_value = events + with override_settings(TOKEN_ENCRYPTION_KEY=key.decode()), patch('office.google_calendar.build', return_value=service): + sync_event(event) + self.assertEqual(event.google_event_id, f'himp{event.pk:x}') + self.assertEqual(events.insert.call_args.kwargs['body']['id'], event.google_event_id) + self.assertEqual(SCOPES, ['https://www.googleapis.com/auth/calendar.events']) diff --git a/web/office/views.py b/web/office/views.py new file mode 100644 index 0000000..de831af --- /dev/null +++ b/web/office/views.py @@ -0,0 +1,237 @@ +from datetime import date +from decimal import Decimal +from functools import wraps +from io import BytesIO + +from django.contrib import messages +from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import PermissionDenied +from django.core.paginator import Paginator +from django.db import IntegrityError, transaction +from django.db.models import Q, Sum +from django.db.models.deletion import ProtectedError +from django.http import FileResponse, Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.utils.formats import number_format +from django.views.decorators.http import require_POST + +from .forms import GenerationForm, record_form +from .models import Agendamento, AuditEvent, Cliente, Configuration, DocumentoCliente, Pagamento, Processo +from .registry import RESOURCES + + +def resource_access(action): + def decorator(view): + @wraps(view) + @login_required + def wrapped(request, resource, *args, **kwargs): + spec = RESOURCES.get(resource) + if not spec: + raise Http404 + if not request.user.has_perm(f'office.{action}_{spec.model._meta.model_name}'): + raise PermissionDenied + return view(request, resource, spec, *args, **kwargs) + return wrapped + return decorator + + +def audit(request, action, instance): + AuditEvent.objects.create(actor=request.user, action=action, model=instance._meta.label, object_id=str(instance.pk)) + + +def queryset(spec): + related = [f.name for f in spec.model._meta.fields if f.is_relation] + return spec.model.objects.select_related(*related) + + +@login_required +def dashboard(request): + today = timezone.localdate() + cards = [] + for key, label in [('clientes', 'Clientes'), ('processos', 'Processos'), ('agenda', 'Agendamentos')]: + spec = RESOURCES[key] + if request.user.has_perm(f'office.view_{spec.model._meta.model_name}'): + count = spec.model.objects.count() + if key == 'agenda': + count = Agendamento.objects.filter(data_inicio__date=today).count() + label = 'Agendamentos hoje' + cards.append({'key': key, 'label': label, 'value': count}) + payments = None + upcoming = None + pending = None + if request.user.has_perm('office.view_pagamento'): + overdue = Pagamento.objects.filter(data_conclusao__isnull=True, data_limite__lt=today) + total = overdue.aggregate(total=Sum('montante'))['total'] or Decimal('0') + cards.append({'key': 'pagamentos', 'label': 'Valor em atraso', 'value': number_format(total, 2) + ' €'}) + payments = overdue.select_related('cliente')[:5] + if request.user.has_perm('office.view_agendamento'): + upcoming = Agendamento.objects.filter(data_inicio__gte=timezone.now()).select_related('cliente')[:5] + if request.user.has_perm('office.view_documentocliente'): + pending = DocumentoCliente.objects.filter(Q(entregue=False) | Q(entregue__isnull=True)).select_related('cliente')[:5] + return render(request, 'office/dashboard.html', {'cards': cards, 'payments': payments, 'upcoming': upcoming, 'pending': pending, 'today': today}) + + +@resource_access('view') +def record_list(request, resource, spec): + records = queryset(spec) + query = request.GET.get('q', '').strip()[:100] + if query: + search = Q() + for field in spec.search: + search |= Q(**{f'{field}__icontains': query}) + records = records.filter(search).distinct() + selected_client = request.GET.get('cliente', '') + if selected_client.isdigit() and 'cliente' in spec.fields: + records = records.filter(cliente_id=int(selected_client)) + if resource == 'pagamentos' and request.GET.get('filtro') == 'atraso': + records = records.filter(data_conclusao__isnull=True, data_limite__lt=timezone.localdate()) + page = Paginator(records, 25).get_page(request.GET.get('page')) + rows = [{'object': obj, 'cells': [getattr(obj, field) for field in spec.columns]} for obj in page] + headers = [spec.model._meta.get_field(field).verbose_name for field in spec.columns] + return render(request, 'office/list.html', { + 'resource': resource, 'spec': spec, 'rows': rows, 'headers': headers, + 'page': page, 'query': query, + 'can_add': request.user.has_perm(f'office.add_{spec.model._meta.model_name}'), + }) + + +@resource_access('view') +def record_detail(request, resource, spec, pk): + instance = get_object_or_404(queryset(spec), pk=pk) + details = [(spec.model._meta.get_field(field).verbose_name, getattr(instance, field)) for field in spec.fields] + related = [] + if resource == 'clientes': + for key, manager in [('processos', 'processos'), ('pagamentos', 'pagamentos'), ('agenda', 'agendamentos'), ('documentos', 'documentos')]: + model = RESOURCES[key].model + if request.user.has_perm(f'office.view_{model._meta.model_name}'): + related.append({'key': key, 'label': RESOURCES[key].title, 'objects': getattr(instance, manager).all()[:20]}) + elif resource == 'processos' and request.user.has_perm('office.view_etapa'): + related.append({'key': 'etapas', 'label': 'Histórico de etapas', 'objects': instance.etapas.select_related('fase')[:50]}) + return render(request, 'office/detail.html', { + 'resource': resource, 'spec': spec, 'object': instance, 'details': details, 'related': related, + 'can_edit': request.user.has_perm(f'office.change_{spec.model._meta.model_name}'), + 'can_delete': request.user.has_perm(f'office.delete_{spec.model._meta.model_name}'), + 'can_sync': resource == 'agenda' and request.user.has_perm('office.sync_calendar'), + }) + + +@login_required +def record_edit(request, resource, pk=None): + spec = RESOURCES.get(resource) + if not spec: + raise Http404 + action = 'change' if pk is not None else 'add' + if not request.user.has_perm(f'office.{action}_{spec.model._meta.model_name}'): + raise PermissionDenied + instance = get_object_or_404(spec.model, pk=pk) if pk is not None else None + initial = {} + if request.GET.get('cliente', '').isdigit() and 'cliente' in spec.fields: + initial['cliente'] = request.GET['cliente'] + form = record_form(spec)(request.POST if request.method == 'POST' else None, instance=instance, initial=initial) + if request.method == 'POST' and form.is_valid(): + try: + with transaction.atomic(): + obj = form.save() + audit(request, action, obj) + except IntegrityError: + form.add_error(None, 'Este registo entra em conflito com dados existentes. Verifique os identificadores.') + else: + messages.success(request, 'Registo guardado com sucesso.') + return redirect('record_detail', resource=resource, pk=obj.pk) + return render(request, 'office/form.html', {'resource': resource, 'spec': spec, 'form': form, 'object': instance}) + + +@resource_access('delete') +def record_delete(request, resource, spec, pk): + instance = get_object_or_404(spec.model, pk=pk) + if request.method == 'POST': + try: + with transaction.atomic(): + audit(request, 'delete', instance) + instance.delete() + except (ProtectedError, IntegrityError): + messages.error(request, 'Existem registos associados. Remova as dependências antes de eliminar.') + return redirect('record_detail', resource=resource, pk=pk) + messages.success(request, 'Registo eliminado.') + return redirect('record_list', resource=resource) + return render(request, 'office/delete.html', {'resource': resource, 'object': instance}) + + +@login_required +@permission_required('office.generate_document', raise_exception=True) +@permission_required('office.view_cliente', raise_exception=True) +def generate_document(request): + from docxtpl import DocxTemplate + from jinja2 import StrictUndefined + from jinja2.sandbox import SandboxedEnvironment + from num2words import num2words + form = GenerationForm(request.POST if request.method == 'POST' else None) + if request.method == 'POST' and form.is_valid(): + client = form.cleaned_data['cliente'] + template = form.cleaned_data['modelo'] + context = {f.name: getattr(client, f.name) or '' for f in client._meta.fields if not f.is_relation} + def fmt(value): + return value.strftime('%d/%m/%Y') if value else '' + context.update({ + 'nome': client.nome_completo or '', 'nome_upper': (client.nome_completo or '').upper(), + 'endereço': ', '.join(str(v) for v in [client.rua, client.numero_rua, client.complemento, client.localidade, client.codigo_postal] if v), + 'data_documento': fmt(timezone.localdate()), 'titulo_residencia': client.titulo_residencia or '', + 'validade_titulo_residencia': fmt(client.validade_bi_cc), + 'validade_passaporte': fmt(client.validade_passaporte), + 'emissao_passaporte': fmt(client.emissao_passaporte), + 'local_emissao_passaporte': client.local_emissao_passaporte or '', + }) + amount = form.cleaned_data.get('valor_contrato') + installments = form.cleaned_data.get('numero_parcelas') + if amount is not None: + context['valor_contrato'] = f'{amount:.2f}' + context['valor_contrato_string'] = num2words(amount, lang='pt', to='currency') + if installments: + context['numero_parcelas'] = installments + if amount is not None: + per_installment = (amount / installments).quantize(Decimal('0.01')) + context['valor_contrato_div_parcelas'] = f'{per_installment:.2f}' + context['valor_contrato_div_parcelas_string'] = num2words(per_installment, lang='pt', to='currency') + start = form.cleaned_data.get('inicio_prestacao') + if start: + from django.utils.formats import date_format + context['mes_ano_inicio_prestacao'] = date_format(start, 'F Y') + missing = [field for field in template.required_fields if context.get(field, '') == ''] + if missing: + form.add_error(None, 'Preencha os campos necessários: ' + ', '.join(missing)) + else: + try: + doc = DocxTemplate(template.file.path) + doc.render(context, jinja_env=SandboxedEnvironment(undefined=StrictUndefined), autoescape=True) + output = BytesIO() + doc.save(output) + output.seek(0) + except Exception: + form.add_error(None, 'Não foi possível gerar o documento. Peça ao administrador para verificar o modelo e os campos.') + else: + audit(request, 'generate', template) + return FileResponse(output, as_attachment=True, filename=f'documento-{client.pk}-{template.pk}.docx') + return render(request, 'office/generate.html', {'form': form}) + + +@login_required +@permission_required('office.sync_calendar', raise_exception=True) +@permission_required('office.change_agendamento', raise_exception=True) +@require_POST +def calendar_sync(request, pk): + from .google_calendar import sync_event + obj = get_object_or_404(Agendamento, pk=pk) + try: + sync_event(obj) + except Exception: + messages.error(request, 'A sincronização falhou. Verifique a ligação Google, a data e a duração; o agendamento local foi preservado.') + else: + audit(request, 'google_sync', obj) + messages.success(request, 'Agendamento sincronizado com o Google Calendar.') + return redirect('record_detail', resource='agenda', pk=pk) + + +def health(request): + from django.http import JsonResponse + return JsonResponse({'status': 'ok'}) diff --git a/web/static/office/app.css b/web/static/office/app.css new file mode 100644 index 0000000..6c2c5c5 --- /dev/null +++ b/web/static/office/app.css @@ -0,0 +1,692 @@ +:root { + --ink:#17332f; + --muted:#74837e; + --accent:#287b62; + --accent-light:#e8f2eb; + --paper:#f6f7f3; + --border:#e4e9e3; + --white:#fff; + --danger:#ac3c3c; + --font:'Segoe UI',system-ui,-apple-system,sans-serif} + +* { + box-sizing:border-box} +body { + margin:0; + background:var(--paper); + color:var(--ink); + font:14px/1.6 var(--font)} +a { + color:inherit; + text-decoration:none} +button,input,select,textarea { + font:inherit} +button,a,input,select,textarea { + outline-offset:4px} +button { + cursor:pointer} +a:hover { + color:var(--accent)} +h1,h2,h3,p { + margin-top:0} +h1 { + font-size:38px; + font-weight:600; + letter-spacing:-1.4px; + line-height:1.2; + margin-bottom:12px; + overflow-wrap:anywhere} +h2 { + font-size:18px; + font-weight:600; + letter-spacing:-.3px; + margin-bottom:0} +h3 { + font-size:17px; + margin-bottom:6px} +ul.errorlist { + list-style:none; + padding:0; + margin:0} +.sidebar { + position:fixed; + inset:0 auto 0 0; + width:238px; + background:#fff; + border-right:1px solid var(--border); + padding:32px 20px 20px; + display:flex; + flex-direction:column} +.brand { + display:flex; + align-items:center; + gap:12px; + font-size:34px; + font-weight:650; + letter-spacing:-1.5px} +.brand-mark { + display:inline-grid; + place-items:center; + background:var(--ink); + color:#fff; + width:39px; + height:43px; + border-radius:12px 12px 12px 3px; + font-size:25px; + font-weight:400} +.brand-dot,.accent { + color:var(--accent)} +.nav-label { + font-size:10px; + letter-spacing:1.6px; + font-weight:600; + color:#98a29b; + margin:38px 14px 12px} +.nav-link { + display:flex; + gap:12px; + align-items:center; + padding:11px 14px; + border-radius:8px; + margin:4px 0; + color:#65756c; + font-weight:500} +.nav-icon { + font-size:20px; + width:22px; + text-align:center; + line-height:1} +.nav-link.selected { + background:var(--accent-light); + color:var(--accent); + font-weight:600} +.nav-link:hover { + background:#f2f5f0} +.sidebar-bottom { + margin-top:auto; + border-top:1px solid var(--border); + padding-top:20px; + display:flex; + gap:12px; + align-items:center; + font-size:12px} +.sidebar-bottom a { + display:block; + color:var(--muted); + font-size:11px} +.avatar { + width:36px; + height:36px; + display:grid; + place-items:center; + background:#f0e9d9; + border-radius:50%; + font-weight:600} +.workspace { + margin-left:238px; + min-height:100vh} +.topbar { + height:76px; + border-bottom:1px solid var(--border); + display:flex; + align-items:center; + justify-content:space-between; + padding:0 42px; + font-size:10px; + letter-spacing:1.6px; + color:var(--muted)} +.topbar-right { + display:flex; + align-items:center; + gap:12px; + font-size:12px; + letter-spacing:0} +.topbar form { + margin-left:18px} +.live-dot { + width:6px; + height:6px; + background:#6ea57b; + border-radius:50%} +main { + max-width:1460px; + margin:auto; + padding:40px 42px} +.page-heading { + display:flex; + justify-content:space-between; + align-items:center; + gap:24px; + margin-bottom:30px} +.eyebrow { + font-size:10px; + letter-spacing:1.9px; + font-weight:600; + color:var(--accent); + margin-bottom:15px} +.subtitle { + color:var(--muted); + margin-bottom:0; + font-size:14px} +.date-pill { + white-space:nowrap; + font-size:12px; + border:1px solid var(--border); + border-radius:7px; + padding:10px 16px; + color:var(--muted); + text-transform:capitalize} +.stats { + display:grid; + grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); + gap:18px; + margin-bottom:28px} +.stat { + border:1px solid var(--border); + border-radius:12px; + background:var(--white); + padding:23px; + display:flex; + flex-direction:column} +.stat-top { + display:flex; + justify-content:space-between; + gap:12px; + color:var(--muted); + font-size:12px} +.stat-arrow { + color:var(--accent); + background:#f0f5ef; + border-radius:50%; + width:24px; + height:24px; + text-align:center} +.stat>strong { + font-size:32px; + letter-spacing:-1px; + font-weight:600; + line-height:1.4; + margin:16px 0} +.stat-bottom { + font-size:11px; + color:var(--muted)} +.panel { + border:1px solid var(--border); + border-radius:12px; + background:#fff; + overflow:hidden} +.dashboard-grid { + display:grid; + grid-template-columns:1.1fr 1fr; + gap:24px} +.wide { + grid-column:1/-1} +.panel-heading { + display:flex; + align-items:center; + justify-content:space-between; + padding:23px 25px; + border-bottom:1px solid var(--border); + gap:16px} +.panel-heading a { + font-size:11px; + color:var(--muted)} +.schedule-row,.compact-row { + display:flex; + align-items:center; + gap:16px; + padding:18px 25px; + border-bottom:1px solid #f0f2ed} +.compact-row { + justify-content:space-between} +.schedule-row:last-child,.compact-row:last-child { + border-bottom:0} +.calendar-badge { + display:flex; + flex-direction:column; + align-items:center; + width:44px; + background:#f0f4ed; + border-radius:8px; + font-size:10px; + text-transform:uppercase; + padding:6px; + flex-shrink:0} +.calendar-badge strong { + font-size:21px; + line-height:1.3} +.row-copy { + display:flex; + flex-direction:column; + min-width:0; + flex:1} +.row-copy strong { + font-size:13px; + font-weight:500; + overflow-wrap:anywhere} +.row-copy>span { + font-size:11px; + color:var(--muted); + margin-top:3px} +.pill { + font-size:10px; + color:var(--accent); + background:#eef5ed; + border-radius:5px; + padding:4px 8px} +.pill.warning { + background:#fff4df; + color:#a07d37} +.empty { + text-align:center; + padding:44px 24px; + color:var(--muted)} +.empty h3 { + color:var(--ink)} +.empty p { + font-size:12px} +.empty-symbol { + color:#9aae9a; + font-size:30px; + display:block; + margin-bottom:10px} +.amount { + font-size:15px; + font-weight:600; + white-space:nowrap} +.button { + display:inline-flex; + align-items:center; + justify-content:center; + background:var(--accent); + color:white; + border:1px solid var(--accent); + padding:10px 18px; + border-radius:7px; + font-size:12px; + font-weight:600; + gap:7px; + line-height:1.5} +.button:hover { + background:#1f6651; + color:white} +.button.secondary { + background:#fff; + color:var(--ink); + border-color:var(--border)} +.button.secondary:hover { + background:#eff4ec} +.button.danger { + background:var(--danger); + border-color:var(--danger); + color:white} +.button.danger.secondary { + color:var(--danger); + background:white; + border-color:#f0dada} +.text-button { + border:none; + background:none; + color:var(--muted); + font-size:12px; + padding:8px} +.table-toolbar { + padding:22px; + display:flex; + align-items:center; + justify-content:space-between; + gap:20px} +.search { + display:flex; + align-items:center; + gap:10px; + max-width:650px; + flex:1} +.search input { + flex:1; + min-width:100px} +.record-count { + font-size:11px; + color:var(--muted); + white-space:nowrap} +.table-scroll { + overflow-x:auto} +table { + width:100%; + border-collapse:collapse; + text-align:left} +th { + background:#f8faf6; + padding:13px 22px; + font-size:10px; + text-transform:uppercase; + letter-spacing:.8px; + font-weight:600; + color:var(--muted); + white-space:nowrap} +td { + padding:19px 22px; + border-top:1px solid #eef1eb; + font-size:12px; + max-width:300px; + overflow-wrap:anywhere} +tbody tr:hover { + background:#fcfdfb} +.row-action { + white-space:nowrap; + color:var(--accent); + font-size:11px} +.pagination { + padding:18px 22px; + display:flex; + align-items:center; + justify-content:space-between; + color:var(--muted); + font-size:11px; + border-top:1px solid var(--border)} +.pagination>div { + display:flex; + gap:10px} +.back-link { + color:var(--muted); + display:inline-block; + font-size:12px; + margin-bottom:20px} +.actions { + display:flex; + gap:12px; + flex-wrap:wrap} +.details-grid { + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + padding:25px 30px; + gap:26px; + margin:0} +dt { + font-size:10px; + text-transform:uppercase; + color:var(--muted); + letter-spacing:.6px; + margin-bottom:6px} +dd { + margin:0; + font-size:14px; + overflow-wrap:anywhere} +.related { + margin-top:22px} +.integration { + padding:24px; + margin-top:24px} +.integration summary { + cursor:pointer} +.integration p { + color:var(--muted); + font-size:12px; + margin-top:10px} +.record-form { + padding:30px} +.form-grid { + display:grid; + grid-template-columns:1fr 1fr; + gap:24px} +.form-field { + display:flex; + flex-direction:column; + gap:7px; + min-width:0} +.form-field label { + font-size:12px; + font-weight:600} +.required { + color:var(--accent)} +input:not([type=checkbox]),select,textarea { + border:1px solid #dce4dc; + border-radius:6px; + padding:10px 12px; + background:#fff; + color:var(--ink); + width:100%; + min-height:42px} +input:focus,select:focus,textarea:focus { + outline:2px solid #92b99f; + outline-offset:1px} +.invalid input,.invalid select { + border-color:var(--danger)} +input[type=checkbox] { + align-self:flex-start; + width:20px; + height:20px; + accent-color:var(--accent)} +.field-error { + color:var(--danger); + font-size:11px} +.help { + font-size:11px; + color:var(--muted); + margin-top:6px; + display:block} +.form-actions { + display:flex; + justify-content:flex-end; + gap:12px; + margin-top:30px; + border-top:1px solid var(--border); + padding-top:24px} +.alert { + border:1px solid #cde2d0; + background:#eef8ef; + padding:14px 20px; + border-radius:7px; + margin-bottom:20px; + font-size:13px} +.alert.error { + background:#fff1f0; + border-color:#edcecb; + color:var(--danger)} +.alert.warning { + background:#fff7e5; + border-color:#ead9ad} +.confirmation { + padding:38px; + max-width:780px} +.confirmation h1 { + font-size:29px} +.confirmation p { + color:var(--muted)} +footer { + padding:18px 42px 28px; + font-size:10px; + color:#9aa79b} +.login-workspace { + min-height:100vh; + padding:45px 7vw; + background:radial-gradient(ellipse at left bottom,#e1ebdd,transparent 60%)} +.login-workspace main { + padding:0; + max-width:none} +.login-layout { + display:grid; + grid-template-columns:1.2fr 1fr; + align-items:center; + gap:8vw; + min-height:70vh} +.login-copy h1 { + font-size:62px; + line-height:1.15; + letter-spacing:-2px} +.login-copy>p:not(.eyebrow) { + color:var(--muted); + font-size:16px; + max-width:400px} +.login-caption { + font-size:12px; + border-top:1px solid #d7e0d3; + padding-top:20px; + max-width:350px; + color:var(--muted); + margin-top:35px} +.login-panel { + padding:36px; + max-width:460px} +.login-panel h2 { + font-size:24px; + margin-bottom:8px} +.login-panel .subtitle { + margin-bottom:28px} +.login-panel .form-grid { + grid-template-columns:1fr; + gap:18px} +.full { + width:100%; + margin:28px 0 10px} +.sr-only { + position:absolute; + width:1px; + height:1px; + padding:0; + overflow:hidden; + clip:rect(0,0,0,0); + white-space:nowrap} +.skip { + position:fixed; + top:-100px; + left:15px; + background:var(--ink); + color:white; + padding:8px; + z-index:10} +.skip:focus { + top:10px} + +@media(min-width:1600px) { + main { + padding-top:55px} +.stats { + gap:25px} +.stat { + padding:28px} +.stat>strong { + font-size:40px} +} + +@media(max-width:1100px) { + .sidebar { + width:200px; + padding:25px 14px} +.workspace { + margin-left:200px} +.topbar { + padding:0 25px} +main { + padding:30px 25px} +.dashboard-grid { + grid-template-columns:1fr} +.details-grid { + grid-template-columns:1fr 1fr} +.schedule-row .pill { + max-width:140px} +.login-copy h1 { + font-size:44px} +.date-pill { + display:none} +} + +@media(max-width:720px) { + .sidebar { + position:static; + width:100%; + padding:15px 18px; + border-bottom:1px solid var(--border); + border-right:0} +.sidebar .brand { + font-size:26px} +.brand-mark { + width:30px; + height:32px; + font-size:20px} +.sidebar nav { + display:flex; + overflow-x:auto; + margin-top:12px; + gap:5px} +.nav-link { + white-space:nowrap; + font-size:12px; + padding:7px 10px} +.nav-icon,.nav-label,.sidebar-bottom { + display:none} +.workspace { + margin:0} +.topbar { + height:48px; + padding:0 20px} +.topbar>span { + font-size:8px; + letter-spacing:1px} +.topbar-right { + font-size:10px} +main { + padding:25px 18px} +.page-heading { + align-items:flex-start; + flex-direction:column; + gap:15px; + margin-bottom:24px} +h1 { + font-size:30px} +.stats { + grid-template-columns:1fr 1fr; + gap:12px} +.stat { + padding:16px} +.stat>strong { + font-size:24px} +.panel-heading { + padding:18px} +.table-toolbar { + align-items:stretch; + flex-direction:column; + padding:18px; + gap:12px} +.search { + flex-wrap:wrap} +.search input { + flex-basis:100%} +th,td { + padding:14px} +.form-grid,.details-grid { + grid-template-columns:1fr} +.record-form { + padding:22px} +.form-actions { + justify-content:stretch; + flex-wrap:wrap} +.form-actions .button { + flex:1} +.login-workspace { + padding:25px} +.login-layout { + grid-template-columns:1fr; + gap:30px; + margin-top:45px} +.login-copy h1 { + font-size:40px} +.login-copy>p:not(.eyebrow) { + font-size:14px} +.login-caption { + display:none} +.login-panel { + padding:25px; + max-width:none} +footer { + padding:25px 18px} +.compact-row,.schedule-row { + padding:16px 18px} +.schedule-row .pill { + display:none} +.confirmation { + padding:25px} +.confirmation h1 { + font-size:25px} +} + diff --git a/web/templates/403.html b/web/templates/403.html new file mode 100644 index 0000000..8ee4e60 --- /dev/null +++ b/web/templates/403.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block content %}

Acesso restrito

A sua conta não tem permissão para esta ação. Peça ao administrador para verificar a sua função.

Voltar
{% endblock %} diff --git a/web/templates/404.html b/web/templates/404.html new file mode 100644 index 0000000..1b8bf73 --- /dev/null +++ b/web/templates/404.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block content %}

Registo não encontrado

Verifique o endereço ou procure o registo na lista.

Voltar
{% endblock %} diff --git a/web/templates/500.html b/web/templates/500.html new file mode 100644 index 0000000..dc675e8 --- /dev/null +++ b/web/templates/500.html @@ -0,0 +1 @@ +HIMP

Não foi possível concluir a operação

Tente novamente. Se o problema persistir, contacte o administrador.

Voltar ao HIMP diff --git a/web/templates/office/base.html b/web/templates/office/base.html new file mode 100644 index 0000000..4a928cd --- /dev/null +++ b/web/templates/office/base.html @@ -0,0 +1,31 @@ +{% load static %} + + + + {% block title %}HIMP · Gestão{% endblock %} + + + + +{% if user.is_authenticated %} + +
+
GESTÃO DO ESCRITÓRIO
Sessão protegida
{% csrf_token %}
+{% else %} + diff --git a/web/templates/office/dashboard.html b/web/templates/office/dashboard.html new file mode 100644 index 0000000..7b997f8 --- /dev/null +++ b/web/templates/office/dashboard.html @@ -0,0 +1,13 @@ +{% extends 'office/base.html' %} +{% block title %}Visão geral · HIMP{% endblock %} +{% block content %} +

O SEU ESCRITÓRIO, NUM SÓ LUGAR

Visão geral.

Tudo o que precisa de acompanhar hoje.

{{ today|date:'l, d F' }}
+{% if not cards %}

Bem-vindo ao HIMP

A sua conta ainda não tem acesso aos registos. Peça ao administrador para atribuir uma função.

{% endif %} +
{% for card in cards %}
{{ card.label }}↗
{{ card.value }}Ver detalhes →
{% endfor %}
+
+{% if upcoming != None %}

Próximos agendamentos

Ver agenda ↗
{% for event in upcoming %}{{ event.data_inicio|date:'d' }}{{ event.data_inicio|date:'M' }}{{ event.titulo }}{{ event.cliente|default:'Sem cliente' }} · {{ event.data_inicio|date:'H:i' }}{{ event.motivo|default:'Agendamento' }}{% empty %}
▦

A agenda está livre

Os próximos agendamentos aparecerão aqui.

{% if perms.office.add_agendamento %}Criar agendamento{% endif %}
{% endfor %}
{% endif %} +{% if payments != None %}

Pagamentos em atraso

Ver todos ↗
{% for payment in payments %}{{ payment.cliente|default:'Sem cliente' }}Venceu em {{ payment.data_limite|date:'d M Y' }}{{ payment.montante|floatformat:2 }} €{% empty %}
✓

Sem pagamentos em atraso

Consulte os pagamentos para acompanhar os próximos prazos.

{% endfor %}
{% endif %} +{% if pending != None %}

Documentos por entregar

Ver documentos ↗
{% for document in pending %}{{ document.documento_nome }}{{ document.cliente }}Pendente{% empty %}

Não há documentos registados como pendentes.

{% endfor %}
{% endif %} +
+{% if user.is_superuser %}
Integração Google Calendar

Ligue a agenda do escritório para sincronizar os agendamentos individualmente.

{% csrf_token %}
{% endif %} +{% endblock %} diff --git a/web/templates/office/delete.html b/web/templates/office/delete.html new file mode 100644 index 0000000..7aab83d --- /dev/null +++ b/web/templates/office/delete.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block content %}

ELIMINAR REGISTO

Eliminar {{ object }}?

Esta ação remove o registo. Se houver dependências, a eliminação será bloqueada.

{% if resource == 'agenda' and object.google_event_id %}
O evento associado no Google Calendar deve ser removido separadamente.
{% endif %}
{% csrf_token %}
Cancelar
{% endblock %} diff --git a/web/templates/office/detail.html b/web/templates/office/detail.html new file mode 100644 index 0000000..500ff50 --- /dev/null +++ b/web/templates/office/detail.html @@ -0,0 +1,9 @@ +{% extends 'office/base.html' %}{% load office_tags %} +{% block title %}{{ object }} · HIMP{% endblock %} +{% block content %} +← {{ spec.title }} +

DETALHE DO REGISTO

{{ object }}

{% if can_edit %}Editar registo{% endif %}{% if can_delete %}Eliminar{% endif %}
+
{% for label, value in details %}
{{ label }}
{{ value|display_value|linebreaksbr }}
{% endfor %}
+{% if can_sync and perms.office.change_agendamento %}

Google Calendar

{% if object.google_event_id %}Este agendamento está ligado ao Google. Sincronize para enviar as alterações.{% else %}Envie este agendamento para a agenda do escritório.{% endif %}

{% csrf_token %}
{% endif %} +{% for group in related %}{% endfor %} +{% endblock %} diff --git a/web/templates/office/form.html b/web/templates/office/form.html new file mode 100644 index 0000000..e310127 --- /dev/null +++ b/web/templates/office/form.html @@ -0,0 +1,9 @@ +{% extends 'office/base.html' %} +{% block title %}{% if object %}Editar{% else %}Novo registo{% endif %} · HIMP{% endblock %} +{% block content %} +← {{ spec.title }} +

{{ spec.title|upper }}

{% if object %}Editar registo{% else %}Novo registo{% endif %}.

Os campos marcados com * são obrigatórios.

+
{% csrf_token %} +{% include 'office/form_fields.html' %} +
Cancelar
+
{% endblock %} diff --git a/web/templates/office/form_fields.html b/web/templates/office/form_fields.html new file mode 100644 index 0000000..713db4d --- /dev/null +++ b/web/templates/office/form_fields.html @@ -0,0 +1,2 @@ +{% if form.non_field_errors %}{% endif %} +
{% for field in form %}
{{ field }}{% if field.help_text %}{{ field.help_text }}{% endif %}{% for error in field.errors %}{{ error }}{% endfor %}
{% endfor %}
diff --git a/web/templates/office/generate.html b/web/templates/office/generate.html new file mode 100644 index 0000000..522979a --- /dev/null +++ b/web/templates/office/generate.html @@ -0,0 +1 @@ +{% extends 'office/base.html' %}{% block title %}Gerar documento · HIMP{% endblock %}{% block content %}

DOCUMENTOS

Gerar documento.

Escolha um cliente e um modelo. O documento será descarregado para o seu computador.

{% csrf_token %}{% include 'office/form_fields.html' %}

Para contratos, o modelo pode exigir valor, prestações e data de início. O valor por prestação é arredondado a cêntimos; confirme eventuais acertos no contrato.

{% endblock %} diff --git a/web/templates/office/list.html b/web/templates/office/list.html new file mode 100644 index 0000000..dab8aac --- /dev/null +++ b/web/templates/office/list.html @@ -0,0 +1,8 @@ +{% extends 'office/base.html' %}{% load office_tags %} +{% block title %}{{ spec.title }} · HIMP{% endblock %} +{% block content %} +

ESPAÇO DE TRABALHO

{{ spec.title }}.

{{ spec.description }}

{% if can_add %}+ Novo registo{% endif %}
+
{{ 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 %} From 0001ccf0efcdf9d4aba5abb65b38c46b76c5dcc0 Mon Sep 17 00:00:00 2001 From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:18:47 +0100 Subject: [PATCH 2/7] Add visual financial reports with date and client filters --- README.md | 9 ++ web/himp/urls.py | 1 + web/office/financial_reports.py | 119 ++++++++++++++++++++ web/office/report_forms.py | 18 +++ web/office/test_financial_reports.py | 121 +++++++++++++++++++++ web/office/views.py | 24 ++++ web/static/office/reports.css | 59 ++++++++++ web/templates/office/base.html | 2 + web/templates/office/financial_report.html | 26 +++++ 9 files changed, 379 insertions(+) create mode 100644 web/office/financial_reports.py create mode 100644 web/office/report_forms.py create mode 100644 web/office/test_financial_reports.py create mode 100644 web/static/office/reports.css create mode 100644 web/templates/office/financial_report.html diff --git a/README.md b/README.md index 4f7cc98..cfa815d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Aplicação Web Python/Django com interface responsiva em português. Uma instal ## 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. @@ -40,6 +41,14 @@ Não há registo público de contas. O administrador cria utilizadores em `/admi 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. +## Relatórios financeiros + +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. + +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. + +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 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. diff --git a/web/himp/urls.py b/web/himp/urls.py index 1d3bb25..9a8db31 100644 --- a/web/himp/urls.py +++ b/web/himp/urls.py @@ -11,6 +11,7 @@ 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'), 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/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/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/views.py b/web/office/views.py index de831af..2023ba5 100644 --- a/web/office/views.py +++ b/web/office/views.py @@ -235,3 +235,27 @@ def calendar_sync(request, pk): def health(request): from django.http import JsonResponse return JsonResponse({'status': 'ok'}) + + +@login_required +@permission_required('office.view_pagamento', raise_exception=True) +def financial_report(request): + from .financial_reports import build_report, preset_dates + from .report_forms import FinancialReportForm + today = timezone.localdate() + start, end = preset_dates(request.GET.get('periodo', '6m'), today) + data = request.GET.copy() + # Explicit dates take precedence over presets; invalid submitted values + # are displayed as errors and never silently replaced with a wider period. + if 'inicio' not in data and 'fim' not in data: + data['inicio'], data['fim'] = start.isoformat(), end.isoformat() + form = FinancialReportForm(data) + report = None + if form.is_valid(): + start, end = form.cleaned_data['inicio'], form.cleaned_data['fim'] + client = form.cleaned_data.get('cliente') + report = build_report(start, end, today, client.pk if client else None) + return render(request, 'office/financial_report.html', { + 'resource': 'relatorios', 'form': form, 'report': report, + 'start': start, 'end': end, 'today': today, + }) diff --git a/web/static/office/reports.css b/web/static/office/reports.css new file mode 100644 index 0000000..bf6c843 --- /dev/null +++ b/web/static/office/reports.css @@ -0,0 +1,59 @@ +.report-filters { padding: 22px 25px; margin-bottom: 20px; } +.report-presets { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; font-size: 12px; } +.report-presets > span { color: var(--muted); margin-right: 8px; } +.report-presets a { border: 1px solid var(--border); border-radius: 20px; padding: 5px 13px; } +.report-presets a:hover { background: var(--accent-light); } +.report-filter-form { display: grid; grid-template-columns: 1fr 1fr 1.5fr auto; align-items: end; gap: 18px; } +.report-filter-form .form-field { gap: 5px; } +.report-period { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; margin: 24px 0 15px; } +.report-stats .stat > strong { font-size: clamp(20px, 2.2vw, 32px); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.report-symbol { border-radius: 8px; width: 28px; height: 28px; text-align: center; font-size: 18px; background: #f2f5ef; } +.report-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 24px; } +.report-trend .panel-heading { align-items: start; } +.chart-subtitle { color: var(--muted); font-size: 11px; margin: 6px 0 0; } +.chart-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 11px; padding-top: 3px; } +.chart-legend span { white-space: nowrap; } +.legend-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 6px; } +.legend-dot.received { background: #287b62; } +.legend-dot.expected { background: #bdcebb; } +.legend-dot.upcoming { background: #d4b675; } +.legend-dot.overdue { background: #bd6861; } +.chart-scroll { overflow-x: auto; padding: 16px 20px 0; } +.monthly-chart { display: block; min-width: 720px; max-width: none; } +.chart-gridline { stroke: #edf0e9; stroke-dasharray: 3 5; } +.chart-tick, .chart-unit, .chart-month { fill: #74837e; font-size: 12px; font-family: var(--font); } +.chart-bar.received, .horizontal-track .received { fill: #287b62; } +.chart-bar.expected { fill: #bdcebb; } +.chart-bar:hover, .chart-bar:focus { opacity: .7; outline: none; } +.chart-bar:focus { stroke: #17332f; stroke-width: 2; } +.chart-data { border-top: 1px solid var(--border); } +.chart-data summary { padding: 14px 25px; color: var(--muted); font-size: 11px; cursor: pointer; } +.donut-wrap { display: flex; justify-content: center; padding: 18px 15px 2px; } +.donut-chart { width: 220px; height: 220px; overflow: visible; } +.donut-track { fill: none; stroke: #edf1e8; stroke-width: 24; } +.donut-segment { fill: none; stroke-width: 24; transform: rotate(-90deg); transform-origin: 110px 110px; } +.donut-segment.received { stroke: #287b62; } +.donut-segment.upcoming { stroke: #d4b675; } +.donut-segment.overdue { stroke: #bd6861; } +.donut-caption { fill: var(--muted); font-size: 9px; letter-spacing: 1px; font-family: var(--font); } +.donut-value { fill: var(--ink); font-size: 17px; font-weight: 600; font-family: var(--font); } +.distribution-legend { padding: 8px 25px 25px; } +.distribution-legend > div { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; font-size: 11px; } +.distribution-legend strong { white-space: nowrap; font-weight: 600; } +.horizontal-chart { padding: 24px 25px 12px; } +.horizontal-row { margin-bottom: 22px; } +.horizontal-row > div { display: flex; justify-content: space-between; gap: 14px; font-size: 12px; margin-bottom: 8px; } +.horizontal-row > div > span { min-width: 0; overflow-wrap: anywhere; } +.horizontal-row strong { white-space: nowrap; font-weight: 600; } +.horizontal-row small { display: block; color: var(--muted); font-size: 10px; } +.horizontal-track { width: 100%; height: 8px; display: block; } +.horizontal-track .track { fill: #f0f3ec; } +.horizontal-track .overdue { fill: #bd6861; } +.report-method { margin-top: 25px; padding: 20px 25px; font-size: 12px; color: var(--muted); } +.report-method summary { cursor: pointer; color: var(--ink); font-weight: 500; } +.report-method p { margin-top: 14px; margin-bottom: 0; } +.report-data-note { background: #fff8e9; border: 1px solid #ede0ba; border-radius: 8px; padding: 12px 18px; margin-bottom: 22px; color: #8a6b32; font-size: 12px; } +@media(min-width:1600px) { .monthly-chart { width: 100%; height: auto; } } +@media(max-width:1400px) { .report-grid { grid-template-columns: 1fr 1fr; } .report-trend, .report-grid > section:last-child { grid-column: 1 / -1; } } +@media(max-width:1100px) { .report-grid { grid-template-columns: 1fr; } .report-filter-form { grid-template-columns: 1fr 1fr; } } +@media(max-width:720px) { .report-filters { padding: 18px; } .report-filter-form { gap: 14px; } .report-filter-form .form-field:nth-child(3), .report-filter-form button { grid-column: 1 / -1; } .report-period { flex-direction: column; gap: 5px; } .report-grid { gap: 18px; } .report-trend .panel-heading { flex-direction: column; gap: 10px; } .report-stats .stat > strong { font-size: 22px; } .chart-scroll { padding: 12px 10px 0; } } diff --git a/web/templates/office/base.html b/web/templates/office/base.html index 4a928cd..884ed90 100644 --- a/web/templates/office/base.html +++ b/web/templates/office/base.html @@ -4,6 +4,7 @@ {% block title %}HIMP · Gestão{% endblock %} + {% block extra_head %}{% endblock %} @@ -14,6 +15,7 @@ diff --git a/web/templates/office/financial_report.html b/web/templates/office/financial_report.html new file mode 100644 index 0000000..d33f474 --- /dev/null +++ b/web/templates/office/financial_report.html @@ -0,0 +1,26 @@ +{% extends 'office/base.html' %}{% load static office_tags l10n %} +{% block title %}Relatórios financeiros · HIMP{% endblock %} +{% block extra_head %}{% endblock %} +{% block content %} +

UMA VISÃO MAIS CLARA DOS NÚMEROS

Relatórios financeiros.

Acompanhe recebimentos, prazos e valores por receber.

Ver pagamentos ↗
+
{% for field in form %}
{{ field }}{% for error in field.errors %}{{ error }}{% endfor %}
{% endfor %}
{{ form.non_field_errors }}
+{% if report %} +
{{ start|date:'d/m/Y' }} — {{ end|date:'d/m/Y' }}Valores em euros · Atualizado em {{ today|date:'d/m/Y' }}
+
+
Recebido no período↙
{{ report.received_total|display_value }}{{ report.received_count }} pagamento{{ report.received_count|pluralize:'s' }} concluído{{ report.received_count|pluralize:'s' }}
+
Por receber◷
{{ report.pending_total|display_value }}{{ report.pending_count }} pagamento{{ report.pending_count|pluralize:'s' }} com vencimento no período
+
Em atraso!
{{ report.overdue_total|display_value }}Parte do valor por receber · {{ report.overdue_count }} pagamento{{ report.overdue_count|pluralize:'s' }}
+
Previsto por vencimento▦
{{ report.expected_total|display_value }}Pagamentos com data limite no período
+
+{% if report.missing_amount or report.invalid_amount or report.missing_dates %}
Qualidade dos dados: {% if report.missing_amount %}{{ report.missing_amount }} pagamento(s) do período sem montante. {% endif %}{% if report.invalid_amount %}{{ report.invalid_amount }} pagamento(s) com valor negativo excluído(s) dos gráficos. {% endif %}{% if report.missing_dates %}{{ report.missing_dates }} pagamento(s) em aberto sem data limite, fora dos totais por período.{% endif %}
{% endif %} +
+

Evolução mensal

Recebimentos reais e previsão por vencimento

RecebidoPrevisto
+{% if report.has_amounts %}
Recebimentos e previsão mensal em eurosCada mês tem uma barra para pagamentos concluídos e outra para pagamentos com vencimento nesse mês. Consulte os valores na tabela abaixo.{% for tick in report.ticks %}{{ tick.label }}{% endfor %}EUR{% for month in report.months %}{% for bar in month.bars %}{{ month.label }} · {% if bar.kind == 'received' %}Recebido{% else %}Previsto{% endif %}: {{ bar.amount|display_value }}{% endfor %}{{ month.label }}{% endfor %}
{% else %}
▥

Ainda sem valores neste período

Altere o período ou registe pagamentos para acompanhar a evolução.

{% endif %} +
Consultar valores mês a mês
{% for month in report.months %}{% endfor %}
MêsRecebidoPrevisto
{{ month.label }}{{ month.received|display_value }}{{ month.expected|display_value }}
+

Distribuição dos valores

Recebidos e pagamentos em aberto

{% if report.distribution_total %}
{% for item in report.distribution %}{{ item.label }}: {{ item.amount|display_value }}. {% endfor %}{% for item in report.distribution %}{% if item.amount %}{{ item.label }}: {{ item.amount|display_value }} ({{ item.percent }}%){% endif %}{% endfor %}VALOR ANALISADO{{ report.distribution_total|display_value }}
{% else %}
◎

Sem montantes para representar.

{% endif %}
{% for item in report.distribution %}
{{ item.label }}{{ item.amount|display_value }}
{% endfor %}
+

Recebimentos por motivo

Onde se concentram os valores recebidos

{% for category in report.categories %}
{{ category.label }}{{ category.amount|display_value }}
{% empty %}

Sem recebimentos registados

Preencha a data de conclusão dos pagamentos recebidos.

{% endfor %}
+

Há quanto tempo estão em atraso?

Vencimentos do período, em relação a hoje

{% for bucket in report.aging %}
{{ bucket.label }} {{ bucket.count }} pagamento{{ bucket.count|pluralize:'s' }}{{ bucket.amount|display_value }}
{% endfor %}
+
+
Como são calculados os valores?

Recebido: pagamentos cuja data de conclusão está no período. Por receber: pagamentos sem conclusão, com data limite no período. Em atraso: parte do valor por receber com data limite anterior a hoje; não deve ser somada novamente aos pendentes. Previsto: todos os pagamentos com vencimento no período, incluindo os já concluídos.

Um pagamento que venceu num mês e foi concluído noutro aparece no previsto do primeiro e no recebido do segundo. A distribuição reúne recebimentos do período e contas ainda em aberto com vencimento no período. A classificação usa datas, independentemente do nome dos estados configuráveis. Estes relatórios representam pagamentos de clientes; despesas e lucro não são calculados.

+{% endif %} +{% endblock %} From 3648f0bc6e48398aad15688fe2f9a2cf437cfb11 Mon Sep 17 00:00:00 2001 From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:48:48 +0100 Subject: [PATCH 3/7] Add per-client Google Drive documents and preserve client context --- README.md | 14 +- docs/supabase_hardening.sql | 2 +- web/.env.example | 1 + web/himp/settings.py | 1 + web/himp/urls.py | 6 +- web/office/forms.py | 37 +++- web/office/google_drive.py | 166 ++++++++++++++ ...ection_cliente_drive_folder_id_and_more.py | 38 ++++ web/office/models.py | 10 + web/office/registry.py | 2 +- web/office/test_google_drive.py | 202 ++++++++++++++++++ web/office/views.py | 119 +++++++++-- web/templates/office/dashboard.html | 4 +- web/templates/office/delete.html | 2 +- web/templates/office/detail.html | 4 +- web/templates/office/form.html | 7 +- web/templates/office/form_fields.html | 2 +- web/templates/office/generate.html | 2 +- web/templates/office/list.html | 3 +- 19 files changed, 595 insertions(+), 27 deletions(-) create mode 100644 web/office/google_drive.py create mode 100644 web/office/migrations/0005_driveconnection_cliente_drive_folder_id_and_more.py create mode 100644 web/office/test_google_drive.py diff --git a/README.md b/README.md index cfa815d..3851083 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,19 @@ As listas são editadas em **Opções configuráveis**. Durações precisam de ` 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. -`processo_anexo_principal` conserva uma referência textual. Upload/download de anexos de processos ou de ficheiros de clientes ainda não faz parte desta versão. A área Documentos controla entregas e gera DOCX. Valores por prestação são arredondados a cêntimos; confirme eventuais acertos no contrato. +`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. + +## Google Drive por cliente + +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. + +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. + +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. 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. + +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. + +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. ## Google Calendar diff --git a/docs/supabase_hardening.sql b/docs/supabase_hardening.sql index 83f1b2a..8b367d7 100644 --- a/docs/supabase_hardening.sql +++ b/docs/supabase_hardening.sql @@ -12,7 +12,7 @@ BEGIN 'lista_fases_processo','pagamento','motivo','status_pagamento', 'agendamento','documentos_cliente','users', 'office_configuration','office_documenttemplate','office_calendarconnection', - 'office_auditevent','auth_user','auth_group','auth_permission', + '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' diff --git a/web/.env.example b/web/.env.example index a66c985..87dd999 100644 --- a/web/.env.example +++ b/web/.env.example @@ -10,4 +10,5 @@ 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/settings.py b/web/himp/settings.py index 8011b28..b9a0762 100644 --- a/web/himp/settings.py +++ b/web/himp/settings.py @@ -105,4 +105,5 @@ 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 index 9a8db31..00e9bae 100644 --- a/web/himp/urls.py +++ b/web/himp/urls.py @@ -1,7 +1,7 @@ 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 +from office import views, google_calendar, google_drive urlpatterns = [ path('admin/', admin.site.urls), @@ -15,6 +15,10 @@ 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'), diff --git a/web/office/forms.py b/web/office/forms.py index 9de8972..5d7dc73 100644 --- a/web/office/forms.py +++ b/web/office/forms.py @@ -1,12 +1,42 @@ import re import zipfile +from copy import deepcopy from datetime import date 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 +from .models import Configuration, DocumentTemplate, Cliente, DocumentoCliente + + +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): @@ -45,6 +75,10 @@ def __init__(self, *args, **kwargs): 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.' def clean(self): data = super().clean() @@ -105,6 +139,7 @@ def clean_file(self): 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_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/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/models.py b/web/office/models.py index b822c9b..7c18ce5 100644 --- a/web/office/models.py +++ b/web/office/models.py @@ -27,6 +27,7 @@ def __str__(self): 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) @@ -189,6 +190,8 @@ def __str__(self): 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') @@ -223,6 +226,13 @@ class CalendarConnection(models.Model): 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) diff --git a/web/office/registry.py b/web/office/registry.py index c3ce466..65da3d5 100644 --- a/web/office/registry.py +++ b/web/office/registry.py @@ -12,7 +12,7 @@ class Resource: 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), + 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.', diff --git a/web/office/test_google_drive.py b/web/office/test_google_drive.py new file mode 100644 index 0000000..b7b034d --- /dev/null +++ b/web/office/test_google_drive.py @@ -0,0 +1,202 @@ +from io import BytesIO +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.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_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' }}
+{% if selected_client %}

Registos de {{ selected_client }} · Voltar ao cliente ↗

{% endif %} +
{{ 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 %} From 8edcee3cb41ee7e0572579ac771fa337203f479d Mon Sep 17 00:00:00 2001 From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:50:40 +0100 Subject: [PATCH 4/7] Keep desktop schema preflight compatible with new Drive fields --- docs/MIGRATION.md | 2 +- .../management/commands/check_legacy_schema.py | 7 ++++++- web/office/test_google_drive.py | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 321a2e6..2729de4 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -17,7 +17,7 @@ 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. A tabela antiga `users` não é usada pela Web. +`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. 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. diff --git a/web/office/management/commands/check_legacy_schema.py b/web/office/management/commands/check_legacy_schema.py index 912a971..dd49b66 100644 --- a/web/office/management/commands/check_legacy_schema.py +++ b/web/office/management/commands/check_legacy_schema.py @@ -1,5 +1,6 @@ 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] @@ -10,6 +11,9 @@ class Command(BaseCommand): 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: @@ -18,7 +22,8 @@ def handle(self, *args, **options): failures.append(f'Falta a tabela {table}') continue actual = {col.name for col in connection.introspection.get_table_description(cursor, table)} - required = {field.column for field in model._meta.fields} + 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)}') diff --git a/web/office/test_google_drive.py b/web/office/test_google_drive.py index b7b034d..505aa52 100644 --- a/web/office/test_google_drive.py +++ b/web/office/test_google_drive.py @@ -1,4 +1,5 @@ -from io import BytesIO +from io import BytesIO, StringIO +from types import SimpleNamespace from hashlib import sha256 import tempfile from cryptography.fernet import Fernet @@ -6,6 +7,8 @@ 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 @@ -29,6 +32,19 @@ def setUp(self): 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}') From 9d23952e666caae37f3963b8b7fbc1ddffea560c Mon Sep 17 00:00:00 2001 From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:52:14 +0100 Subject: [PATCH 5/7] Avoid closing the test transaction when checking streamed downloads --- web/office/test_google_drive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/web/office/test_google_drive.py b/web/office/test_google_drive.py index 505aa52..c6fbe65 100644 --- a/web/office/test_google_drive.py +++ b/web/office/test_google_drive.py @@ -213,6 +213,5 @@ def test_generated_document_can_be_saved_and_failure_offers_download(self, uploa response = self.client.post(url, {'modelo': template.pk}) self.assertTrue(response['Content-Type'].startswith('application/vnd.openxmlformats')) output = BytesIO(b''.join(response.streaming_content)) - response.close() self.assertEqual(Document(output).paragraphs[0].text, 'Olá Ana Teste') self.assertEqual(DocumentoCliente.objects.count(), 3) From bdafdf00eb4eb177b32d730957791e0f2c3906c0 Mon Sep 17 00:00:00 2001 From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:00:32 +0100 Subject: [PATCH 6/7] Access client documents through client records instead of global navigation --- README.md | 2 +- web/office/context.py | 1 - web/office/test_google_drive.py | 5 +++-- web/office/tests.py | 8 +++++--- web/office/views.py | 7 +++++++ web/templates/office/base.html | 2 +- web/templates/office/dashboard.html | 2 +- 7 files changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3851083..cefcb22 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Ative a **Google Drive API** no mesmo projeto Google Cloud e adicione o escopo ` 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. -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. 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. +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. 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. diff --git a/web/office/context.py b/web/office/context.py index 30c9bf7..8b3c8a0 100644 --- a/web/office/context.py +++ b/web/office/context.py @@ -5,6 +5,5 @@ def navigation(request): ('etapas', 'Etapas', 'office.view_etapa'), ('pagamentos', 'Pagamentos', 'office.view_pagamento'), ('agenda', 'Agenda', 'office.view_agendamento'), - ('documentos', 'Documentos', 'office.view_documentocliente'), ] return {'navigation': [(key, label) for key, label, perm in items if request.user.has_perm(perm)]} diff --git a/web/office/test_google_drive.py b/web/office/test_google_drive.py index c6fbe65..754c0b4 100644 --- a/web/office/test_google_drive.py +++ b/web/office/test_google_drive.py @@ -68,8 +68,9 @@ def test_context_is_retained_on_validation_error_and_cancel(self): self.assertEqual(self.client.get('/gestao/documentos/novo/?cliente=not-a-client').status_code, 404) self.assertEqual(self.client.get('/gestao/documentos/?cliente=99999').status_code, 404) - def test_without_context_client_is_still_selectable(self): - self.assertContains(self.client.get('/gestao/documentos/novo/'), '