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 %}
+
+
+
+Saltar para o conteúdo
+{% if user.is_authenticated %}
+
+
+
+{% else %}
H himp. {% endif %}
+
+ {% for message in messages %}{{ message }}
{% endfor %}
+ {% block content %}{% endblock %}
+
+
HIMP · Gestão simples, trabalho organizado.
+
+
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 %}
+
+
+{% if upcoming != None %}
{% endif %}
+{% if payments != None %}
{% endif %}
+{% if pending != None %}
{% endif %}
+
+{% if user.is_superuser %}
Integração Google Calendar Ligue a agenda do escritório para sincronizar os agendamentos individualmente.
{% 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 %}{% 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 }}
+
{% 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 %}
{% 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.
+
{% 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 %}
{{ form.non_field_errors }}
{% endif %}
+
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.
{% 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 %}
+
+{% 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 %}
MENOS COMPLEXIDADE. MAIS CONTROLO.
O seu escritório. Onde estiver. Clientes, processos e documentos organizados num espaço de trabalho pensado para o seu dia.
Uma aplicação. Qualquer navegador.
Bem-vindo de volta Entre com a sua conta do escritório.
Ainda não tem acesso? Peça uma conta ao administrador do escritório.
{% 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. {% 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 %}
{% 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 %}
Saltar para o conteúdo
@@ -14,6 +15,7 @@
◈ Visão geral
{% for key, label in navigation %}{% if key == 'clientes' %}◎{% elif key == 'processos' %}▤{% elif key == 'agenda' %}▦{% elif key == 'pagamentos' %}€{% else %}◇{% endif %} {{ label }} {% endfor %}
+ {% if perms.office.view_pagamento %}▥ Relatórios {% endif %}
{% if perms.office.generate_document and perms.office.view_cliente %}↗ Gerar documento {% endif %}
{% if user.is_staff %}ADMINISTRAÇÃO
⚙ Configurações {% endif %}
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 ↗
+
{{ 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
Recebido Previsto
+{% if report.has_amounts %}Recebimentos e previsão mensal em euros Cada 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
+
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, ''), SimpleUploadedFile('x.pdf', b''),
+ SimpleUploadedFile('x.docx', b'not a zip'), SimpleUploadedFile('x.pdf', b'%PDF-' + b'x' * (10 * 1024 * 1024))]
+ for file in files:
+ with self.subTest(file=file.name):
+ self.assertFalse(DriveUploadForm(files={'ficheiro': file}).is_valid())
+ buffer = BytesIO()
+ Document().save(buffer)
+ self.assertTrue(DriveUploadForm(files={'ficheiro': SimpleUploadedFile('x.docx', buffer.getvalue())}).is_valid())
+ self.assertTrue(DriveUploadForm(files={'ficheiro': self.pdf()}).is_valid())
+
+ @patch('office.google_drive.upload_document')
+ def test_new_record_upload_and_failure_preserve_record(self, upload):
+ upload.side_effect = RuntimeError('private token must not leak')
+ result = self.client.post(f'/gestao/documentos/novo/?cliente={self.person.pk}', {'documento_nome': 'Passaporte', 'ficheiro': self.pdf()}, follow=True)
+ self.assertContains(result, 'o envio ao Drive falhou')
+ self.assertNotContains(result, 'private token')
+ self.assertEqual(DocumentoCliente.objects.count(), 2)
+ self.assertEqual(upload.call_args.args[1], b'%PDF-1.7\nexample')
+ self.assertEqual(upload.call_args.args[2], 'passaporte.pdf')
+
+ def files(self):
+ files = MagicMock()
+ files.list.return_value.execute.return_value = {'files': []}
+ files.create.return_value.execute.side_effect = [{'id': 'root-id'}, {'id': 'client-id'}, {'id': 'file-id'}]
+ files.get.return_value.execute.side_effect = [{'id': 'root-id'}, {'id': 'client-id', 'name': f'{self.person.pk} - Ana Teste'}]
+ return files
+
+ @patch('office.google_drive.get_service')
+ def test_real_upload_service_creates_named_folder_and_stores_ids(self, service):
+ DriveConnection.objects.create(encrypted_credentials='encrypted-test-only')
+ files = self.files()
+ service.return_value.files.return_value = files
+ result = upload_document(self.document.pk, b'%PDF-test', 'test.pdf', 'application/pdf')
+ self.assertEqual(files.create.call_args_list[1].kwargs['body']['name'], f'{self.person.pk} - Ana Teste')
+ self.assertEqual(files.create.call_args_list[2].kwargs['body']['parents'], ['client-id'])
+ self.assertEqual(result.drive_file_id, 'file-id')
+ self.assertTrue(result.entregue)
+ self.person.refresh_from_db()
+ self.assertEqual(self.person.drive_folder_id, 'client-id')
+ with self.assertRaises(ValueError):
+ upload_document(self.document.pk, b'%PDF-test', 'again.pdf', 'application/pdf')
+ self.assertEqual(files.create.call_count, 3)
+
+ @patch('office.google_drive.get_service')
+ def test_retry_recovers_existing_remote_files_without_duplication(self, service):
+ DriveConnection.objects.create(encrypted_credentials='encrypted-test-only')
+ files = self.files()
+ files.list.return_value.execute.side_effect = [{'files': [{'id': 'root-id'}]}, {'files': [{'id': 'client-id'}]}, {'files': [{'id': 'file-id'}]}]
+ files.get.return_value.execute.side_effect = [{'id': 'root-id'}, {'id': 'client-id', 'name': f'{self.person.pk} - Ana Teste'},
+ {'name': 'test.pdf', 'appProperties': {'himpHash': sha256(b'%PDF-test').hexdigest()}}]
+ service.return_value.files.return_value = files
+ result = upload_document(self.document.pk, b'%PDF-test', 'test.pdf', 'application/pdf')
+ self.assertEqual(result.drive_file_id, 'file-id')
+ files.create.assert_not_called()
+
+ @patch('office.google_drive.get_service')
+ def test_rename_uses_same_folder_and_remote_failure_rolls_back(self, service):
+ DriveConnection.objects.create(encrypted_credentials='encrypted-test-only', root_folder_id='root-id')
+ self.person.drive_folder_id = 'client-id'
+ self.person.save()
+ files = self.files()
+ files.get.return_value.execute.side_effect = [{'id': 'root-id'}, {'id': 'client-id', 'name': 'old name'}]
+ service.return_value.files.return_value = files
+ self.assertEqual(create_client_folder(self.person.pk), 'client-id')
+ files.update.assert_called_once_with(fileId='client-id', body={'name': f'{self.person.pk} - Ana Teste'}, fields='id')
+ files.get.return_value.execute.side_effect = RuntimeError('offline')
+ with self.assertRaises(RuntimeError):
+ upload_document(self.document.pk, b'test', 'test.pdf', 'application/pdf')
+ self.document.refresh_from_db()
+ self.assertFalse(self.document.drive_file_id)
+ self.assertFalse(self.document.entregue)
+
+ def test_linked_document_cannot_be_moved_to_another_client(self):
+ self.document.drive_file_id = 'file-id'
+ self.document.save()
+ self.client.post(f'/gestao/documentos/{self.document.pk}/editar/', {'cliente': self.other.pk, 'documento_nome': 'Passaporte'})
+ self.document.refresh_from_db()
+ self.assertEqual(self.document.cliente_id, self.person.pk)
+ self.assertNotIn('drive_folder_id', record_form(RESOURCES['clientes'])().fields)
+ self.assertNotIn('drive_file_id', record_form(RESOURCES['documentos'])().fields)
+
+ @patch('office.google_drive.flow')
+ def test_callback_encrypts_credentials_and_rejects_partial_consent(self, flow):
+ key = Fernet.generate_key()
+ credentials = flow.return_value.credentials
+ credentials.refresh_token = 'test-refresh'
+ credentials.has_scopes.return_value = True
+ credentials.to_json.return_value = '{"token":"test-private-token"}'
+ def session_state():
+ from django.utils import timezone
+ session = self.client.session
+ session['drive_state'] = 'verified-state'
+ session['drive_started_at'] = timezone.now().timestamp()
+ session.save()
+ with override_settings(TOKEN_ENCRYPTION_KEY=key.decode()):
+ session_state()
+ response = self.client.get('/google/drive/callback/?state=verified-state&code=test-code')
+ self.assertEqual(response.status_code, 302)
+ connection = DriveConnection.objects.get()
+ self.assertNotIn('test-private-token', connection.encrypted_credentials)
+ self.assertEqual(Fernet(key).decrypt(connection.encrypted_credentials.encode()), b'{"token":"test-private-token"}')
+ credentials.has_scopes.return_value = False
+ session_state()
+ self.client.get('/google/drive/callback/?state=verified-state&code=test-code')
+ connection.refresh_from_db()
+ self.assertEqual(Fernet(key).decrypt(connection.encrypted_credentials.encode()), b'{"token":"test-private-token"}')
+
+ @patch('office.google_drive.upload_document')
+ def test_generated_document_can_be_saved_and_failure_offers_download(self, upload):
+ with tempfile.TemporaryDirectory() as temp, override_settings(MEDIA_ROOT=temp,
+ STORAGES={'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
+ 'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'}}):
+ buffer = BytesIO()
+ doc = Document()
+ doc.add_paragraph('Olá {{ nome }}')
+ doc.save(buffer)
+ template = DocumentTemplate.objects.create(name='Modelo', file=SimpleUploadedFile('modelo.docx', buffer.getvalue()))
+ url = f'/gerar-documento/?cliente={self.person.pk}'
+ response = self.client.post(url, {'cliente': self.other.pk, 'modelo': template.pk, 'guardar_drive': 'on'})
+ self.assertEqual(response.status_code, 302)
+ saved = DocumentoCliente.objects.latest('pk')
+ self.assertEqual(saved.cliente_id, self.person.pk)
+ self.assertEqual(upload.call_args.args[0], saved.pk)
+ self.assertTrue(upload.call_args.args[1].startswith(b'PK'))
+ upload.side_effect = RuntimeError('offline')
+ response = self.client.post(url, {'modelo': template.pk, 'guardar_drive': 'on'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'O envio ao Drive falhou')
+ self.assertContains(response, 'Abrir registo do documento')
+ 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)
diff --git a/web/office/views.py b/web/office/views.py
index 2023ba5..b98a05a 100644
--- a/web/office/views.py
+++ b/web/office/views.py
@@ -16,8 +16,34 @@
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 .forms import GenerationForm, DriveUploadForm, record_form
+from .models import Agendamento, AuditEvent, Cliente, Configuration, DocumentoCliente, Pagamento, Processo, DriveConnection
+
+
+def contextual_client(request, spec):
+ if 'cliente' not in spec.fields or not request.GET.get('cliente'):
+ return None
+ value = request.GET['cliente']
+ if not value.isdigit() or len(value) > 10:
+ raise Http404
+ return get_object_or_404(Cliente, pk=int(value))
+
+
+def fix_client(form, client):
+ from django import forms
+ if client:
+ form.initial['cliente'] = client.pk
+ form.fields['cliente'].disabled = True
+ form.fields['cliente'].widget = forms.HiddenInput()
+
+
+def save_drive_file(request, obj, file):
+ import mimetypes
+ from pathlib import PurePosixPath
+ from .google_drive import upload_document
+ filename = PurePosixPath(file.name.replace('\\', '/')).name[:255]
+ obj = upload_document(obj.pk, file.read(), filename, mimetypes.guess_type(filename)[0] or 'application/octet-stream')
+ audit(request, 'drive_upload', obj)
from .registry import RESOURCES
@@ -69,7 +95,8 @@ def dashboard(request):
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})
+ return render(request, 'office/dashboard.html', {'cards': cards, 'payments': payments, 'upcoming': upcoming, 'pending': pending, 'today': today,
+ 'drive_connected': DriveConnection.objects.filter(key='office').exists()})
@resource_access('view')
@@ -81,9 +108,9 @@ def record_list(request, resource, spec):
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))
+ selected_client = contextual_client(request, spec)
+ if selected_client:
+ records = records.filter(cliente=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'))
@@ -91,7 +118,7 @@ def record_list(request, resource, spec):
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,
+ 'page': page, 'query': query, 'selected_client': selected_client,
'can_add': request.user.has_perm(f'office.add_{spec.model._meta.model_name}'),
})
@@ -113,6 +140,8 @@ def record_detail(request, resource, spec, pk):
'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'),
+ 'upload_form': DriveUploadForm() if resource == 'documentos' and not instance.drive_file_id else None,
+ 'drive_connected': DriveConnection.objects.filter(key='office').exists(),
})
@@ -125,10 +154,12 @@ def record_edit(request, resource, pk=None):
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)
+ selected_client = contextual_client(request, spec) if instance is None else None
+ if resource == 'documentos' and instance and instance.drive_file_id:
+ selected_client = instance.cliente
+ form = record_form(spec)(request.POST if request.method == 'POST' else None,
+ request.FILES if request.method == 'POST' else None, instance=instance)
+ fix_client(form, selected_client)
if request.method == 'POST' and form.is_valid():
try:
with transaction.atomic():
@@ -138,8 +169,15 @@ def record_edit(request, resource, pk=None):
form.add_error(None, 'Este registo entra em conflito com dados existentes. Verifique os identificadores.')
else:
messages.success(request, 'Registo guardado com sucesso.')
+ if resource == 'documentos' and form.cleaned_data.get('ficheiro'):
+ try:
+ save_drive_file(request, obj, form.cleaned_data['ficheiro'])
+ except Exception:
+ messages.error(request, 'O registo foi guardado, mas o envio ao Drive falhou. Verifique a ligação Google e volte a enviar o ficheiro no detalhe do documento.')
+ else:
+ messages.success(request, 'Ficheiro guardado na pasta Google Drive do cliente.')
return redirect('record_detail', resource=resource, pk=obj.pk)
- return render(request, 'office/form.html', {'resource': resource, 'spec': spec, 'form': form, 'object': instance})
+ return render(request, 'office/form.html', {'resource': resource, 'spec': spec, 'form': form, 'object': instance, 'selected_client': selected_client})
@resource_access('delete')
@@ -167,6 +205,10 @@ def generate_document(request):
from jinja2.sandbox import SandboxedEnvironment
from num2words import num2words
form = GenerationForm(request.POST if request.method == 'POST' else None)
+ selected_client = contextual_client(request, RESOURCES['documentos'])
+ fix_client(form, selected_client)
+ if request.method == 'POST' and request.POST.get('guardar_drive') and not request.user.has_perm('office.add_documentocliente'):
+ raise PermissionDenied
if request.method == 'POST' and form.is_valid():
client = form.cleaned_data['cliente']
template = form.cleaned_data['modelo']
@@ -211,8 +253,59 @@ def fmt(value):
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)
+ if form.cleaned_data.get('guardar_drive'):
+ obj = DocumentoCliente.objects.create(cliente=client, documento_nome=template.name)
+ audit(request, 'add', obj)
+ try:
+ from .google_drive import upload_document
+ upload_document(obj.pk, output.getvalue(), f'documento-{client.pk}-{template.pk}.docx',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
+ audit(request, 'drive_upload', obj)
+ except Exception:
+ form.add_error(None, 'O envio ao Drive falhou. Desmarque “Guardar na pasta Google Drive” e volte a gerar para descarregar o DOCX. Depois envie o ficheiro pelo registo criado abaixo.')
+ return render(request, 'office/generate.html', {'form': form, 'selected_client': selected_client, 'failed_document': obj})
+ messages.success(request, 'Documento gerado e guardado na pasta Google Drive do cliente.')
+ return redirect('record_detail', resource='documentos', pk=obj.pk)
return FileResponse(output, as_attachment=True, filename=f'documento-{client.pk}-{template.pk}.docx')
- return render(request, 'office/generate.html', {'form': form})
+ return render(request, 'office/generate.html', {'form': form, 'selected_client': selected_client})
+
+
+@login_required
+@permission_required('office.change_documentocliente', raise_exception=True)
+@permission_required('office.view_documentocliente', raise_exception=True)
+@require_POST
+def document_upload(request, pk):
+ obj = get_object_or_404(DocumentoCliente, pk=pk)
+ form = DriveUploadForm(request.POST, request.FILES)
+ if form.is_valid():
+ try:
+ save_drive_file(request, obj, form.cleaned_data['ficheiro'])
+ except Exception:
+ messages.error(request, 'Não foi possível enviar o ficheiro. Verifique a ligação Google; um registo já enviado não aceita outro ficheiro.')
+ else:
+ messages.success(request, 'Ficheiro guardado na pasta Google Drive do cliente.')
+ else:
+ for errors in form.errors.values():
+ for error in errors:
+ messages.error(request, error)
+ return redirect('record_detail', resource='documentos', pk=pk)
+
+
+@login_required
+@permission_required('office.change_cliente', raise_exception=True)
+@permission_required('office.view_cliente', raise_exception=True)
+@require_POST
+def client_drive_folder(request, pk):
+ obj = get_object_or_404(Cliente, pk=pk)
+ try:
+ from .google_drive import create_client_folder
+ create_client_folder(obj.pk)
+ except Exception:
+ messages.error(request, 'Não foi possível preparar a pasta. Verifique a ligação Google e se a pasta não está no lixo.')
+ else:
+ audit(request, 'drive_folder', obj)
+ messages.success(request, 'Pasta Google Drive preparada.')
+ return redirect('record_detail', resource='clientes', pk=pk)
@login_required
diff --git a/web/templates/office/dashboard.html b/web/templates/office/dashboard.html
index 7b997f8..6887f11 100644
--- a/web/templates/office/dashboard.html
+++ b/web/templates/office/dashboard.html
@@ -1,5 +1,6 @@
{% extends 'office/base.html' %}
-{% block title %}Visão geral · HIMP{% endblock %}
+{% block title %}Visão geral · HIMP{% if user.is_superuser %}Integração Google Drive {% if drive_connected %}Conta ligada{% else %}Conta ainda não ligada{% endif %}
Ligue a conta do escritório. O HIMP cria uma pasta principal e uma pasta por cliente, no formato ID - Nome. Envie ficheiros pela área Documentos ou guarde documentos gerados diretamente.
{% endif %}
+{% 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 %}
@@ -10,4 +11,5 @@
{% if pending != None %}{% endif %}
{% if user.is_superuser %}Integração Google Calendar Ligue a agenda do escritório para sincronizar os agendamentos individualmente.
{% endif %}
+{% if user.is_superuser %}Integração Google Drive {% if drive_connected %}Conta ligada{% else %}Conta ainda não ligada{% endif %}
Ligue a conta do escritório. O HIMP cria uma pasta principal e uma pasta por cliente, no formato ID - Nome. Envie ficheiros pela área Documentos ou guarde documentos gerados diretamente.
{% endif %}
{% endblock %}
diff --git a/web/templates/office/delete.html b/web/templates/office/delete.html
index 7aab83d..1f9e41f 100644
--- a/web/templates/office/delete.html
+++ b/web/templates/office/delete.html
@@ -1 +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 %} {% endblock %}
+{% 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 %}{% if object.drive_file_id or object.drive_folder_id %}O ficheiro ou pasta no Google Drive será preservado. A eliminação aqui remove apenas o registo do HIMP.
{% endif %} {% endblock %}
diff --git a/web/templates/office/detail.html b/web/templates/office/detail.html
index 500ff50..4f5dd53 100644
--- a/web/templates/office/detail.html
+++ b/web/templates/office/detail.html
@@ -1,9 +1,11 @@
{% extends 'office/base.html' %}{% load office_tags %}
{% block title %}{{ object }} · HIMP{% endblock %}
{% block content %}
-← {{ spec.title }}
+← {{ spec.title }}
DETALHE DO REGISTO
{{ object }}
{% for label, value in details %}
{{ label }} {{ value|display_value|linebreaksbr }}
{% endfor %}
+{% if resource == 'clientes' %}Pasta Google Drive Organização: HIMP / {{ object.pk }} - {{ object.nome_completo }}. Os ficheiros mantêm as permissões do Drive.
{% if object.drive_folder_id %}
Abrir pasta ↗ {% endif %}{% if can_edit %}
{% endif %}{% if perms.office.generate_document %}
Gerar documento {% endif %}
{% if not drive_connected %}O administrador precisa de ligar o Google Drive na Visão geral antes de enviar ficheiros.
{% endif %} {% endif %}
+{% if resource == 'documentos' %}Ficheiro no Google Drive {% if object.drive_file_id %}{{ object.drive_filename }}
Abrir ficheiro ↗ Para uma nova versão, crie outro registo. Gerir acesso e descarregar: Google Drive.
{% elif can_edit %}O ficheiro será guardado na pasta {{ object.cliente.pk }} - {{ object.cliente.nome_completo }}.
{% if not drive_connected %}O administrador precisa de ligar o Google Drive na Visão geral.
{% endif %}{% else %}Este registo ainda não tem um ficheiro associado.
{% endif %} {% endif %}
{% 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 %}
{% endif %}
{% for group in related %}{% endfor %}
{% endblock %}
diff --git a/web/templates/office/form.html b/web/templates/office/form.html
index e310127..7cb1ede 100644
--- a/web/templates/office/form.html
+++ b/web/templates/office/form.html
@@ -1,9 +1,10 @@
{% extends 'office/base.html' %}
{% block title %}{% if object %}Editar{% else %}Novo registo{% endif %} · HIMP{% endblock %}
{% block content %}
-← {{ spec.title }}
+← {{ spec.title }}
{{ spec.title|upper }}
{% if object %}Editar registo{% else %}Novo registo{% endif %}. Os campos marcados com * são obrigatórios.
-{% endblock %}
diff --git a/web/templates/office/form_fields.html b/web/templates/office/form_fields.html
index 713db4d..30ee346 100644
--- a/web/templates/office/form_fields.html
+++ b/web/templates/office/form_fields.html
@@ -1,2 +1,2 @@
{% if form.non_field_errors %}{{ form.non_field_errors }}
{% endif %}
-
+
diff --git a/web/templates/office/generate.html b/web/templates/office/generate.html
index 522979a..17ac3e8 100644
--- a/web/templates/office/generate.html
+++ b/web/templates/office/generate.html
@@ -1 +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.
{% endblock %}
+{% extends 'office/base.html' %}{% block title %}Gerar documento · HIMP{% endblock %}{% block content %}DOCUMENTOS
Gerar documento. Escolha um cliente e um modelo. Pode descarregar o documento ou guardá-lo diretamente na pasta do cliente no Google Drive.
{% if selected_client %}Cliente: {{ selected_client }}
{% endif %}{% endblock %}
diff --git a/web/templates/office/list.html b/web/templates/office/list.html
index dab8aac..57bbcb2 100644
--- a/web/templates/office/list.html
+++ b/web/templates/office/list.html
@@ -2,7 +2,8 @@
{% block title %}{{ spec.title }} · HIMP{% endblock %}
{% block content %}
ESPAÇO DE TRABALHO
{{ spec.title }}. {{ spec.description }}
{% if can_add %}
+ Novo registo {% endif %}
-
+{% if selected_client %}Registos de {{ selected_client }} · Voltar ao cliente ↗
{% 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/'), 'ESPAÇO DE TRABALHO
◈ Visão geral
- {% for key, label in navigation %}{% if key == 'clientes' %}◎{% elif key == 'processos' %}▤{% elif key == 'agenda' %}▦{% elif key == 'pagamentos' %}€{% else %}◇{% endif %} {{ label }} {% endfor %}
+ {% for key, label in navigation %}{% if key == 'clientes' %}◎{% elif key == 'processos' %}▤{% elif key == 'agenda' %}▦{% elif key == 'pagamentos' %}€{% else %}◇{% endif %} {{ label }} {% endfor %}
{% if perms.office.view_pagamento %}▥ Relatórios {% endif %}
{% if perms.office.generate_document and perms.office.view_cliente %}↗ Gerar documento {% endif %}
{% if user.is_staff %}ADMINISTRAÇÃO
⚙ Configurações {% endif %}
diff --git a/web/templates/office/dashboard.html b/web/templates/office/dashboard.html
index 6887f11..1e99c54 100644
--- a/web/templates/office/dashboard.html
+++ b/web/templates/office/dashboard.html
@@ -8,7 +8,7 @@
{% if upcoming != None %}
{% endif %}
{% if payments != None %}
{% endif %}
-{% if pending != None %}
{% endif %}
+{% if pending != None %}
{% endif %}
{% if user.is_superuser %}Integração Google Calendar Ligue a agenda do escritório para sincronizar os agendamentos individualmente.
{% endif %}
{% if user.is_superuser %}Integração Google Drive {% if drive_connected %}Conta ligada{% else %}Conta ainda não ligada{% endif %}
Ligue a conta do escritório. O HIMP cria uma pasta principal e uma pasta por cliente, no formato ID - Nome. Envie ficheiros pela área Documentos ou guarde documentos gerados diretamente.
{% endif %}
From 833d8b50beb21af0e944309fb2f15726dc706f20 Mon Sep 17 00:00:00 2001
From: Aleff Cavalcante <157486523+AceSCav@users.noreply.github.com>
Date: Wed, 7 Oct 2026 19:16:30 +0100
Subject: [PATCH 7/7] Create payment installments with optional due-date
intervals
---
README.md | 10 ++
docs/MIGRATION.md | 2 +-
web/office/forms.py | 38 +++++-
...ero_parcela_pagamento_plano_id_and_more.py | 28 +++++
web/office/models.py | 6 +-
web/office/payment_installments.py | 29 +++++
web/office/registry.py | 2 +-
web/office/test_payment_installments.py | 109 ++++++++++++++++++
web/office/views.py | 22 +++-
9 files changed, 238 insertions(+), 8 deletions(-)
create mode 100644 web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py
create mode 100644 web/office/payment_installments.py
create mode 100644 web/office/test_payment_installments.py
diff --git a/README.md b/README.md
index cefcb22..afd124a 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,16 @@ Os tokens OAuth são cifrados e não aparecem nos formulários/admin. Não torne
A migração `0005` adiciona as referências de pastas/ficheiros e a tabela privada de ligação. Reveja também `docs/supabase_hardening.sql` para bloquear acesso público à nova tabela. Nenhuma conta Google é ligada automaticamente; é necessário configurar as credenciais e autorizar a conta do escritório. A aplicação continua a funcionar sem Drive para os registos locais.
+## Pagamentos parcelados
+
+Em **Pagamentos → Novo registo**, introduza o **valor total** e o **número de parcelas** (1 a 120). O sistema divide o total em cêntimos exatos e distribui eventuais cêntimos restantes pelas primeiras parcelas: 1.000 € em três parcelas resulta em 333,34 €, 333,33 € e 333,33 €. Use 1 para um pagamento único.
+
+Com várias parcelas, a **data limite da primeira** é obrigatória. O **intervalo em dias** é opcional: em branco, as restantes parcelas ficam sem vencimento; com 30, os vencimentos são calculados a cada 30 dias desde a primeira data. São dias corridos, não meses de calendário. Depois pode definir ou alterar individualmente os vencimentos. A primeira parcela mantém uma data limite obrigatória.
+
+Cliente, entidade, referência, motivo e estado inicial são aplicados ao plano. A conclusão de cada parcela é registada individualmente depois da criação; as parcelas futuras não são marcadas como pagas em conjunto. O cliente de um plano é preservado na edição. A lista identifica cada parcela como **1 de N**, e o detalhe permite abrir as restantes parcelas do mesmo plano. Os valores, referências e datas podem ser editados por parcela. Todas as parcelas e a auditoria são criadas numa única transação; falhas não deixam um plano parcial.
+
+Parcelas sem vencimento não entram na previsão financeira por data: os relatórios avisam sobre pagamentos em aberto sem data limite. A migração `0006` acrescenta apenas a identificação do plano e a numeração das parcelas; pagamentos existentes mantêm os seus valores e não são agrupados automaticamente.
+
## Google Calendar
No Google Cloud, crie um OAuth client **Web application** com o URI HTTPS exato `/google/callback/` da instalação. Configure no servidor `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, `GOOGLE_CALENDAR_ID` e `TOKEN_ENCRYPTION_KEY`. Gere a chave com:
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index 2729de4..69a22e2 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; `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.
+`0001` contém apenas as onze tabelas originais de negócio. `--fake-initial` reconhece-as sem recriar. `0002` cria as tabelas Web; Django cria Auth/Sessions; `0003` altera tipos financeiros/horários; `0004` importa catálogos e metadados de modelos; `0005` adiciona referências de pastas/ficheiros Google Drive e a tabela privada de ligação Google Drive. `0006` adiciona identificação e numeração dos planos parcelados aos pagamentos, sem alterar pagamentos anteriores. A pré-verificação compara as colunas de `0001`, sem exigir antecipadamente os novos campos Web. A tabela antiga `users` não é usada pela Web.
Numa **base vazia**, execute apenas `migrate`. Num esquema parcial/diferente, não use `--fake` para contornar erros nem apague tabelas: prepare uma migração explícita para a estrutura real.
diff --git a/web/office/forms.py b/web/office/forms.py
index 5d7dc73..d18a8ce 100644
--- a/web/office/forms.py
+++ b/web/office/forms.py
@@ -1,13 +1,13 @@
import re
import zipfile
from copy import deepcopy
-from datetime import date
+from datetime import date, timedelta
from io import BytesIO
from django import forms
from django.core.exceptions import ValidationError
from django.db import models
-from .models import Configuration, DocumentTemplate, Cliente, DocumentoCliente
+from .models import Configuration, DocumentTemplate, Cliente, DocumentoCliente, Pagamento
def validate_drive_file(file):
@@ -79,6 +79,23 @@ def __init__(self, *args, **kwargs):
self.fields['ficheiro'] = deepcopy(DriveUploadForm.base_fields['ficheiro'])
self.fields['ficheiro'].required = False
self.fields['ficheiro'].help_text += ' Opcional; requer ligação Google do escritório.'
+ if self._meta.model == Pagamento and not self.instance.pk:
+ self.fields['montante'].label = 'Valor total (€)'
+ self.fields['montante'].help_text = 'O valor será dividido pelas parcelas, com acerto dos cêntimos para manter o total.'
+ self.fields['numero_parcelas'] = forms.IntegerField(label='Número de parcelas', min_value=1, max_value=120, initial=1, required=False,
+ help_text='Use 1 para pagamento único. Pode criar até 120 parcelas de uma vez.')
+ self.fields['intervalo_dias'] = forms.IntegerField(label='Intervalo entre parcelas (dias)', min_value=1, max_value=365, required=False,
+ widget=forms.NumberInput(attrs={'placeholder': 'Opcional · exemplo: 30'}),
+ help_text='Em branco: só a primeira tem vencimento; as restantes ficam sem data. Ex.: 30 cria vencimentos a cada 30 dias.')
+ self.fields['data_limite'].label = 'Data limite da primeira parcela'
+ self.fields['data_limite'].help_text = 'Obrigatória quando existem várias parcelas. As restantes datas podem ser definidas individualmente depois.'
+ self.fields['data_conclusao'].help_text = 'Para pagamento parcelado, registe a conclusão de cada parcela depois de criar o plano.'
+ self.fields['status'].help_text = 'Estado inicial aplicado a todas as parcelas.'
+ self.fields['referencia'].help_text = 'Aplicada a todas as parcelas; pode alterar a referência de cada uma depois.'
+ self.order_fields(['cliente', 'montante', 'numero_parcelas', 'data_limite', 'intervalo_dias', 'entidade', 'referencia', 'motivo', 'status', 'data_conclusao'])
+ elif self._meta.model == Pagamento and self.instance.numero_parcela == 1:
+ self.fields['data_limite'].required = True
+ self.fields['data_limite'].help_text = 'A primeira parcela do plano tem de manter uma data limite.'
def clean(self):
data = super().clean()
@@ -92,6 +109,23 @@ def clean(self):
self.add_error(name, f'Introduza {length} algarismos.')
if data.get('montante') is not None and data['montante'] < 0:
self.add_error('montante', 'O montante não pode ser negativo.')
+ if self._meta.model == Pagamento and not self.instance.pk:
+ count = data.get('numero_parcelas') or 1
+ data['numero_parcelas'] = count
+ if count > 1:
+ if not data.get('data_limite'):
+ self.add_error('data_limite', 'Defina a data limite da primeira parcela.')
+ if data.get('montante') is not None and data['montante'] * 100 < count:
+ self.add_error('montante', 'O total deve permitir pelo menos 0,01 € por parcela.')
+ if data.get('data_conclusao'):
+ self.add_error('data_conclusao', 'Registe a conclusão de cada parcela individualmente, depois de criar o plano.')
+ if data.get('data_limite') and data.get('intervalo_dias'):
+ try:
+ data['data_limite'] + timedelta(days=data['intervalo_dias'] * (count - 1))
+ except OverflowError:
+ self.add_error('intervalo_dias', 'O último vencimento ultrapassa o limite de datas. Reduza o intervalo ou o número de parcelas.')
+ elif data.get('intervalo_dias'):
+ self.add_error('intervalo_dias', 'O intervalo só é usado quando existem várias parcelas.')
if data.get('data_nascimento') and data['data_nascimento'] > date.today():
self.add_error('data_nascimento', 'A data de nascimento não pode ser futura.')
for start, end in [('emissao_passaporte', 'validade_passaporte'), ('emissao_bi_cc', 'validade_bi_cc')]:
diff --git a/web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py b/web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py
new file mode 100644
index 0000000..a6f54f0
--- /dev/null
+++ b/web/office/migrations/0006_pagamento_numero_parcela_pagamento_plano_id_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.18 on 2026-10-07 18:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('office', '0005_driveconnection_cliente_drive_folder_id_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='pagamento',
+ name='numero_parcela',
+ field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True, verbose_name='Parcela'),
+ ),
+ migrations.AddField(
+ model_name='pagamento',
+ name='plano_id',
+ field=models.UUIDField(blank=True, db_index=True, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name='pagamento',
+ name='total_parcelas',
+ field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True),
+ ),
+ ]
diff --git a/web/office/models.py b/web/office/models.py
index 7c18ce5..7bbd420 100644
--- a/web/office/models.py
+++ b/web/office/models.py
@@ -154,6 +154,9 @@ def __str__(self):
class Pagamento(models.Model):
+ plano_id = models.UUIDField(null=True, blank=True, editable=False, db_index=True)
+ numero_parcela = models.PositiveSmallIntegerField('Parcela', null=True, blank=True, editable=False)
+ total_parcelas = models.PositiveSmallIntegerField(null=True, blank=True, editable=False)
pagamento_id = models.BigAutoField(primary_key=True)
cliente = models.ForeignKey(Cliente, on_delete=models.PROTECT, db_column='cliente_id', null=True, blank=True, related_name='pagamentos')
entidade = models.CharField('Entidade de pagamento', max_length=20, null=True, blank=True)
@@ -168,7 +171,8 @@ class Meta:
ordering = ['data_limite', '-pagamento_id']
verbose_name = 'Pagamento'
def __str__(self):
- return f'Pagamento {self.pk} · {self.cliente or "Sem cliente"}'
+ label = f'Parcela {self.numero_parcela} de {self.total_parcelas}' if self.plano_id else f'Pagamento {self.pk}'
+ return f'{label} · {self.cliente or "Sem cliente"}'
class Agendamento(models.Model):
diff --git a/web/office/payment_installments.py b/web/office/payment_installments.py
new file mode 100644
index 0000000..c511849
--- /dev/null
+++ b/web/office/payment_installments.py
@@ -0,0 +1,29 @@
+from datetime import timedelta
+from decimal import Decimal
+from uuid import uuid4
+
+from .models import Pagamento
+
+
+def create_installments(form):
+ """Called inside the view transaction, after validating the creation form."""
+ data = form.cleaned_data
+ count = data['numero_parcelas']
+ if count == 1:
+ return [form.save()]
+ # Work in integer cents; distribute the remainder over the first parcels.
+ cents, remainder = divmod(int(data['montante'] * 100), count)
+ plan = uuid4()
+ payments = []
+ for index in range(count):
+ due = data['data_limite'] if index == 0 else None
+ if data.get('intervalo_dias'):
+ due = data['data_limite'] + timedelta(days=index * data['intervalo_dias'])
+ payments.append(Pagamento.objects.create(
+ cliente=data['cliente'], entidade=data.get('entidade'), referencia=data.get('referencia'),
+ motivo=data.get('motivo'), status=data.get('status'),
+ montante=Decimal(cents + (1 if index < remainder else 0)) / 100,
+ data_limite=due, data_conclusao=None, plano_id=plan,
+ numero_parcela=index + 1, total_parcelas=count,
+ ))
+ return payments
diff --git a/web/office/registry.py b/web/office/registry.py
index 65da3d5..2085f57 100644
--- a/web/office/registry.py
+++ b/web/office/registry.py
@@ -23,7 +23,7 @@ class Resource:
('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')),
+ ('cliente', 'numero_parcela', 'montante', 'data_limite', 'status', 'referencia'), ('cliente__nome_completo', 'referencia')),
'agenda': Resource(models.Agendamento, 'Agenda', 'Organize os atendimentos e sincronize com o Google Calendar.',
('cliente', 'titulo', 'data_inicio', 'duracao', 'motivo', 'descricao'),
('titulo', 'cliente', 'data_inicio', 'duracao', 'motivo'), ('titulo', 'cliente__nome_completo', 'motivo')),
diff --git a/web/office/test_payment_installments.py b/web/office/test_payment_installments.py
new file mode 100644
index 0000000..37472e4
--- /dev/null
+++ b/web/office/test_payment_installments.py
@@ -0,0 +1,109 @@
+from datetime import date
+from decimal import Decimal
+from unittest.mock import patch
+
+from django.contrib.auth.models import User, Permission
+from django.db import IntegrityError
+from django.test import TestCase
+
+from .models import AuditEvent, Cliente, Pagamento
+
+
+class InstallmentTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_superuser('admin', password='Local-test-2026!')
+ self.person = Cliente.objects.create(nome_completo='Cliente parcelado')
+ self.other = Cliente.objects.create(nome_completo='Outro cliente')
+ self.client.force_login(self.user)
+ self.url = f'/gestao/pagamentos/novo/?cliente={self.person.pk}'
+ self.data = {'cliente': self.other.pk, 'montante': '1000.00', 'numero_parcelas': '3',
+ 'data_limite': '2026-01-31', 'entidade': '00012', 'referencia': '00012345'}
+
+ def payments(self):
+ return list(Pagamento.objects.order_by('numero_parcela'))
+
+ def test_optional_interval_creates_only_first_due_date_and_exact_total(self):
+ response = self.client.post(self.url, self.data)
+ self.assertRedirects(response, f'/gestao/pagamentos/?cliente={self.person.pk}')
+ payments = self.payments()
+ self.assertEqual([p.montante for p in payments], [Decimal('333.34'), Decimal('333.33'), Decimal('333.33')])
+ self.assertEqual(sum(p.montante for p in payments), Decimal('1000'))
+ self.assertEqual([p.data_limite for p in payments], [date(2026, 1, 31), None, None])
+ self.assertEqual({p.cliente_id for p in payments}, {self.person.pk})
+ self.assertEqual(len({p.plano_id for p in payments}), 1)
+ self.assertEqual([p.numero_parcela for p in payments], [1, 2, 3])
+ self.assertTrue(all(p.total_parcelas == 3 and p.referencia == '00012345' and p.entidade == '00012' and p.data_conclusao is None for p in payments))
+ self.assertEqual(AuditEvent.objects.filter(action='add', model='office.Pagamento').count(), 3)
+ self.assertContains(self.client.get(response.url), '1 de 3')
+
+ def test_interval_means_exact_days_and_handles_year_leap_boundaries(self):
+ data = {**self.data, 'data_limite': '2024-01-31', 'intervalo_dias': '30'}
+ self.client.post(self.url, data)
+ self.assertEqual([p.data_limite for p in self.payments()], [date(2024, 1, 31), date(2024, 3, 1), date(2024, 3, 31)])
+
+ def test_validation_never_creates_partial_plans(self):
+ invalid = [({'data_limite': ''}, 'data_limite'), ({'numero_parcelas': '0'}, 'numero_parcelas'),
+ ({'numero_parcelas': '121'}, 'numero_parcelas'), ({'montante': '0.02'}, 'montante'),
+ ({'intervalo_dias': '0'}, 'intervalo_dias'), ({'intervalo_dias': '-30'}, 'intervalo_dias'),
+ ({'intervalo_dias': '1.5'}, 'intervalo_dias'), ({'data_limite': '9999-12-31', 'intervalo_dias': '30'}, 'intervalo_dias'),
+ ({'data_conclusao': '2026-01-31'}, 'data_conclusao')]
+ for changes, field in invalid:
+ with self.subTest(changes=changes):
+ response = self.client.post(self.url, {**self.data, **changes})
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(field, response.context['form'].errors)
+ self.assertEqual(Pagamento.objects.count(), 0)
+ self.assertEqual(AuditEvent.objects.count(), 0)
+
+ def test_single_payment_remains_compatible(self):
+ self.client.post(self.url, {'montante': '19.99', 'data_conclusao': '2026-01-01'})
+ payment = Pagamento.objects.get()
+ self.assertEqual(payment.montante, Decimal('19.99'))
+ self.assertIsNone(payment.plano_id)
+ self.assertIsNone(payment.data_limite)
+ self.assertEqual(payment.data_conclusao, date(2026, 1, 1))
+
+ def test_later_due_dates_and_completion_can_be_edited_without_changing_siblings(self):
+ self.client.post(self.url, self.data)
+ first, second, third = self.payments()
+ response = self.client.post(f'/gestao/pagamentos/{second.pk}/editar/',
+ {'cliente': self.other.pk, 'montante': '333.33', 'data_limite': '2026-05-15', 'data_conclusao': '2026-05-14'})
+ self.assertEqual(response.status_code, 302)
+ second.refresh_from_db()
+ first.refresh_from_db()
+ third.refresh_from_db()
+ self.assertEqual(second.cliente_id, self.person.pk)
+ self.assertEqual(second.data_limite, date(2026, 5, 15))
+ self.assertEqual(second.data_conclusao, date(2026, 5, 14))
+ self.assertEqual(first.data_limite, date(2026, 1, 31))
+ self.assertIsNone(third.data_limite)
+ self.assertEqual(Pagamento.objects.count(), 3)
+ self.assertContains(self.client.get(f'/gestao/pagamentos/{second.pk}/'), 'Outras parcelas do plano')
+
+ def test_first_installment_cannot_lose_due_date(self):
+ self.client.post(self.url, self.data)
+ first = self.payments()[0]
+ response = self.client.post(f'/gestao/pagamentos/{first.pk}/editar/', {'montante': '333.34', 'data_limite': ''})
+ self.assertEqual(response.status_code, 200)
+ self.assertIn('data_limite', response.context['form'].errors)
+ first.refresh_from_db()
+ self.assertEqual(first.data_limite, date(2026, 1, 31))
+
+ def test_transaction_rolls_back_whole_plan_and_audit_on_failure(self):
+ original = Pagamento.objects.create
+ def create(**kwargs):
+ if kwargs.get('numero_parcela') == 2:
+ raise IntegrityError('simulated database failure')
+ return original(**kwargs)
+ with patch('office.payment_installments.Pagamento.objects.create', side_effect=create):
+ response = self.client.post(self.url, self.data)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(Pagamento.objects.count(), 0)
+ self.assertEqual(AuditEvent.objects.count(), 0)
+
+ def test_viewer_cannot_create_installments(self):
+ viewer = User.objects.create_user('viewer')
+ viewer.user_permissions.add(Permission.objects.get(codename='view_pagamento'))
+ self.client.force_login(viewer)
+ self.assertEqual(self.client.post(self.url, self.data).status_code, 403)
+ self.assertEqual(Pagamento.objects.count(), 0)
diff --git a/web/office/views.py b/web/office/views.py
index 342b102..c96fe89 100644
--- a/web/office/views.py
+++ b/web/office/views.py
@@ -117,7 +117,7 @@ def record_list(request, resource, spec):
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]
+ rows = [{'object': obj, 'cells': [f'{obj.numero_parcela} de {obj.total_parcelas}' if field == 'numero_parcela' and obj.plano_id else 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,
@@ -138,6 +138,10 @@ def record_detail(request, resource, spec, pk):
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]})
+ elif resource == 'pagamentos' and instance.plano_id:
+ details.append(('Parcela', f'{instance.numero_parcela} de {instance.total_parcelas}'))
+ related.append({'key': 'pagamentos', 'label': 'Outras parcelas do plano', 'objects': Pagamento.objects.filter(
+ plano_id=instance.plano_id, cliente_id=instance.cliente_id).exclude(pk=instance.pk).select_related('cliente').order_by('numero_parcela')})
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}'),
@@ -162,17 +166,29 @@ def record_edit(request, resource, pk=None):
return redirect('record_list', resource='clientes')
if resource == 'documentos' and instance and instance.drive_file_id:
selected_client = instance.cliente
+ if resource == 'pagamentos' and instance and instance.plano_id:
+ selected_client = instance.cliente
form = record_form(spec)(request.POST if request.method == 'POST' else None,
request.FILES if request.method == 'POST' else None, instance=instance)
fix_client(form, selected_client)
if request.method == 'POST' and form.is_valid():
try:
with transaction.atomic():
- obj = form.save()
- audit(request, action, obj)
+ if resource == 'pagamentos' and instance is None:
+ from .payment_installments import create_installments
+ payments = create_installments(form)
+ obj = payments[0]
+ for payment in payments:
+ audit(request, action, payment)
+ else:
+ 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:
+ if resource == 'pagamentos' and instance is None and len(payments) > 1:
+ messages.success(request, f'{len(payments)} parcelas criadas. Pode editar os valores, referências e vencimentos de cada parcela individualmente.')
+ return redirect(reverse('record_list', args=['pagamentos']) + f'?cliente={obj.cliente_id}')
messages.success(request, 'Registo guardado com sucesso.')
if resource == 'documentos' and form.cleaned_data.get('ficheiro'):
try: