From 4fa44f10cba64c05d00c28e765dff38b6bbb1bcb Mon Sep 17 00:00:00 2001 From: Caue Felipe Trovatto Tragante Date: Tue, 20 Jan 2026 16:03:52 -0300 Subject: [PATCH] Lack0fdev (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redesign admin UI and add real-time activity tracking Major UI overhaul for admin templates (cadastrar_funcionario, listar_funcionarios) with Tailwind CSS, improved forms, and statistics. Added real-time user activity tracking and status indicators for employees. Introduced endpoint for activity registration, updated login redirection logic, and improved test coverage for new flows. Also includes VSCode config files, CPF input enhancements, and various template and test adjustments for consistency and usability. * lint: type improvements Introduced explicit type annotations for variables and return types in tv1_historico_api_view and tv2_historico_api_view functions. This improves code clarity and type safety. * feat: integra melhorias da interface e funcionalidades do stash - Adiciona ROTEIRO_PI2.md com documentação do projeto - Melhora templates HTML (painéis, formulários, TVs) - Atualiza estilos CSS para melhor UX - Refatora testes e views com type hints - Adiciona configurações .vscode e .coveragerc - Integra análise de segurança Bandit no CI/CD - Atualiza .gitignore com padrões abrangentes * fix: resolve mypy type errors in run_bandit_separate.py * docs: README update * Refactor: update quality checks and test scripts * Fix: Remove unnecessary global declaration in mock function * feat: implement SMS/WhatsApp notifications. * Transferir mudanças para lack0fdev * chore(updt): Update Django * feat: adiciona observações visíveis, permite reutilizar cartão SUS e registra hora de entrada - Adicionada exibição de observações nos painéis do Guichê e Profissional de Saúde - Removida validação de unicidade do Cartão do SUS para permitir pacientes retornarem - Implementada inserção automática da hora de entrada nas observações do paciente - Atualizado teste de integração para refletir nova regra de negócio do Cartão SUS * style: formata arquivo de teste com black * style: força reformatação do manage.py * chore(deps): apply safety recommendations (Django/security) --- .bandit | 11 + .coveragerc | 28 + .github/workflows/django.yml | 37 +- .gitignore | 187 +- .vscode/extensions.json | 11 + .vscode/settings.json | 30 + Dockerfile | 23 + README.md | 237 +-- READMEimplementation.md | 293 +++ .../alterar_senha_funcionario.html | 63 + .../administrador/cadastrar_funcionario.html | 217 ++- .../editar_dados_funcionario.html | 49 + .../administrador/listar_funcionarios.html | 381 +++- administrador/urls.py | 15 + administrador/views.py | 160 +- bandit_Rodar.py | 140 ++ bandit_analisar.py | 254 +++ core/forms.py | 89 +- core/signals.py | 5 +- core/tests.py | 1722 ----------------- core/utils.py | 166 +- core/views.py | 39 +- docker-compose.prod.yml | 50 + entrypoint.sh | 120 ++ guiche/templates/guiche/painel_guiche.html | 212 +- .../templates/guiche/selecionar_guiche.html | 74 +- guiche/templates/guiche/tv1.html | 43 +- guiche/urls.py | 1 + guiche/views.py | 102 +- mypy.ini | 3 + nginx.conf | 51 + profissional_saude/forms.py | 17 + .../painel_profissional.html | 169 +- .../profissional_saude/selecionar_sala.html | 65 + .../templates/profissional_saude/tv2.html | 106 +- profissional_saude/urls.py | 2 + profissional_saude/views.py | 190 +- pytest.ini | 3 + .../recepcionista/cadastrar_paciente.html | 130 +- recepcionista/tests.py | 404 ---- recepcionista/views.py | 19 +- requirements-docker.txt | 10 + requirements.txt | 2 +- sga/settings.py | 3 + sga/tests/__init__.py | 0 sga/{ => tests}/settings_test.py | 12 +- static/css/estilos.css | 303 +-- static/js/atividade.js | 64 + staticfiles/admin/css/widgets.css | 4 +- staticfiles/admin/js/SelectFilter2.js | 12 +- templates/base.html | 225 ++- templates/login.html | 83 +- templates/pagina_inicial.html | 4 +- test_fluxocompleto2.py | 790 ++++++++ test_phone.py | 21 - tests/__init__.py | 1 + tests/apps.py | 6 + tests/tests/__init__.py | 4 + tests/tests/functional/__init__.py | 6 + .../tests/functional/administrador_tests.py | 4 + .../tests/functional/guiche_tests.py | 20 +- .../functional/profissional_saude_tests.py | 28 +- tests/tests/functional/recepcionista_tests.py | 192 ++ tests/tests/functional/tests.py | 30 + tests/tests/functional/tests_views.py | 39 + tests/tests/integration/__init__.py | 3 + .../tests_integracao_autorizacao.py | 343 +--- .../integration/tests_integracao_whatsapp.py | 172 ++ tests/tests/integration/tests_integration.py | 85 + tests/tests/security/__init__.py | 4 + tests/tests/security/tests_forms_login.py | 160 ++ tests/tests/security/tests_security_forms.py | 119 ++ tests/tests/security/tests_security_views.py | 132 ++ tests/tests/unit/__init__.py | 9 + tests/tests/unit/tests_forms_funcionario.py | 262 +++ tests/tests/unit/tests_forms_paciente.py | 194 ++ tests/tests/unit/tests_models_atendimento.py | 87 + tests/tests/unit/tests_models_chamada.py | 209 ++ tests/tests/unit/tests_models_customuser.py | 126 ++ tests/tests/unit/tests_models_guiche.py | 83 + tests/tests/unit/tests_models_paciente.py | 181 ++ tests/tests/unit/tests_models_registro.py | 111 ++ tests/tests/unit/tests_utils.py | 159 ++ 83 files changed, 7018 insertions(+), 3202 deletions(-) create mode 100644 .bandit create mode 100644 .coveragerc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 READMEimplementation.md create mode 100644 administrador/templates/administrador/alterar_senha_funcionario.html create mode 100644 administrador/templates/administrador/editar_dados_funcionario.html create mode 100644 bandit_Rodar.py create mode 100644 bandit_analisar.py delete mode 100644 core/tests.py create mode 100644 docker-compose.prod.yml create mode 100755 entrypoint.sh create mode 100644 nginx.conf create mode 100644 profissional_saude/templates/profissional_saude/selecionar_sala.html create mode 100644 pytest.ini delete mode 100644 recepcionista/tests.py create mode 100644 requirements-docker.txt create mode 100644 sga/tests/__init__.py rename sga/{ => tests}/settings_test.py (68%) create mode 100644 static/js/atividade.js create mode 100644 test_fluxocompleto2.py delete mode 100644 test_phone.py create mode 100644 tests/__init__.py create mode 100644 tests/apps.py create mode 100644 tests/tests/__init__.py create mode 100644 tests/tests/functional/__init__.py rename administrador/tests.py => tests/tests/functional/administrador_tests.py (99%) rename guiche/tests.py => tests/tests/functional/guiche_tests.py (96%) rename profissional_saude/tests.py => tests/tests/functional/profissional_saude_tests.py (95%) create mode 100644 tests/tests/functional/recepcionista_tests.py create mode 100644 tests/tests/functional/tests.py create mode 100644 tests/tests/functional/tests_views.py create mode 100644 tests/tests/integration/__init__.py rename core/tests_integracao_dinamica.py => tests/tests/integration/tests_integracao_autorizacao.py (54%) create mode 100644 tests/tests/integration/tests_integracao_whatsapp.py create mode 100644 tests/tests/integration/tests_integration.py create mode 100644 tests/tests/security/__init__.py create mode 100644 tests/tests/security/tests_forms_login.py create mode 100644 tests/tests/security/tests_security_forms.py create mode 100644 tests/tests/security/tests_security_views.py create mode 100644 tests/tests/unit/__init__.py create mode 100644 tests/tests/unit/tests_forms_funcionario.py create mode 100644 tests/tests/unit/tests_forms_paciente.py create mode 100644 tests/tests/unit/tests_models_atendimento.py create mode 100644 tests/tests/unit/tests_models_chamada.py create mode 100644 tests/tests/unit/tests_models_customuser.py create mode 100644 tests/tests/unit/tests_models_guiche.py create mode 100644 tests/tests/unit/tests_models_paciente.py create mode 100644 tests/tests/unit/tests_models_registro.py create mode 100644 tests/tests/unit/tests_utils.py diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..072a8cc --- /dev/null +++ b/.bandit @@ -0,0 +1,11 @@ +[bandit] +exclude_dirs = __pycache__,migrations,htmlcov,staticfiles,.git,.venv,.mypy_cache,venv,tests +verbose = True +debug = True +format = json +output_file = bandit_debug_report.json + +[tool:bandit] +# Configurações específicas do Bandit +severity = all +confidence = all \ No newline at end of file diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..867025e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,28 @@ +[run] +source = . +omit = + */migrations/* + */__pycache__/* + */tests/* + */test_*.py + manage.py + sga/wsgi.py + sga/asgi.py + sga/settings*.py + api/* + venv/* + .venv/* + staticfiles/* + static/* + analyze_bandit_ci.py + run_bandit_separate.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + class .*\bProtocol\): + @(abc\.)?abstractmethod \ No newline at end of file diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index dcb44d7..c13a343 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -37,7 +37,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install coverage + pip install coverage pytest pytest-django - name: Run Database Migrations env: @@ -55,16 +55,31 @@ jobs: DJANGO_SETTINGS_MODULE: sga.settings DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb run: | - coverage run --source='.' manage.py test --settings=sga.settings_test + coverage run --source=. --omit="bandit_Rodar.py,bandit_analisar.py,test_fluxocompleto2.py" manage.py test tests --pattern="*test*.py" --settings=sga.tests.settings_test coverage report coverage html + - name: Run Integration Test with HTML Report + env: + DJANGO_SETTINGS_MODULE: sga.settings + DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb + run: | + echo "Running complete integration test with HTML report generation..." + python test_fluxocompleto2.py + echo "Integration test completed successfully" + - name: Upload Coverage Report uses: actions/upload-artifact@v4 with: name: coverage-report path: htmlcov/ + - name: Upload Integration Test Report + uses: actions/upload-artifact@v4 + with: + name: integration-test-report + path: relatorio_teste_real.html + lint: runs-on: ubuntu-latest steps: @@ -99,17 +114,23 @@ jobs: pip install -r requirements.txt pip install safety bandit mypy - name: Run Safety Scan - uses: pyupio/safety-action@v1 - with: - api-key: ${{ secrets.SAFETY_API_KEY }} + run: safety scan . + env: + SAFETY_API_KEY: ${{ secrets.SAFETY_API_KEY }} - name: Run Bandit Security Linter run: | - bandit -r . --exclude venv,.git,__pycache__,.mypy_cache,staticfiles,node_modules -f html -o bandit-report.html || true + echo "Running Bandit security analysis using separate script..." + python bandit_Rodar.py + echo "Bandit analysis completed successfully" + + - name: Analyze Bandit Results + run: | + python bandit_analisar.py - name: Run MyPy Type Checking run: mypy . - name: Upload Bandit Report uses: actions/upload-artifact@v4 with: - name: bandit-report - path: bandit-report.html + name: bandit-reports + path: bandit_report.html diff --git a/.gitignore b/.gitignore index 4b0a466..e193f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -180,5 +180,188 @@ cython_debug/ .cursorignore .cursorindexingignore -# Copilot Instructions -.github/copilot-instructions.md \ No newline at end of file +# Backup files +*.bak +*.backup +*~ + +# Development and testing artifacts +*.pyc +*.pyo +*.pyd +__pycache__/ +*.so +*.dll +*.dylib + +# Database and media files +*.sqlite3 +*.db +/media/ +staticfiles/ +staticfiles_build/ + +# Environment and configuration +.env +.env.* +settings/local.py +settings/production.py +settings/staging.py + +# Logs and debugging +*.log +logs/ +debug.log +django_debug.log +gunicorn.log +celery.log + +# IDE and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Testing and coverage +.coverage +.coverage.* +coverage.xml +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ +.cache/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pytype/ + +# Linting and formatting +.ruff_cache/ + +# Security reports (generated by CI/CD) +bandit_*.json +bandit_report.html +safety-report.json +security_scan_*.json + +# Node.js (for frontend assets) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Docker and containers +.dockerignore +docker-compose.override.yml + +# Deployment and build artifacts +build/ +dist/ +*.egg-info/ +.eggs/ + +# Secrets and certificates +secrets.json +keys/ +*.key +*.pem +*.crt +*.p12 +*.pfx +*.cer + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# Temporary files +*.tmp +*.temp +.tmp/ +temp/ + +# Jupyter notebooks checkpoints +.ipynb_checkpoints/ + +# VS Code extensions and settings +.vscode/extensions.json +.vscode/settings.json + +# Cursor AI editor +.cursorignore +.cursorindexingignore + +# GitHub Copilot +.github/copilot-instructions.md + +# Additional Django specific +# Uncomment if you want to ignore migrations in development +# */migrations/ + +# Redis dumps +dump.rdb + +# Celery beat schedule +celerybeat-schedule +celerybeat.pid + +# Sentry config +.sentryclirc + +# AWS credentials +.aws/ + +# Google Cloud +.gcloud/ + +# Azure +.azure/ + +# Terraform +*.tfstate +*.tfvars + +# Ansible +*.retry + +# Vagrant +.vagrant/ + +# VirtualBox +*.vbox +*.vbox-prev + +# VMware +*.vmx +*.vmxf +*.vmdk +*.nvram + +# Serverless frameworks +.serverless/ + +# Local development overrides +docker-compose.override.yml +docker-compose.local.yml + +# Documentation and planning files +ROTEIRO_PI2.md +roteiro_*.md +planning_*.md +notes_*.md +ROTEIRO_TESTES.md + +# Generated test reports +relatorio_teste_real.html \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..5cbcff5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.flake8", + "ms-python.mypy-type-checker", + "batisteo.vscode-django", + "bradlc.vscode-tailwindcss", + "esbenp.prettier-vscode", + "ms-vscode.vscode-json" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..137a3e2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,30 @@ +{ + "files.associations": { + "*.html": "html", + "**/templates/**/*.html": "django-html", + "**/templates/**": "django-html" + }, + "emmet.includeLanguages": { + "django-html": "html" + }, + "html.validate.scripts": false, + "html.validate.styles": false, + "css.validate": false, + "python.linting.enabled": true, + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.mypyEnabled": true, + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/staticfiles/": true, + "**/htmlcov/": true, + "**/.coverage": true + }, + "search.exclude": { + "**/staticfiles/": true, + "**/htmlcov/": true, + "**/__pycache__": true, + "**/*.pyc": true + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78ad520 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements-docker.txt /app/requirements-docker.txt + +RUN python -m pip install --upgrade pip setuptools wheel \ + && pip install -r /app/requirements-docker.txt + +COPY . /app + +RUN chmod +x /app/entrypoint.sh || true + +EXPOSE 8000 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 1d9bec0..454152e 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,70 @@ -# Sistema de Gerenciamento de Atendimento (SGA) - ILSL +# Sistema de Gerenciamento de Atendimento (SGA) - ILSL Este projeto visa informatizar e otimizar o fluxo de atendimento de pacientes no Instituto Lauro de Souza Lima, em Bauru - SP, tornando o processo mais eficiente, seguro e humanizado para profissionais e pacientes. -## Índice +## Como Funciona -- [Visão Geral](#visão-geral) -- [Funcionalidades](#funcionalidades) -- [Instalação](#instalação) -- [Testes](#testes) -- [Como Contribuir](#como-contribuir) -- [Próximos Passos e Melhorias Futuras](#próximos-passos-e-melhorias-futuras) -- [Licença](#licença) +### Fluxo de Atendimento ---- +1. **📋 Cadastro do Paciente** + - Recepcionista cadastra paciente com dados pessoais e agendamento + - Sistema valida CPF e dados automaticamente -## Visão Geral +2. **🎫 Chegada e Fila de Espera** + - Paciente chega e é direcionado para o guichê + - Recebe senha de acordo com o tipo de atendimento -O SGA foi desenvolvido em Python (Django), com o objetivo de gerenciar de forma integrada o fluxo de pacientes, organização de filas, agendamento e comunicação entre equipe médica e pacientes. Ele tem como foco principal: +3. **📢 Chamada Automática** + - Guichê ou profissional de saúde chama próximo paciente via painel + - Sistema envia notificação automática via SMS/WhatsApp + - Paciente é direcionado para o guichê ou sala correta -- Gerenciamento eletrônico da fila de espera. -- Redução de atrasos e aumento da organização interna. -- Melhoria da comunicação com os pacientes. +4. **👨‍⚕️ Atendimento** + - Profissional confirma início do atendimento + - Sistema registra horário de entrada na consulta ---- +5. **✅ Finalização** + - Profissional confirma fim do atendimento + - Sistema registra horário de saída e atualiza displays -## Funcionalidades - -### Implementadas - -- **Cadastro de Pacientes:** Permite cadastrar novos pacientes no sistema com validação completa de dados -- **Gerenciamento de Fila de Espera:** Organização automática da ordem de atendimento com prioridade -- **Controle de Entrada e Saída:** Registro preciso do momento em que cada paciente entra/saí da consulta -- **Histórico de Atendimentos:** Armazena o histórico completo dos atendimentos para consultas futuras -- **Sistema de Chamadas:** Integração WhatsApp para chamada automática de pacientes -- **Displays TV:** Painéis de acompanhamento em tempo real para pacientes e profissionais -- **Relatórios Avançados:** Geração de relatórios detalhados para análise de desempenho -- **Controle de Acesso:** Sistema robusto de permissões por função (administrador, recepcionista, profissional, guichê) -- **Segurança Avançada:** - - Proteções CSRF, validação de CPF completa - - Proteção contra XSS (Cross-Site Scripting) - - Proteção contra SQL Injection - - Bloqueio automático contra força bruta - - Sanitização completa de entrada -- **Interface Responsiva:** Design moderno e intuitivo para desktop e dispositivos móveis -- **Integração Contínua:** CI/CD com GitHub Actions, testes automatizados com PostgreSQL -- **Cobertura de Testes Completa:** 193 testes incluindo segurança e API Twilio +### Displays em Tempo Real ---- +- **TV do Guichê:** Mostra fila atual e próximos pacientes +- **TV das Salas:** Exibe paciente atual e sala de destino +- **Painel do Administrador:** Controle total do sistema + +## Funcionalidades Principais + +### 👥 Gestão de Usuários +- **Administrador:** Controle total, relatórios e configurações +- **Recepcionista:** Cadastro de pacientes e gerenciamento de filas +- **Profissional de Saúde:** Chamada de pacientes e controle de consultas +- **Guichê:** Atendimento inicial e distribuição de senhas + +### 📊 Monitoramento em Tempo Real +- Status online/offline dos funcionários (bolinhas coloridas) +- Displays atualizados automaticamente a cada 5 segundos + +### 📋 Relatórios e Analytics +- Relatório HTML detalhado de testes de integração +- Logs visuais categorizados por etapas (Recepcionista, Guichê, Profissional) +- Estatísticas de cobertura e performance dos testes + +### 📱 Comunicação Integrada +- Notificações automáticas via SMS/WhatsApp para guichê e profissionais de saúde +- Relatório visual de testes com logs detalhados das notificações ## Instalação -### 1. Clone o repositório +### Pré-requisitos +- Python 3.11+ +- PostgreSQL (produção) ou SQLite (desenvolvimento) +- Git + +### Passos Rápidos ```bash +# 1. Clone o repositório git clone https://github.com/lack0fcode/python-sga-LSL-Univesp.git cd python-sga-LSL-Univesp ``` @@ -75,98 +87,89 @@ source venv/bin/activate # Linux/Mac ```bash pip install -r requirements.txt -``` -### 5. Rode o servidor localmente +# 3. Configure o banco de dados +python manage.py migrate -```bash +# 4. Crie um superusuário +python manage.py createsuperuser + +# 5. Rode o servidor python manage.py runserver ``` -O sistema estará disponível em http://127.0.0.1:8000/ - ---- - -## Testes - -O projeto possui **188 testes automatizados** que cobrem as funcionalidades principais do sistema, incluindo testes de segurança abrangentes e validações completas. - -### Executando os Testes +Acesse http://127.0.0.1:8000/ e faça login! -Para executar os testes, use o comando: +## Testes e Qualidade +### Executando Testes ```bash -python manage.py test --settings=sga.settings_test -``` - -**Nota:** Os testes utilizam SQLite em memória para desenvolvimento local (rápido e isolado), mas PostgreSQL no GitHub Actions (igual ao ambiente de produção). - -### Cobertura dos Testes - -- ✅ Testes de autenticação e autorização -- ✅ Testes de cadastro e gerenciamento de pacientes -- ✅ Testes de fila de atendimento no guichê -- ✅ Testes de painel e ações do profissional de saúde -- ✅ Testes de integração WhatsApp para chamadas de pacientes -- ✅ Testes de displays TV para acompanhamento em tempo real -- ✅ Testes de relatórios e histórico de chamadas -- ✅ Testes de validação de formulários e segurança -- ✅ Testes de API endpoints -- ✅ Testes de controle de acesso e permissões -- ✅ **Testes de Segurança Avançada:** - - Proteção contra XSS (Cross-Site Scripting) - - Proteção contra SQL Injection - - Proteção contra força bruta (bloqueio de conta) - - Validações de entrada sanitizadas - -### Integração Contínua (CI/CD) +# Testes completos +python manage.py test --settings=sga.tests.settings_test -O projeto utiliza GitHub Actions para integração contínua: +# Com cobertura (excluindo arquivos de análise) +coverage run --source=. --omit="bandit_Rodar.py,bandit_analisar.py,test_fluxocompleto2.py" manage.py test tests --pattern="*test*.py" --settings=sga.tests.settings_test +coverage report -- **Testes Automatizados:** Executados em PostgreSQL (ambiente idêntico à produção) -- **Análise de Segurança:** Verificação com Bandit e Safety -- **Linting:** Validação de código com Flake8 e Black -- **Cobertura:** Relatórios detalhados de cobertura de testes - -### Arquitetura de Testes - -O sistema de testes foi projetado para máxima eficiência e confiabilidade: +# Teste de fluxo completo com relatório HTML +python test_fluxocompleto2.py +``` -- **Desenvolvimento Local:** SQLite in-memory (rápido, ~6 segundos para 188 testes) -- **CI/CD:** PostgreSQL (igual à produção, captura diferenças de comportamento) -- **APIs Externas:** Mocks completos (Twilio) para evitar custos e dependências -- **Segurança:** Testes ativos de vulnerabilidades (XSS, SQL injection, força bruta) -- **Cobertura:** 100% das funcionalidades críticas testadas - ---- +### Qualidade do Código +- ✅ **195 testes automatizados** cobrindo funcionalidades críticas +- ✅ **96% de cobertura** de testes automatizados +- ✅ **Análise de segurança** com Bandit e Safety +- ✅ **Linting** com Flake8 e Black +- ✅ **Type checking** com MyPy +- ✅ **CI/CD** automatizado no GitHub Actions +- ✅ **Workflow completo** com testes, linting e segurança +- ✅ **Relatórios automáticos** de cobertura e análise de segurança ## Segurança -O sistema implementa **múltiplas camadas de segurança** com validações ativas: - -### 🛡️ **Proteções Implementadas:** - -- **Proteção CSRF:** Todas as views estão protegidas contra ataques CSRF -- **Validação de CPF:** Validação completa com cálculo de dígitos verificadores -- **Controle de Acesso:** Sistema de permissões baseado em funções (administrador, recepcionista, profissional de saúde, guichê) -- **Proteção XSS:** Validação ativa contra scripts maliciosos em formulários -- **Proteção SQL Injection:** Django ORM com prepared statements (proteção nativa) -- **Bloqueio de Força Bruta:** Contas bloqueadas após 4 tentativas de login falhidas -- **Sanitização de Entrada:** Validação rigorosa de todos os dados de entrada -- **Configurações de Produção:** Headers de segurança, SSL/TLS obrigatório, configurações controladas por variáveis de ambiente - ---- - -Contribuições são muito bem-vindas! Siga os passos abaixo: - -1. Faça um fork deste repositório. -2. Crie uma branch para sua feature (`git checkout -b minha-feature`). -3. Commit suas mudanças (`git commit -m 'Minha nova feature'`). -4. Faça um push para a branch (`git push origin minha-feature`). -5. Abra um Pull Request. +### Proteções Implementadas +- 🔒 **Autenticação robusta** com bloqueio contra força bruta +- 🛡️ **Validação completa** de CPF e dados pessoais +- 🔐 **Controle de acesso** por funções (Admin, Recepcionista, Profissional, Guichê) +- 🚫 **Proteção contra ataques** XSS, CSRF, SQL Injection +- 📱 **Sanitização** completa de todas as entradas + +## Tecnologias Utilizadas + +- **Backend:** Python 3.11+ com Django 4.2 +- **Banco de Dados:** PostgreSQL (produção) / SQLite (desenvolvimento) +- **Frontend:** HTML5, CSS3, JavaScript (jQuery, Bootstrap) +- **APIs:** Twilio (SMS/WhatsApp) +- **Testes:** pytest, Coverage.py, unittest.mock +- **Qualidade:** Black, Flake8, MyPy, Bandit, Safety +- **CI/CD:** GitHub Actions +- **Relatórios:** HTML dinâmico com estatísticas visuais + +## Como Contribuir + +1. **Fork** o projeto +2. **Clone** sua fork: `git clone https://github.com/SEU_USERNAME/python-sga-LSL-Univesp.git` +3. **Crie uma branch** para sua feature: `git checkout -b minha-feature` +4. **Faça suas mudanças** seguindo os padrões do projeto +5. **Execute os testes:** `python manage.py test --settings=sga.tests.settings_test` +6. **Commit suas mudanças:** `git commit -m 'feat: descrição da feature'` +7. **Push para sua branch:** `git push origin minha-feature` +8. **Abra um Pull Request** + +### Padrões de Commit +- `feat:` para novas funcionalidades +- `fix:` para correções de bugs +- `docs:` para documentação +- `refactor:` para refatoração de código +- `test:` para testes + +## Suporte + +Para dúvidas ou problemas: +- 🐛 **Issues:** [GitHub Issues](https://github.com/lack0fcode/python-sga-LSL-Univesp/issues) +- 📖 **Documentação:** Este README e comentários no código --- -## Licença - -Este projeto está sob a licença MIT. Veja o arquivo `LICENSE` para mais detalhes. +**Instituto Lauro de Souza Lima - Bauru/SP** +*Sistema desenvolvido para otimizar o atendimento médico e melhorar a experiência de pacientes e profissionais.* diff --git a/READMEimplementation.md b/READMEimplementation.md new file mode 100644 index 0000000..847d008 --- /dev/null +++ b/READMEimplementation.md @@ -0,0 +1,293 @@ +# Implementação em Produção - SGA LSL Univesp + +## Pré-requisitos +- Servidor Linux com Docker e Docker Compose instalados +- PostgreSQL (via Docker) +- Porta 80 disponível para Nginx + +## Passo a Passo + +### 1. Clonagem e Configuração Inicial + +- Adicione os arquivos do sistema no diretório do servidor (Por ex. /home/user/python-sga-lsl-univesp) +- Entre no diretório (cd /caminho/do/sistema) + +```bash +# No diretório do projeto (onde está entrypoint.sh) +chmod +x entrypoint.sh +``` + +### 2. Configuração do Ambiente (.env) +Edite o arquivo `.env` com valores seguros para produção: + +```env +POSTGRES_USER=sga_prod_user +POSTGRES_PASSWORD=SuaSenhaSuperSeguraAqui123! +POSTGRES_DB=sga_prod_db +SECRET_KEY=SuaSecretKeySuperSeguraDePeloMenos50CaracteresAqui +DEBUG=0 +DATABASE_URL=postgres://sga_prod_user:SuaSenhaSuperSeguraAqui123!@db:5432/sga_prod_db + +# Superuser para produção +DJANGO_SUPERUSER_USERNAME=admin_cpf # CPF válido sem máscara +DJANGO_SUPERUSER_EMAIL=admin@seudominio.com +DJANGO_SUPERUSER_PASSWORD=SenhaSuperSeguraParaAdmin +``` + +**Recomendações de Segurança:** +- Use senhas fortes e únicas (mínimo 16 caracteres, com letras, números e símbolos) +- Mantenha o `DEBUG=0` em produção +- Use um `SECRET_KEY` gerado aleatoriamente (pode usar `openssl rand -hex 32`) +- Restrinja acesso ao arquivo `.env` (chmod 600) +- Considere usar variáveis de ambiente do sistema em vez de arquivo `.env` para maior segurança + +### 3. Build e Inicialização +```bash +# Build das imagens +docker-compose -f docker-compose.prod.yml build + +# Inicialização (cria banco, superuser, guichês) +docker-compose -f docker-compose.prod.yml up -d +``` + +### 4. Verificação +Acesse `http://seu-servidor` e faça login com o superuser configurado. + +Sugestão de fluxo inicial após o primeiro login: + +- **Login do administrador:** faça login usando as credenciais do superuser definidas no arquivo `.env` (campo `DJANGO_SUPERUSER_USERNAME` e `DJANGO_SUPERUSER_PASSWORD`). +- **Ajuste do perfil:** após o login, acesse a interface de administrador e edite suas próprias informações de contato/perfil para que constem corretamente no sistema. +- **Cadastrar funcionários:** comece a adicionar os funcionários no sistema pela interface de administração. Recomendamos chamar cada funcionário até o computador do administrador para que o próprio funcionário preencha ou confirme seus dados na tela — a interface é responsiva e também pode ser usada a partir de um smartphone caso prefira levar o dispositivo até o funcionário. +- **Informar acesso aos funcionários:** depois de criar a conta, informe ao funcionário a URL que ele deve digitar no navegador para acessar o sistema (ex.: `http://seu-servidor/` ou `http://seu-servidor/login`). + +- **IMPORTANTE:** Esses passos garantem que o cadastro seja feito com supervisão e que cada funcionário saiba imediatamente como acessar a aplicação. + +- **Preparar as tvs:** Cada tv tem uma url especifica (`http://seu-servidor/guiche/tv1` e `http://seu-servidor/profissional_saude/tv2`). +Instale os cabos hdmi em quaisquer computadores proximos (maximo 10m de distancia) ou alguma outra solução para que as tvs tenham acesso remoto a algum computador para exibirem a interface web (É possivel testar o navegador da própria TV caso seja Smart). +Esses endpoints nao precisam de login. + +### 5. Configuração para Iniciar Automaticamente no Boot + +#### Opção 1: Usando systemd (Recomendado) +Crie um serviço systemd: + +```bash +sudo nano /etc/systemd/system/sga.service +``` + +Conteúdo do arquivo: +```ini +[Unit] +Description=SGA LSL Univesp +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/caminho/para/python-sga-LSL-Univesp +ExecStart=/usr/bin/docker-compose -f docker-compose.prod.yml up -d +ExecStop=/usr/bin/docker-compose -f docker-compose.prod.yml down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target +``` + +Habilite e inicie: +```bash +sudo systemctl daemon-reload +sudo systemctl enable sga.service +sudo systemctl start sga.service +``` + +#### Opção 2: Usando cron (@reboot) +Adicione ao crontab do root: +```bash +sudo crontab -e +``` + +Adicione a linha: +```cron +@reboot cd /caminho/para/python-sga-LSL-Univesp && /usr/bin/docker-compose -f docker-compose.prod.yml up -d +``` + +### 6. Manutenção +- **Logs:** `docker-compose -f docker-compose.prod.yml logs -f` +- **Backup do banco:** Use volumes Docker ou scripts externos +- **Atualizações:** Pare o serviço, atualize o código, rebuild e reinicie + +### 7. Segurança Adicional + +#### Firewall e Restrição de Acesso +Para garantir que apenas dispositivos autorizados (ex.: rede do hospital) acessem a aplicação, configure restrições por IP no Nginx e no firewall do servidor. + +##### Opção 1: Restrição no Nginx (Recomendado para Controle Fino) +Edite o arquivo `nginx.conf` e adicione as linhas `allow` e `deny` no bloco `server`: + +```nginx +server { + listen 80; + server_name localhost; + + # Restringir acesso por IP (ajuste a sub-rede do hospital) + allow 192.168.1.0/24; # Permite sub-rede específica (ex.: rede interna do hospital) + deny all; # Bloqueia todos os outros IPs + + location / { + proxy_pass http://django; + # ... resto da configuração + } + # ... outras locations +} +``` + +- Substitua `192.168.1.0/24` pela sub-rede real da rede do hospital (ex.: `10.0.0.0/8` para redes privadas). +- Após editar, reinicie o Nginx: `docker-compose -f docker-compose.prod.yml restart nginx`. + +##### Opção 2: Firewall no Servidor (ufw - Ubuntu/Debian) +Use `ufw` para restringir acesso na porta do Nginx (padrão 80, ou mude para outra como 8080 no `docker-compose.prod.yml`). + +Instale e configure o ufw (se não estiver instalado): +```bash +sudo apt update +sudo apt install ufw +sudo ufw enable +``` + +Permita apenas a sub-rede específica: +```bash +sudo ufw allow from 192.168.1.0/24 to any port 80 # Permite sub-rede na porta 80 +sudo ufw deny 80 # Bloqueia todos os outros na porta 80 +``` + +- Ajuste a sub-rede e porta conforme necessário. +- Verifique o status: `sudo ufw status`. +- Isso adiciona uma camada extra de segurança além do Nginx. + +#### Outras Medidas de Segurança +- **HTTPS**: Configure SSL no Nginx para criptografar o tráfego (use Let's Encrypt ou certificado próprio). +- **Monitoramento**: Monitore logs do Nginx e Docker regularmente: `docker-compose -f docker-compose.prod.yml logs -f nginx`. +- **Atualizações**: Mantenha Docker, Nginx, Django e dependências atualizadas para patches de segurança. +- **Backup**: Faça backups regulares do banco de dados (use volumes Docker ou ferramentas como `pg_dump`). + +### 8. HTTPS / Certificados (Passo-a-passo) + +Para produção recomendamos usar HTTPS para criptografar o tráfego mesmo em redes internas. Abaixo estão 3 opções com passos concretos. + +Opção A — Self-signed (rápido, gera aviso no navegador) + +1. Gere certificados no servidor (ex.: dentro do diretório `nginx/ssl` no repo): + +```bash +mkdir -p nginx/ssl +openssl req -x509 -nodes -days 365 \ + -newkey rsa:2048 \ + -keyout nginx/ssl/nginx.key \ + -out nginx/ssl/nginx.crt \ + -subj "/CN=sga.hospital.local" +``` + +2. Monte os arquivos no serviço `nginx` do `docker-compose.prod.yml` (exemplo): + +```yaml +services: + nginx: + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - staticfiles:/app/staticfiles + ports: + - "443:443" + - "80:80" +``` + +3. Adicione um bloco `server` em `nginx.conf` para TLS (exemplo): + +```nginx +server { + listen 443 ssl; + server_name sga.hospital.local; + + ssl_certificate /etc/nginx/ssl/nginx.crt; + ssl_certificate_key /etc/nginx/ssl/nginx.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static/ { alias /app/staticfiles/; } + location /media/ { alias /app/media/; } +} + +server { + listen 80; + server_name sga.hospital.local; + return 301 https://$host$request_uri; +} +``` + +4. Reinicie o nginx container: + +```bash +docker-compose -f docker-compose.prod.yml up -d --no-deps --build nginx +# ou +docker-compose -f docker-compose.prod.yml restart nginx +``` + +Nota: navegadores mostrarão aviso de certificado não confiável. Use apenas para testes ou quando for aceitável confiar manualmente. + +Opção B — mkcert (boa para LAN gerenciada) + +- `mkcert` cria um CA local e pode gerar certificados confiáveis se você instalar a CA em todas as máquinas clientes (TI do hospital pode distribuir a CA via políticas): + +1. Instale `mkcert` (veja docs: https://github.com/FiloSottile/mkcert). +2. No host de administração gere os certificados: + +```bash +mkcert -install +mkcert sga.hospital.local 192.168.1.10 +# Isso gera algo como sga.hospital.local+IP.pem e key file +``` + +3. Monte as chaves em `nginx` como no passo A e reinicie. Instale a CA raiz `mkcert` nos navegadores/hosts clientes (TI). + +Opção C — Let's Encrypt (melhor se tiver domínio público) + +1. Se seu servidor tem um domínio público válido (`sga.hospital.example`) e aponta para o IP, use `certbot`: + +```bash +sudo apt update +sudo apt install certbot +sudo certbot certonly --standalone -d sga.hospital.example +``` + +2. Monte `/etc/letsencrypt/live/sga.hospital.example/fullchain.pem` e `privkey.pem` no container `nginx` (somente se terminar TLS no container). Use `certbot renew` em cron/systemd timer. + +Firewall e HSTS +- Após habilitar HTTPS, ajuste o `ufw` para permitir apenas a sub-rede hospitalar nas portas 443/80: + +```bash +sudo ufw allow from 192.168.1.0/24 to any port 443 +sudo ufw deny 443 +``` + +- Cuidado com HSTS: só ative `add_header Strict-Transport-Security` quando tiver certificados confiáveis e controle sobre os clientes (HSTS impede acesso HTTP por período configurado). + +### 9. Desinstalação completa + +- Caso tenha interesse em desinstalar completamente o sistema rode o comando + +```bash +docker-compose -f docker-compose.prod.yml down -v +``` +(Use com cuidado, remove também todos os dados do Banco de dados) + +--- diff --git a/administrador/templates/administrador/alterar_senha_funcionario.html b/administrador/templates/administrador/alterar_senha_funcionario.html new file mode 100644 index 0000000..8956851 --- /dev/null +++ b/administrador/templates/administrador/alterar_senha_funcionario.html @@ -0,0 +1,63 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+
+

Alterar Senha do Funcionário

+

Altere a senha do funcionário selecionado: {{ funcionario.first_name }} {{ funcionario.last_name }} (CPF: {{ funcionario.cpf }})

+
+ +
+
+ {% csrf_token %} + + {% if messages %} +
+ {% for message in messages %} +
+
+
+ {% if message.tags == 'success' %} + + {% elif message.tags == 'error' %} + + {% else %} + + {% endif %} +
+
+

{{ message }}

+
+
+
+ {% endfor %} +
+ {% endif %} + +
+
+ + {{ form.new_password1 }} + {% if form.errors.new_password1 %} +

{{ form.errors.new_password1.0 }}

+ {% endif %} +
+ +
+ + {{ form.new_password2 }} + {% if form.errors.new_password2 %} +

{{ form.errors.new_password2.0 }}

+ {% endif %} +
+
+ +
+ Cancelar + +
+
+
+
+{% endblock %} diff --git a/administrador/templates/administrador/cadastrar_funcionario.html b/administrador/templates/administrador/cadastrar_funcionario.html index d407012..e86fae7 100644 --- a/administrador/templates/administrador/cadastrar_funcionario.html +++ b/administrador/templates/administrador/cadastrar_funcionario.html @@ -1,20 +1,213 @@ {% extends 'base.html' %} +{% load static %} {% block content %} -
-

Cadastrar Funcionário

-

Preencha o formulário abaixo para cadastrar um novo funcionário:

- - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} +
+ +
+
+
+

Cadastrar Funcionário

+

Adicione um novo funcionário ao sistema SGA

+
+ +
+
-
+ +
+ {% csrf_token %} - {{ form.as_p }} - + + + {% if messages %} +
+ {% for message in messages %} +
+
+
+ {% if message.tags == 'success' %} + + {% elif message.tags == 'error' %} + + {% else %} + + {% endif %} +
+
+

{{ message }}

+
+
+
+ {% endfor %} +
+ {% endif %} + + +
+ +
+ + {{ form.cpf }} + {% if form.cpf.errors %} +

{{ form.cpf.errors.0 }}

+ {% endif %} + {% if form.cpf.help_text %} +

{{ form.cpf.help_text }}

+ {% endif %} +
+ + +
+ + {{ form.first_name }} + {% if form.first_name.errors %} +

{{ form.first_name.errors.0 }}

+ {% endif %} +
+ + +
+ + {{ form.last_name }} + {% if form.last_name.errors %} +

{{ form.last_name.errors.0 }}

+ {% endif %} +
+ + +
+ + {{ form.email }} + {% if form.email.errors %} +

{{ form.email.errors.0 }}

+ {% endif %} + {% if form.email.help_text %} +

{{ form.email.help_text }}

+ {% endif %} +
+ + + {% if form.password1 %} +
+ + {{ form.password1 }} + {% if form.password1.errors %} +

{{ form.password1.errors.0 }}

+ {% endif %} +
+ +
+ + {{ form.password2 }} + {% if form.password2.errors %} +

{{ form.password2.errors.0 }}

+ {% endif %} +
+ {% endif %} + + +
+ + {{ form.funcao }} + {% if form.funcao.errors %} +

{{ form.funcao.errors.0 }}

+ {% endif %} + {% if form.funcao.help_text %} +

{{ form.funcao.help_text }}

+ {% endif %} +
+ + +
+ + {{ form.data_admissao }} + {% if form.data_admissao.errors %} +

{{ form.data_admissao.errors.0 }}

+ {% endif %} + {% if form.data_admissao.help_text %} +

{{ form.data_admissao.help_text }}

+ {% endif %} +
+ + +
+ + {{ form.sala }} + {% if form.sala.errors %} +

{{ form.sala.errors.0 }}

+ {% endif %} + {% if form.sala.help_text %} +

{{ form.sala.help_text }}

+ {% endif %} +
+
+ + +
+ + Cancelar + + +
+ + +
+
+
+ +
+
+

Dicas para Cadastro

+
+
    +
  • CPF deve conter apenas números (11 dígitos)
  • +
  • Email é opcional, mas recomendado para recuperação de senha
  • +
  • A função determina as permissões do usuário no sistema
  • +
  • Data de admissão é usada para controle interno
  • +
  • Sala é opcional e pode ser definida posteriormente
  • +
+
+
+
+
+
+ +{% block atividade_script %}{% endblock %} + {% endblock %} \ No newline at end of file diff --git a/administrador/templates/administrador/editar_dados_funcionario.html b/administrador/templates/administrador/editar_dados_funcionario.html new file mode 100644 index 0000000..a46a502 --- /dev/null +++ b/administrador/templates/administrador/editar_dados_funcionario.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+
+

Editar Dados do Funcionário

+

Editando: {{ funcionario.first_name }} {{ funcionario.last_name }} — CPF: {{ funcionario.cpf }}

+
+ +
+
+ {% csrf_token %} + + {% if messages %} +
+ {% for message in messages %} +
+
+
+ {% if message.tags == 'success' %} + + {% elif message.tags == 'error' %} + + {% else %} + + {% endif %} +
+
+

{{ message }}

+
+
+
+ {% endfor %} +
+ {% endif %} + +
+ {{ form.as_p }} +
+ +
+ Cancelar + +
+
+
+
+{% endblock %} diff --git a/administrador/templates/administrador/listar_funcionarios.html b/administrador/templates/administrador/listar_funcionarios.html index 1f3cb51..55733b6 100644 --- a/administrador/templates/administrador/listar_funcionarios.html +++ b/administrador/templates/administrador/listar_funcionarios.html @@ -1,36 +1,359 @@ {% extends 'base.html' %} +{% load static %} {% block content %} -
-

Listar Funcionários

-

Lista de todos os funcionários cadastrados:

- - {% if funcionarios %} - - - - - - - - - - +
+ +
+
+
+

Funcionários Cadastrados

+

Gerencie todos os funcionários do sistema

+
+ +
+
+ + +
+
+
+
+ +
+
+

Total de Funcionários

+

{{ total_funcionarios }}

+
+
+
+
+
+
+ +
+
+

Online Ativos

+

{{ funcionarios_online_ativos }}

+
+
+
+
+
+
+ +
+
+

Online Inativos

+

{{ funcionarios_online_inativos }}

+
+
+
+
+
+
+ +
+
+

Offline

+

{{ funcionarios_offline }}

+
+
+
+
+
+
+ +
+
+

Funções

+

{{ funcoes_distintas }}

+
+
+ + + +
+
+ + + {% if funcionarios %} +
+ +
+
+

Lista de Funcionários

+ + +
+ + +
+
+
+ + +
CPFNomeFunçãoAções
+ + + + + + + + + + + {% for funcionario in funcionarios %} + + + + + + + + {% endfor %} + +
+ CPF + + Nome Completo + + Função + + Status + + Ações +
+ {{ funcionario.cpf|slice:":3" }}.{{ funcionario.cpf|slice:"3:6" }}.{{ funcionario.cpf|slice:"6:9" }}-{{ funcionario.cpf|slice:"9:" }} + +
+
+
+ + {{ funcionario.first_name|first }}{{ funcionario.last_name|first }} + +
+
+
+ {% if funcionario.id in usuarios_online_ativos_ids %} + + {% elif funcionario.id in usuarios_online_inativos_ids %} + + {% else %} + + {% endif %} +
+
+ {{ funcionario.first_name }} {{ funcionario.last_name }} +
+
+ {{ funcionario.email|default:"Sem email" }} +
+
+
+
+
+ + {{ funcionario.get_funcao_display|default:funcionario.funcao|title }} + + + + {% if funcionario.is_active %}Ativo{% else %}Inativo{% endif %} + + + +
+
+
+ + +
+
{% for funcionario in funcionarios %} - - {{ funcionario.cpf }} - {{ funcionario.first_name }} {{ funcionario.last_name }} - {{ funcionario.funcao }} - - Editar - Excluir - - +
+
+
+
+
+ + {{ funcionario.first_name|first }}{{ funcionario.last_name|first }} + +
+
+
+ {% if funcionario.id in usuarios_online_ativos_ids %} + + {% elif funcionario.id in usuarios_online_inativos_ids %} + + {% else %} + + {% endif %} +
+
+ {{ funcionario.first_name }} {{ funcionario.last_name }} +
+
+ CPF: {{ funcionario.cpf|slice:":3" }}.{{ funcionario.cpf|slice:"3:6" }}.{{ funcionario.cpf|slice:"6:9" }}-{{ funcionario.cpf|slice:"9:" }} +
+
+ + {{ funcionario.get_funcao_display|default:funcionario.funcao|title }} + + + {% if funcionario.is_active %}Ativo{% else %}Inativo{% endif %} + +
+
+
+
+ +
+
{% endfor %} - - - {% else %} -

Nenhum funcionário cadastrado.

- {% endif %} +
+
+ + {% else %} + +
+
+ +
+

Nenhum funcionário cadastrado

+

Comece cadastrando o primeiro funcionário do sistema.

+ + + Cadastrar Primeiro Funcionário + +
+ {% endif %} + + + + + + {% endblock %} \ No newline at end of file diff --git a/administrador/urls.py b/administrador/urls.py index c48766f..93018ff 100644 --- a/administrador/urls.py +++ b/administrador/urls.py @@ -17,9 +17,24 @@ views.editar_funcionario, name="editar_funcionario", ), + path( + "alterar_senha_funcionario//", + views.alterar_senha_funcionario, + name="alterar_senha_funcionario", + ), + path( + "editar_dados_funcionario//", + views.editar_dados_funcionario, + name="editar_dados_funcionario", + ), path( "excluir_funcionario//", views.excluir_funcionario, name="excluir_funcionario", ), + path( + "registrar-atividade/", + views.registrar_atividade, + name="registrar_atividade", + ), ] diff --git a/administrador/views.py b/administrador/views.py index 9239bf9..2e6191e 100644 --- a/administrador/views.py +++ b/administrador/views.py @@ -2,10 +2,17 @@ from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils import timezone +from django.contrib.sessions.models import Session +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.decorators import login_required from core.decorators import admin_required -from core.forms import CadastrarFuncionarioForm -from core.models import CustomUser # Importe o modelo CustomUser +from core.forms import CadastrarFuncionarioForm, EditarFuncionarioForm +from core.models import CustomUser, RegistroDeAcesso # Importe o modelo CustomUser +from django.contrib.auth.forms import SetPasswordForm @admin_required @@ -27,13 +34,116 @@ def cadastrar_funcionario(request): return render(request, "administrador/cadastrar_funcionario.html", {"form": form}) +@login_required +@csrf_exempt +def registrar_atividade(request): + """View para registrar atividade do usuário em tempo real""" + if request.method == "POST": + # Registrar atividade no banco de dados + RegistroDeAcesso.objects.create( + usuario=request.user, tipo_de_acesso="atividade", data_hora=timezone.now() + ) + return JsonResponse({"status": "ok"}) + return JsonResponse({"status": "error"}, status=400) + + @admin_required def listar_funcionarios(request): - funcionarios = CustomUser.objects.all() # Obtém todos os usuários + # Obter parâmetro de filtro da função + funcao_filtro = request.GET.get("funcao", "") + + # Filtrar funcionários baseado na função selecionada + if funcao_filtro: + funcionarios = CustomUser.objects.filter(funcao=funcao_filtro) + else: + funcionarios = CustomUser.objects.all() # Obtém todos os usuários + + # Calcular estatísticas + total_funcionarios = funcionarios.count() + funcionarios_ativos = funcionarios.filter(is_active=True).count() + + # Calcular status dos funcionários com três categorias + usuarios_online_ativos_ids = [] # Verde: online e ativo (últimos 2 min) + usuarios_online_inativos_ids = [] # Amarelo: online mas inativo (2-5 min) + usuarios_offline_ids = [] # Vermelho: offline (mais de 5 min) + + agora = timezone.now() + cinco_minutos_atras = agora - timezone.timedelta(minutes=5) + dois_minutos_atras = agora - timezone.timedelta(minutes=2) + + # Obter sessões ativas (não expiradas) + sessoes_ativas = Session.objects.filter(expire_date__gt=agora) + usuarios_com_sessao_ativa = set() + + for sessao in sessoes_ativas: + try: + dados_sessao = sessao.get_decoded() + user_id = dados_sessao.get("_auth_user_id") + if user_id: + usuarios_com_sessao_ativa.add(int(user_id)) + except: + continue + + # Usuários com atividade recente (nos últimos 5 minutos) + usuarios_com_atividade_recente = set( + RegistroDeAcesso.objects.filter(data_hora__gte=cinco_minutos_atras) + .values_list("usuario_id", flat=True) + .distinct() + ) + + # Usuários realmente online: apenas aqueles com atividade nos últimos 10 minutos + # (removendo a dependência de sessões ativas que podem durar semanas) + usuarios_online_potenciais = usuarios_com_atividade_recente + + # Para cada funcionário, determinar seu status + for usuario in funcionarios: + if usuario.id in usuarios_online_potenciais: + # Verificar se teve atividade muito recente (nos últimos 2 minutos) + atividade_muito_recente = RegistroDeAcesso.objects.filter( + usuario=usuario, data_hora__gte=dois_minutos_atras + ).exists() + + if atividade_muito_recente: + usuarios_online_ativos_ids.append(usuario.id) + else: + usuarios_online_inativos_ids.append(usuario.id) + else: + usuarios_offline_ids.append(usuario.id) + + # Manter compatibilidade com código existente + usuarios_online_ids = usuarios_online_ativos_ids + usuarios_online_inativos_ids + # Atualizar estatísticas + funcionarios_online_ativos = len(usuarios_online_ativos_ids) + funcionarios_online_inativos = len(usuarios_online_inativos_ids) + funcionarios_offline = len(usuarios_offline_ids) + + # Manter compatibilidade - total online = ativos + inativos + funcionarios_online = funcionarios_online_ativos + funcionarios_online_inativos + + funcoes_distintas = funcionarios.values("funcao").distinct().count() + + # Obter todas as funções disponíveis para o filtro + funcoes_disponiveis = CustomUser.FUNCAO_CHOICES + return render( request, "administrador/listar_funcionarios.html", - {"funcionarios": funcionarios}, + { + "funcionarios": funcionarios, + "total_funcionarios": total_funcionarios, + "funcionarios_ativos": funcionarios_ativos, + "funcionarios_online": funcionarios_online, + "funcionarios_online_ativos": funcionarios_online_ativos, + "funcionarios_online_inativos": funcionarios_online_inativos, + "funcionarios_offline": funcionarios_offline, + "usuarios_online_ids": usuarios_online_ids, + "usuarios_online_ativos_ids": usuarios_online_ativos_ids, + "usuarios_online_inativos_ids": usuarios_online_inativos_ids, + "usuarios_offline_ids": usuarios_offline_ids, + "funcoes_distintas": funcoes_distintas, + "funcoes_disponiveis": funcoes_disponiveis, + "funcao_filtro": funcao_filtro, + }, ) @@ -61,3 +171,45 @@ def excluir_funcionario(request, pk): funcionario.delete() messages.success(request, "Funcionário excluído com sucesso!") return redirect(reverse("administrador:listar_funcionarios")) + + +@admin_required +def alterar_senha_funcionario(request, pk): + funcionario = get_object_or_404(CustomUser, pk=pk) + if request.method == "POST": + form = SetPasswordForm(funcionario, request.POST) + if form.is_valid(): + form.save() + messages.success(request, "Senha alterada com sucesso para o funcionário.") + return redirect(reverse("administrador:listar_funcionarios")) + else: + messages.error(request, "Erro ao alterar a senha. Verifique os dados.") + else: + form = SetPasswordForm(funcionario) + + return render( + request, + "administrador/alterar_senha_funcionario.html", + {"form": form, "funcionario": funcionario}, + ) + + +@admin_required +def editar_dados_funcionario(request, pk): + funcionario = get_object_or_404(CustomUser, pk=pk) + if request.method == "POST": + form = EditarFuncionarioForm(request.POST, instance=funcionario) + if form.is_valid(): + form.save() + messages.success(request, "Dados do funcionário atualizados com sucesso!") + return redirect(reverse("administrador:listar_funcionarios")) + else: + messages.error(request, "Erro ao atualizar os dados. Verifique os campos.") + else: + form = EditarFuncionarioForm(instance=funcionario) + + return render( + request, + "administrador/editar_dados_funcionario.html", + {"form": form, "funcionario": funcionario}, + ) diff --git a/bandit_Rodar.py b/bandit_Rodar.py new file mode 100644 index 0000000..0fc682d --- /dev/null +++ b/bandit_Rodar.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Script para executar Bandit em todos os apps Django separadamente +e combinar os resultados em um único relatório. +""" + +import os +import json +import subprocess +from pathlib import Path +from typing import Any, Dict, List + + +def run_bandit_on_app(app_name): + """Executa Bandit em um app específico.""" + print(f"Executando Bandit no app: {app_name}") + + cmd = [ + "bandit", + "--ini", + ".bandit", + "-r", + app_name, + "-f", + "json", + "-o", + f"bandit_{app_name}_report.json", + ] + + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=600 + ) # 10 minutos timeout + if ( + result.returncode == 0 or result.returncode == 1 + ): # 0 = sem issues, 1 = com issues + print(f"✓ {app_name} concluído") + return True + else: + print(f"✗ Erro no {app_name}: {result.stderr}") + return False + except subprocess.TimeoutExpired: + print(f"✗ Timeout no {app_name} (10 minutos)") + return False + except Exception as e: + print(f"✗ Erro inesperado no {app_name}: {e}") + return False + + +def combine_reports() -> None: + """Combina todos os relatórios individuais em um único.""" + combined: Dict[str, Any] = { + "generated_at": "2025-11-03T16:08:12Z", + "metrics": {"_totals": {"loc": 0, "nosec": 0, "skipped_tests": 0}}, + "results": [], + "errors": [], + } + + apps = [ + "core", + "administrador", + "guiche", + "recepcionista", + "profissional_saude", + "api", + "sga", + ] + + for app in apps: + report_file = f"bandit_{app}_report.json" + if os.path.exists(report_file): + try: + with open(report_file, "r", encoding="utf-8") as f: + data = json.load(f) + + # Combinar métricas + if "_totals" in data.get("metrics", {}): + totals: Dict[str, int] = data["metrics"]["_totals"] + for key, value in totals.items(): + if key in combined["metrics"]["_totals"]: + combined["metrics"]["_totals"][key] += value + else: + combined["metrics"]["_totals"][key] = value + + # Combinar resultados + results: List[Dict[str, Any]] = data.get("results", []) + errors: List[str] = data.get("errors", []) + combined["results"].extend(results) + combined["errors"].extend(errors) + + # Combinar métricas por arquivo + for file_key, file_metrics in data.get("metrics", {}).items(): + if file_key != "_totals": + combined["metrics"][file_key] = file_metrics + + except Exception as e: + print(f"Erro ao processar {report_file}: {e}") + + # Salvar relatório combinado + with open("bandit_full_report.json", "w", encoding="utf-8") as f: + json.dump(combined, f, indent=2, ensure_ascii=False) + + print(f"Relatório combinado salvo em bandit_full_report.json") + print(f"Total de vulnerabilidades encontradas: {len(combined['results'])}") + + +if __name__ == "__main__": + print("Iniciando análise de segurança com Bandit...") + + apps = [ + "core", + "administrador", + "guiche", + "recepcionista", + "profissional_saude", + "api", + "sga", + ] + + success_count = 0 + for app in apps: + if run_bandit_on_app(app): + success_count += 1 + + print(f"\nConcluído: {success_count}/{len(apps)} apps processados com sucesso") + + if success_count == 0: + print( + "ERRO: Nenhum app foi processado com sucesso. Abortando combinação de relatórios." + ) + exit(1) + + if success_count < len(apps): + print( + f"AVISO: Apenas {success_count} de {len(apps)} apps foram processados. Combinando relatórios disponíveis." + ) + + combine_reports() + print("✅ Análise de segurança Bandit concluída com sucesso!") + exit(0) diff --git a/bandit_analisar.py b/bandit_analisar.py new file mode 100644 index 0000000..572c882 --- /dev/null +++ b/bandit_analisar.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Bandit Report Analyzer for GitHub Actions + +Combines and analyzes multiple Bandit JSON reports +Generates HTML report for better visualization +""" + +import json +import os +import sys +from pathlib import Path +from datetime import datetime + + +def generate_html_report( + all_results, severity_counts, confidence_counts, total_issues, total_files +): + """Generate HTML report from Bandit analysis results""" + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC") + + # Determine status classes + if severity_counts["HIGH"] > 0: + status_class = "status-high" + overall_status_class = "status-high" + overall_status = "CRITICAL" + elif severity_counts["MEDIUM"] > 0: + status_class = "status-medium" + overall_status_class = "status-medium" + overall_status = "WARNING" + elif severity_counts["LOW"] > 0: + status_class = "status-low" + overall_status_class = "status-low" + overall_status = "REVIEW" + else: + status_class = "status-good" + overall_status_class = "status-good" + overall_status = "SECURE" + + # Generate issues table + if all_results: + issues_html = """ +
+ + + + + + + + + + + + """ + + for issue in sorted( + all_results, + key=lambda x: (x["issue_severity"], x["issue_confidence"]), + reverse=True, + ): + severity_class = f"severity-{issue['issue_severity'].lower()}" + issues_html += f""" + + + + + + + + """ + + issues_html += """ + +
SeverityConfidenceIssueFileLineDescription
{issue['issue_severity']}{issue['issue_confidence']}{issue['test_id']}{issue['filename']}{issue['line_number']}{issue['issue_text']}
+
""" + else: + issues_html = """ +
+
+

No Security Issues Found!

+

All scanned files passed the security analysis.

+
+
""" + + # Simple HTML template + html_template = ( + """ + + + + + Bandit Security Analysis Report + + + +
+

Bandit Security Analysis Report

+

Generated on: """ + + timestamp + + """

+
+
+
+

Files Scanned

+
""" + + str(total_files) + + """
+
+
+

Total Issues

+
""" + + str(total_issues) + + """
+
+
+

High Severity

+
""" + + str(severity_counts["HIGH"]) + + """
+
+
+

Medium Severity

+
""" + + str(severity_counts["MEDIUM"]) + + """
+
+
+

Low Severity

+
""" + + str(severity_counts["LOW"]) + + """
+
+
+

Status

+
""" + + overall_status + + """
+
+
""" + + issues_html + + """ + +""" + ) + + # Write HTML report + with open("bandit_report.html", "w", encoding="utf-8") as f: + f.write(html_template) + + print(f"\n HTML Report generated: bandit_report.html") + + +def analyze_bandit_reports(): + """Analyze Bandit JSON reports and provide summary""" + + reports = [ + "bandit_core_report.json", + "bandit_administrador_report.json", + "bandit_guiche_report.json", + "bandit_recepcionista_report.json", + "bandit_profissional_saude_report.json", + "bandit_api_report.json", + "bandit_sga_report.json", + ] + + total_issues = 0 + total_files = 0 + severity_counts = {"LOW": 0, "MEDIUM": 0, "HIGH": 0} + confidence_counts = {"LOW": 0, "MEDIUM": 0, "HIGH": 0} + all_results = [] + + print(" BANDIT SECURITY ANALYSIS - GITHUB ACTIONS") + print("=" * 50) + + for report_file in reports: + if Path(report_file).exists(): + try: + with open(report_file, "r", encoding="utf-8") as f: + data = json.load(f) + + results = data.get("results", []) + all_results.extend(results) + + # Count total files from metrics (exclude _totals) + metrics = data.get("metrics", {}) + files_in_report = len([k for k in metrics.keys() if k != "_totals"]) + total_files += files_in_report + + print(f"\n {report_file}:") + print(f" Files scanned: {files_in_report}") + print(f" Issues found: {len(results)}") + + # Count issues in this report + for issue in results: + sev = issue["issue_severity"] + conf = issue["issue_confidence"] + severity_counts[sev] += 1 + confidence_counts[conf] += 1 + total_issues += 1 + + except Exception as e: + print(f" Error reading {report_file}: {e}") + else: + print(f"\n {report_file}: Not found") + + print(f"\n SUMMARY:") + print(f" Total files scanned: {total_files}") + print(f" Total issues: {total_issues}") + print(f" HIGH severity: {severity_counts['HIGH']}") + print(f" MEDIUM severity: {severity_counts['MEDIUM']}") + print(f" LOW severity: {severity_counts['LOW']}") + + # Generate HTML report + generate_html_report( + all_results, severity_counts, confidence_counts, total_issues, total_files + ) + + # Determine overall status + if severity_counts["HIGH"] > 0 or severity_counts["MEDIUM"] > 0: + print(" ❌ Status: SECURITY ISSUES FOUND - REVIEW REQUIRED") + return 1 + elif severity_counts["LOW"] > 0: + print(" ⚠️ Status: ONLY LOW SEVERITY ISSUES FOUND") + return 0 + else: + print(" ✅ Status: NO SECURITY ISSUES FOUND") + return 0 + + +if __name__ == "__main__": + sys.exit(analyze_bandit_reports()) diff --git a/core/forms.py b/core/forms.py index cdfbe9a..3af0a00 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,6 +3,7 @@ from django.contrib.auth import authenticate from django.contrib.auth.forms import UserCreationForm from django.utils import timezone +from typing import Dict, Optional import re from django.core.exceptions import ValidationError @@ -62,12 +63,8 @@ def clean_nome_completo(self): def clean_cartao_sus(self): cartao_sus = self.cleaned_data.get("cartao_sus") - if cartao_sus: - # Verifica se já existe um paciente com este cartão SUS - if Paciente.objects.filter(cartao_sus=cartao_sus).exists(): - raise forms.ValidationError( - "Já existe um paciente cadastrado com este cartão SUS." - ) + # Removida validação de unicidade para permitir que o mesmo cartão SUS + # seja usado em múltiplos atendimentos (pacientes retornando) return cartao_sus class Meta: @@ -82,7 +79,7 @@ class Meta: "telefone_celular", ] - help_texts = { + help_texts: Dict[str, Optional[str]] = { "telefone_celular": None, } @@ -147,12 +144,25 @@ def validate_cpf(value): help_text="Selecione a função do funcionário.", ) + def clean_cpf(self): + cpf = self.cleaned_data.get("cpf") + if CustomUser.objects.filter(username=cpf).exists(): + raise forms.ValidationError("Este CPF já está cadastrado.") + return cpf + class Meta(UserCreationForm.Meta): model = CustomUser - fields = ("cpf", "username", "first_name", "last_name", "email", "funcao") - help_texts = { - "username": None, - } + # include password fields so admin can set initial password + fields = ( + "cpf", + "first_name", + "last_name", + "email", + "funcao", + "password1", + "password2", + ) + help_texts: Dict[str, Optional[str]] = {} def clean_first_name(self): first_name = self.cleaned_data.get("first_name") @@ -161,9 +171,13 @@ def clean_first_name(self): return first_name def save(self, commit=True): + # Use UserCreationForm handling for password, but ensure username becomes CPF user = super().save(commit=False) - user = super(UserCreationForm, self).save(commit=False) - user.username = self.cleaned_data["cpf"] # Define o username como o CPF + user.username = self.cleaned_data.get("cpf") + # If password1 provided, set it explicitly (UserCreationForm normally handles this in its save) + pwd = self.cleaned_data.get("password1") + if pwd: + user.set_password(pwd) if commit: user.save() return user @@ -173,13 +187,36 @@ class LoginForm(forms.Form): cpf = forms.CharField( label="CPF", max_length=14, - widget=forms.TextInput(attrs={"placeholder": "Digite seu CPF"}), + widget=forms.TextInput( + attrs={ + "placeholder": "Digite seu CPF", + "autocomplete": "username", + "autocorrect": "off", + "autocapitalize": "off", + "spellcheck": "false", + } + ), ) password = forms.CharField( label="Senha", - widget=forms.PasswordInput(attrs={"placeholder": "Digite sua senha"}), + widget=forms.PasswordInput( + attrs={ + "placeholder": "Digite sua senha", + "autocomplete": "current-password", + "autocorrect": "off", + "autocapitalize": "off", + "spellcheck": "false", + } + ), ) + def clean_cpf(self): + cpf = self.cleaned_data.get("cpf") + if cpf: + # Remove pontos, traços e espaços do CPF + cpf = "".join(filter(str.isdigit, cpf)) + return cpf + def clean(self): cleaned_data = super().clean() cpf = cleaned_data.get("cpf") @@ -188,7 +225,9 @@ def clean(self): if cpf and password: try: user = CustomUser.objects.get(cpf=cpf) + print(f"DEBUG clean: user found {user.username}") except CustomUser.DoesNotExist: + print(f"DEBUG clean: user not found for cpf {cpf}") user = None if user: @@ -226,3 +265,23 @@ def clean(self): raise ValidationError("CPF ou senha incorretos.") return cleaned_data + + +class EditarFuncionarioForm(forms.ModelForm): + class Meta: + model = CustomUser + # Excluir CPF e campos de senha — apenas editar dados administrativos + fields = ( + "first_name", + "last_name", + "email", + "funcao", + "data_admissao", + "sala", + "is_active", + ) + widgets = { + "data_admissao": forms.DateInput(attrs={"type": "date"}), + "sala": forms.NumberInput(), + "email": forms.EmailInput(), + } diff --git a/core/signals.py b/core/signals.py index 820ce0e..117577c 100644 --- a/core/signals.py +++ b/core/signals.py @@ -1,4 +1,5 @@ import datetime +import logging import random from django.contrib.auth.signals import user_logged_in @@ -8,6 +9,8 @@ from core.models import RegistroDeAcesso +logger = logging.getLogger(__name__) + from .models import CustomUser @@ -29,7 +32,7 @@ def gerar_senha_paciente(sender, instance, **kwargs): @receiver(user_logged_in) def log_user_login(sender, request, user, **kwargs): # Registra o login do usuário - print( + logger.info( f"Usuário {user.username} (CPF: {user.cpf}) logou-se em {request.META.get('REMOTE_ADDR')}" ) # Aqui você pode salvar essas informações em um modelo específico, como 'RegistroDeAcesso' diff --git a/core/tests.py b/core/tests.py deleted file mode 100644 index abf095b..0000000 --- a/core/tests.py +++ /dev/null @@ -1,1722 +0,0 @@ -from django.test import Client, TestCase -from django.urls import reverse -from django.http import HttpResponse -from django.utils import timezone -from unittest.mock import patch - -from .forms import CadastrarFuncionarioForm, CadastrarPacienteForm, LoginForm -from .models import ( - Atendimento, - Chamada, - ChamadaProfissional, - CustomUser, - Guiche, - Paciente, - RegistroDeAcesso, -) - - -class CustomUserModelTest(TestCase): - """Testes abrangentes para o modelo CustomUser.""" - - def setUp(self): - self.user_data = { - "cpf": "12345678901", - "username": "12345678901", - "password": "testpass123", - "first_name": "João", - "last_name": "Silva", - "email": "joao.silva@test.com", - "funcao": "administrador", - } - - def test_create_user_valid(self): - """Testa criação de usuário válido.""" - user = CustomUser.objects.create_user(**self.user_data) - self.assertEqual(user.cpf, "12345678901") - self.assertEqual(user.first_name, "João") - self.assertEqual(user.funcao, "administrador") - self.assertTrue(user.check_password("testpass123")) - - def test_username_field_is_cpf(self): - """Testa que USERNAME_FIELD é cpf.""" - self.assertEqual(CustomUser.USERNAME_FIELD, "cpf") - - def test_str_method(self): - """Testa método __str__.""" - user = CustomUser.objects.create_user(**self.user_data) - self.assertEqual(str(user), "João Silva") - - def test_cpf_unique_constraint(self): - """Testa constraint de unicidade do CPF.""" - CustomUser.objects.create_user(**self.user_data) - with self.assertRaises(Exception): # IntegrityError - CustomUser.objects.create_user(**self.user_data) - - def test_cpf_max_length(self): - """Testa limite de tamanho do CPF.""" - # Django não valida max_length automaticamente no banco, apenas no form - # Vamos testar que o campo aceita até o limite - data = self.user_data.copy() - data["cpf"] = "1" * 14 # Exatamente max_length=14 - data["username"] = data["cpf"] - user = CustomUser.objects.create_user(**data) - self.assertEqual(len(user.cpf), 14) - - def test_funcao_choices_valid(self): - """Testa valores válidos para função.""" - for funcao in [ - "administrador", - "recepcionista", - "guiche", - "profissional_saude", - ]: - data = self.user_data.copy() - data["cpf"] = f"1111111111{funcao[0]}" # CPF único - data["username"] = data["cpf"] - data["funcao"] = funcao - user = CustomUser.objects.create_user(**data) - self.assertEqual(user.funcao, funcao) - - def test_funcao_choices_invalid(self): - """Testa valor inválido para função.""" - # Django não valida choices automaticamente no banco - # Vamos testar que o valor é salvo mesmo sendo inválido - data = self.user_data.copy() - data["cpf"] = "11111111111" - data["username"] = data["cpf"] - data["funcao"] = "funcao_invalida" - user = CustomUser.objects.create_user(**data) - self.assertEqual(user.funcao, "funcao_invalida") # Django permite - - def test_sala_field_optional(self): - """Testa que campo sala é opcional.""" - data = self.user_data.copy() - data["cpf"] = "22222222222" - data["username"] = data["cpf"] - user = CustomUser.objects.create_user(**data) - self.assertIsNone(user.sala) - - def test_sala_field_with_value(self): - """Testa campo sala com valor.""" - data = self.user_data.copy() - data["cpf"] = "33333333333" - data["username"] = data["cpf"] - data["sala"] = 101 - user = CustomUser.objects.create_user(**data) - self.assertEqual(user.sala, 101) - - def test_data_admissao_optional(self): - """Testa que data_admissao é opcional.""" - user = CustomUser.objects.create_user(**self.user_data) - self.assertIsNone(user.data_admissao) - - def test_required_fields(self): - """Testa campos obrigatórios.""" - # CPF não é obrigatório no modelo (USERNAME_FIELD), mas vamos testar username - data = self.user_data.copy() - data.pop("username") # Remove username que é obrigatório - with self.assertRaises(Exception): - CustomUser.objects.create_user(**data) - - def test_email_optional(self): - """Testa que email é opcional.""" - data = self.user_data.copy() - data["cpf"] = "44444444444" - data["username"] = data["cpf"] - data.pop("email") - user = CustomUser.objects.create_user(**data) - self.assertEqual(user.email, "") - - def test_superuser_creation(self): - """Testa criação de superusuário.""" - data = self.user_data.copy() - data["cpf"] = "55555555555" - data["username"] = data["cpf"] - user = CustomUser.objects.create_superuser(**data) - self.assertTrue(user.is_superuser) - self.assertTrue(user.is_staff) - - -class PacienteModelTest(TestCase): - """Testes abrangentes para o modelo Paciente.""" - - def setUp(self): - self.profissional = CustomUser.objects.create_user( - cpf="11122233344", - username="11122233344", - password="testpass", - funcao="profissional_saude", - first_name="Dr.", - last_name="Teste", - ) - self.paciente_data = { - "nome_completo": "Maria Oliveira Santos", - "tipo_senha": "G", - "senha": "G001", - "cartao_sus": "123456789012345", - "profissional_saude": self.profissional, - "telefone_celular": "(11) 99999-9999", - "observacoes": "Paciente de teste", - } - - def test_create_paciente_valid(self): - """Testa criação de paciente válido.""" - paciente = Paciente.objects.create(**self.paciente_data) - self.assertEqual(paciente.nome_completo, "Maria Oliveira Santos") - self.assertEqual(paciente.tipo_senha, "G") - self.assertEqual(paciente.senha, "G001") - self.assertFalse(paciente.atendido) # default False - - def test_str_method(self): - """Testa método __str__.""" - paciente = Paciente.objects.create(**self.paciente_data) - str_repr = str(paciente) - self.assertIn("Maria Oliveira Santos", str_repr) - self.assertIn("G001", str_repr) - - def test_campos_opcionais(self): - """Testa campos opcionais.""" - data_minima = { - "nome_completo": "João Silva", - } - paciente = Paciente.objects.create(**data_minima) - self.assertIsNone(paciente.tipo_senha) - self.assertIsNone(paciente.senha) - self.assertIsNone(paciente.cartao_sus) - self.assertIsNone(paciente.profissional_saude) - self.assertIsNone(paciente.telefone_celular) - self.assertIsNone(paciente.observacoes) - self.assertFalse(paciente.atendido) - - def test_tipo_senha_choices_valid(self): - """Testa valores válidos para tipo_senha.""" - tipos_validos = ["E", "C", "P", "G", "D", "A", "NH", "H", "U"] - for tipo in tipos_validos: - data = self.paciente_data.copy() - data["tipo_senha"] = tipo - data["senha"] = f"{tipo}001" - paciente = Paciente.objects.create(**data) - self.assertEqual(paciente.tipo_senha, tipo) - - def test_tipo_senha_choices_invalid(self): - """Testa valor inválido para tipo_senha.""" - # Django não valida choices automaticamente no banco - data = self.paciente_data.copy() - data["tipo_senha"] = "X" # Inválido - paciente = Paciente.objects.create(**data) - self.assertEqual(paciente.tipo_senha, "X") # Django permite - - def test_senha_max_length(self): - """Testa limite de tamanho da senha.""" - # Django não valida max_length automaticamente no banco - data = self.paciente_data.copy() - data["senha"] = "A" * 6 # Exatamente max_length=6 - paciente = Paciente.objects.create(**data) - self.assertEqual(len(paciente.senha), 6) - - def test_cartao_sus_max_length(self): - """Testa limite de tamanho do cartão SUS.""" - # Django não valida max_length automaticamente no banco - data = self.paciente_data.copy() - data["cartao_sus"] = "1" * 20 # Exatamente max_length=20 - paciente = Paciente.objects.create(**data) - self.assertEqual(len(paciente.cartao_sus), 20) - - def test_nome_completo_max_length(self): - """Testa limite de tamanho do nome completo.""" - # Django não valida max_length automaticamente no banco - data = self.paciente_data.copy() - data["nome_completo"] = "A" * 255 # Exatamente max_length=255 - paciente = Paciente.objects.create(**data) - self.assertEqual(len(paciente.nome_completo), 255) - - def test_observacoes_max_length(self): - """Testa limite de tamanho das observações.""" - # Django não valida max_length automaticamente no banco - data = self.paciente_data.copy() - data["observacoes"] = "A" * 255 # Exatamente max_length=255 - paciente = Paciente.objects.create(**data) - self.assertEqual(len(paciente.observacoes), 255) - - def test_telefone_celular_max_length(self): - """Testa limite de tamanho do telefone.""" - # Django não valida max_length automaticamente no banco - data = self.paciente_data.copy() - data["telefone_celular"] = "1" * 20 # Exatamente max_length=20 - paciente = Paciente.objects.create(**data) - self.assertEqual(len(paciente.telefone_celular), 20) - - def test_telefone_e164_valid_formats(self): - """Testa método telefone_e164 com formatos válidos.""" - test_cases = [ - ("(11) 99999-9999", "+5511999999999"), - ("5511999999999", "+5511999999999"), - ] - for telefone_input, expected in test_cases: - data = self.paciente_data.copy() - data["telefone_celular"] = telefone_input - paciente = Paciente.objects.create(**data) - self.assertEqual(paciente.telefone_e164(), expected) - - def test_telefone_e164_invalid_formats(self): - """Testa método telefone_e164 com formatos inválidos.""" - invalid_cases = [ - "(11) 9999-9999", # Sem 9 no início - "1199999999", # 10 dígitos - "119999999999", # 12 dígitos - "551199999999", # 12 dígitos com 55 - "abc123", # Não numérico - "", # Vazio - ] - for telefone_input in invalid_cases: - data = self.paciente_data.copy() - data["telefone_celular"] = telefone_input - paciente = Paciente.objects.create(**data) - self.assertIsNone(paciente.telefone_e164()) - - def test_telefone_e164_none_when_empty(self): - """Testa telefone_e164 retorna None quando telefone é vazio.""" - data = self.paciente_data.copy() - data["telefone_celular"] = None - paciente = Paciente.objects.create(**data) - self.assertIsNone(paciente.telefone_e164()) - - paciente.telefone_celular = "" - paciente.save() - self.assertIsNone(paciente.telefone_e164()) - - def test_horario_agendamento_auto_now_add(self): - """Testa que horario_geracao_senha é auto_now_add.""" - before = timezone.now() - paciente = Paciente.objects.create(**self.paciente_data) - after = timezone.now() - - self.assertIsNotNone(paciente.horario_geracao_senha) - self.assertGreaterEqual(paciente.horario_geracao_senha, before) - self.assertLessEqual(paciente.horario_geracao_senha, after) - - def test_atendido_default_false(self): - """Testa que atendido tem default False.""" - paciente = Paciente.objects.create(**self.paciente_data) - self.assertFalse(paciente.atendido) - - def test_foreign_key_profissional_saude(self): - """Testa relacionamento ForeignKey com profissional_saude.""" - paciente = Paciente.objects.create(**self.paciente_data) - self.assertEqual(paciente.profissional_saude, self.profissional) - - def test_foreign_key_profissional_saude_null(self): - """Testa ForeignKey profissional_saude pode ser null.""" - data = self.paciente_data.copy() - data.pop("profissional_saude") - paciente = Paciente.objects.create(**data) - self.assertIsNone(paciente.profissional_saude) - - -class AtendimentoModelTest(TestCase): - """Testes para o modelo Atendimento.""" - - def setUp(self): - self.profissional = CustomUser.objects.create_user( - cpf="22233344455", - username="22233344455", - password="testpass", - funcao="profissional_saude", - ) - self.paciente = Paciente.objects.create( - nome_completo="Paciente Teste", - tipo_senha="G", - senha="G001", - ) - - def test_create_atendimento_valid(self): - """Testa criação de atendimento válido.""" - atendimento = Atendimento.objects.create( - paciente=self.paciente, - funcionario=self.profissional, - ) - self.assertEqual(atendimento.paciente, self.paciente) - self.assertEqual(atendimento.funcionario, self.profissional) - self.assertIsNotNone(atendimento.data_hora) - - def test_str_method(self): - """Testa método __str__.""" - atendimento = Atendimento.objects.create( - paciente=self.paciente, - funcionario=self.profissional, - ) - str_repr = str(atendimento) - self.assertIn("Paciente Teste", str_repr) - self.assertIn("22233344455", str_repr) - - def test_data_hora_auto_now_add(self): - """Testa que data_hora é auto_now_add.""" - before = timezone.now() - atendimento = Atendimento.objects.create( - paciente=self.paciente, - funcionario=self.profissional, - ) - after = timezone.now() - - self.assertGreaterEqual(atendimento.data_hora, before) - self.assertLessEqual(atendimento.data_hora, after) - - def test_foreign_keys_required(self): - """Testa que ForeignKeys são obrigatórios.""" - # Sem paciente - with self.assertRaises(Exception): - Atendimento.objects.create(funcionario=self.profissional) - - # Sem funcionário - with self.assertRaises(Exception): - Atendimento.objects.create(paciente=self.paciente) - - -class RegistroDeAcessoModelTest(TestCase): - """Testes para o modelo RegistroDeAcesso.""" - - def setUp(self): - self.usuario = CustomUser.objects.create_user( - cpf="33344455566", - username="33344455566", - password="testpass", - ) - - def test_create_registro_valid(self): - """Testa criação de registro válido.""" - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - endereco_ip="127.0.0.1", - user_agent="TestAgent/1.0", - view_name="pagina_inicial", - ) - self.assertEqual(registro.usuario, self.usuario) - self.assertEqual(registro.tipo_de_acesso, "login") - self.assertEqual(registro.endereco_ip, "127.0.0.1") - - def test_str_method(self): - """Testa método __str__.""" - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - ) - str_repr = str(registro) - self.assertIn("33344455566", str_repr) - self.assertIn("login", str_repr) - - def test_tipo_acesso_choices_valid(self): - """Testa valores válidos para tipo_de_acesso.""" - for tipo in ["login", "logout"]: - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso=tipo, - ) - self.assertEqual(registro.tipo_de_acesso, tipo) - - def test_tipo_acesso_choices_invalid(self): - """Testa valor inválido para tipo_de_acesso.""" - # Django não valida choices automaticamente no banco - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="invalid", - ) - self.assertEqual(registro.tipo_de_acesso, "invalid") # Django permite - - def test_campos_opcionais(self): - """Testa campos opcionais.""" - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - ) - self.assertIsNone(registro.endereco_ip) - self.assertIsNone(registro.user_agent) - self.assertIsNone(registro.view_name) - - def test_data_hora_default_now(self): - """Testa que data_hora tem default timezone.now.""" - before = timezone.now() - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - ) - after = timezone.now() - - self.assertGreaterEqual(registro.data_hora, before) - self.assertLessEqual(registro.data_hora, after) - - def test_view_name_max_length(self): - """Testa limite de tamanho do view_name.""" - # Django não valida max_length automaticamente no banco - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - view_name="a" * 255, # Exatamente max_length=255 - ) - self.assertEqual(len(registro.view_name), 255) - - def test_endereco_ip_generic_ip_field(self): - """Testa campo endereco_ip como GenericIPAddressField.""" - # IPv4 válido - registro = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - endereco_ip="192.168.1.1", - ) - self.assertEqual(registro.endereco_ip, "192.168.1.1") - - # IPv6 válido - registro2 = RegistroDeAcesso.objects.create( - usuario=self.usuario, - tipo_de_acesso="login", - endereco_ip="2001:db8::1", - ) - self.assertEqual(registro2.endereco_ip, "2001:db8::1") - - def test_foreign_key_usuario_required(self): - """Testa que usuario é obrigatório.""" - with self.assertRaises(Exception): - RegistroDeAcesso.objects.create(tipo_de_acesso="login") - - -class GuicheModelTest(TestCase): - """Testes para o modelo Guiche.""" - - def setUp(self): - self.funcionario = CustomUser.objects.create_user( - cpf="44455566677", - username="44455566677", - password="testpass", - funcao="guiche", - ) - self.paciente = Paciente.objects.create( - nome_completo="Paciente Guiche", - tipo_senha="G", - senha="G001", - ) - - def test_create_guiche_valid(self): - """Testa criação de guichê válido.""" - guiche = Guiche.objects.create( - numero=1, - funcionario=self.funcionario, - ) - self.assertEqual(guiche.numero, 1) - self.assertEqual(guiche.funcionario, self.funcionario) - self.assertFalse(guiche.em_atendimento) - - def test_str_method_with_funcionario(self): - """Testa método __str__ com funcionário.""" - guiche = Guiche.objects.create( - numero=1, - funcionario=self.funcionario, - ) - str_repr = str(guiche) - self.assertIn("Guichê 1", str_repr) - # O modelo usa first_name, então vamos verificar isso - self.assertIn(self.funcionario.first_name, str_repr) - - def test_str_method_without_funcionario(self): - """Testa método __str__ sem funcionário.""" - guiche = Guiche.objects.create(numero=2) - str_repr = str(guiche) - self.assertIn("Guichê 2", str_repr) - self.assertIn("Livre", str_repr) - - def test_numero_unique(self): - """Testa constraint de unicidade do numero.""" - Guiche.objects.create(numero=1) - with self.assertRaises(Exception): - Guiche.objects.create(numero=1) - - def test_campos_opcionais(self): - """Testa campos opcionais.""" - guiche = Guiche.objects.create(numero=3) - self.assertIsNone(guiche.funcionario) - self.assertIsNone(guiche.senha_atendida) - self.assertIsNone(guiche.user) - self.assertFalse(guiche.em_atendimento) - - def test_em_atendimento_default_false(self): - """Testa que em_atendimento tem default False.""" - guiche = Guiche.objects.create(numero=4) - self.assertFalse(guiche.em_atendimento) - - def test_one_to_one_user(self): - """Testa relacionamento OneToOne com user.""" - guiche = Guiche.objects.create( - numero=5, - user=self.funcionario, - ) - self.assertEqual(guiche.user, self.funcionario) - - def test_foreign_key_senha_atendida(self): - """Testa ForeignKey senha_atendida.""" - guiche = Guiche.objects.create( - numero=6, - senha_atendida=self.paciente, - ) - self.assertEqual(guiche.senha_atendida, self.paciente) - - -class ChamadaModelTest(TestCase): - """Testes para o modelo Chamada.""" - - def setUp(self): - self.guiche = Guiche.objects.create(numero=1) - self.paciente = Paciente.objects.create( - nome_completo="Paciente Chamada", - tipo_senha="G", - senha="G001", - ) - - def test_create_chamada_valid(self): - """Testa criação de chamada válida.""" - chamada = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao="chamada", - ) - self.assertEqual(chamada.paciente, self.paciente) - self.assertEqual(chamada.guiche, self.guiche) - self.assertEqual(chamada.acao, "chamada") - - def test_str_method(self): - """Testa método __str__.""" - chamada = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao="chamada", - ) - str_repr = str(chamada) - self.assertIn("Chamada", str_repr) - self.assertIn("G001", str_repr) - self.assertIn("Guichê 1", str_repr) - - def test_acao_choices_valid(self): - """Testa valores válidos para acao.""" - for acao in ["chamada", "reanuncio", "confirmado"]: - chamada = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao=acao, - ) - self.assertEqual(chamada.acao, acao) - - def test_acao_choices_invalid(self): - """Testa valor inválido para acao.""" - # Django não valida choices automaticamente no banco - chamada = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao="acao_invalida", - ) - self.assertEqual(chamada.acao, "acao_invalida") # Django permite - - def test_data_hora_auto_now_add(self): - """Testa que data_hora é auto_now_add.""" - before = timezone.now() - chamada = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao="chamada", - ) - after = timezone.now() - - self.assertGreaterEqual(chamada.data_hora, before) - self.assertLessEqual(chamada.data_hora, after) - - def test_ordering_meta(self): - """Testa ordering -data_hora.""" - from time import sleep - - chamada1 = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao="chamada", - ) - sleep(0.01) # Pequena pausa para garantir timestamps diferentes - chamada2 = Chamada.objects.create( - paciente=self.paciente, - guiche=self.guiche, - acao="reanuncio", - ) - - chamadas = list(Chamada.objects.all()) - self.assertEqual(chamadas[0], chamada2) # Mais recente primeiro - self.assertEqual(chamadas[1], chamada1) - - def test_foreign_keys_required(self): - """Testa que ForeignKeys são obrigatórios.""" - # Sem paciente - with self.assertRaises(Exception): - Chamada.objects.create(guiche=self.guiche, acao="chamada") - - # Sem guiche - with self.assertRaises(Exception): - Chamada.objects.create(paciente=self.paciente, acao="chamada") - - -class ChamadaProfissionalModelTest(TestCase): - """Testes para o modelo ChamadaProfissional.""" - - def setUp(self): - self.profissional = CustomUser.objects.create_user( - cpf="55566677788", - username="55566677788", - password="testpass", - funcao="profissional_saude", - ) - self.paciente = Paciente.objects.create( - nome_completo="Paciente Profissional", - tipo_senha="G", - senha="G001", - ) - - def test_create_chamada_profissional_valid(self): - """Testa criação de chamada profissional válida.""" - chamada = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao="chamada", - ) - self.assertEqual(chamada.paciente, self.paciente) - self.assertEqual(chamada.profissional_saude, self.profissional) - self.assertEqual(chamada.acao, "chamada") - - def test_str_method(self): - """Testa método __str__.""" - chamada = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao="chamada", - ) - str_repr = str(chamada) - self.assertIn("Chamada", str_repr) - self.assertIn("G001", str_repr) - # O modelo usa first_name do profissional - self.assertIn(self.profissional.first_name, str_repr) - - def test_acao_choices_valid(self): - """Testa valores válidos para acao.""" - for acao in ["chamada", "reanuncio", "confirmado", "encaminha"]: - chamada = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao=acao, - ) - self.assertEqual(chamada.acao, acao) - - def test_acao_choices_invalid(self): - """Testa valor inválido para acao.""" - # Django não valida choices automaticamente no banco - chamada = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao="acao_invalida", - ) - self.assertEqual(chamada.acao, "acao_invalida") # Django permite - - def test_data_hora_auto_now_add(self): - """Testa que data_hora é auto_now_add.""" - before = timezone.now() - chamada = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao="chamada", - ) - after = timezone.now() - - self.assertGreaterEqual(chamada.data_hora, before) - self.assertLessEqual(chamada.data_hora, after) - - def test_ordering_meta(self): - """Testa ordering -data_hora.""" - chamada1 = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao="chamada", - ) - # Pequeno delay para garantir ordem temporal - import time - - time.sleep(0.001) - chamada2 = ChamadaProfissional.objects.create( - paciente=self.paciente, - profissional_saude=self.profissional, - acao="reanuncio", - ) - - chamadas = list(ChamadaProfissional.objects.all()) - self.assertEqual(chamadas[0], chamada2) # Mais recente primeiro - self.assertEqual(chamadas[1], chamada1) - - def test_foreign_keys_required(self): - """Testa que ForeignKeys são obrigatórios.""" - # Sem paciente - with self.assertRaises(Exception): - ChamadaProfissional.objects.create( - profissional_saude=self.profissional, acao="chamada" - ) - - # Sem profissional - with self.assertRaises(Exception): - ChamadaProfissional.objects.create(paciente=self.paciente, acao="chamada") - - -# Forms -class CadastrarPacienteFormTest(TestCase): - """Testes abrangentes para CadastrarPacienteForm com foco em segurança.""" - - def setUp(self): - self.profissional = CustomUser.objects.create_user( - cpf="77788899900", - username="77788899900", - password="testpass", - funcao="profissional_saude", - first_name="Dr.", - last_name="Teste", - ) - self.valid_data = { - "nome_completo": "João Silva Santos", - "cartao_sus": "123456789012345", - "horario_agendamento": timezone.now(), - "profissional_saude": self.profissional.id, - "observacoes": "Paciente de teste", - "tipo_senha": "G", - "telefone_celular": "(11) 99999-9999", - } - - def test_valid_form(self): - """Testa formulário válido.""" - form = CadastrarPacienteForm(data=self.valid_data) - self.assertTrue(form.is_valid()) - paciente = form.save() - self.assertEqual(paciente.nome_completo, "João Silva Santos") - self.assertEqual(paciente.telefone_celular, "11999999999") # Limpo - - def test_sql_injection_nome_completo(self): - """Testa proteção contra SQL injection no nome.""" - malicious_data = self.valid_data.copy() - malicious_data["nome_completo"] = "'; DROP TABLE paciente; --" - form = CadastrarPacienteForm(data=malicious_data) - self.assertTrue(form.is_valid()) # Django ModelForm protege automaticamente - paciente = form.save() - self.assertEqual(paciente.nome_completo, "'; DROP TABLE paciente; --") - - def test_xss_nome_completo(self): - """Testa proteção contra XSS no nome.""" - xss_data = self.valid_data.copy() - xss_data["nome_completo"] = '' - form = CadastrarPacienteForm(data=xss_data) - self.assertFalse(form.is_valid()) - self.assertIn("nome_completo", form.errors) - self.assertIn( - "Entrada inválida: scripts não são permitidos.", - str(form.errors["nome_completo"]), - ) - - def test_sql_injection_observacoes(self): - """Testa proteção contra SQL injection nas observações.""" - malicious_data = self.valid_data.copy() - malicious_data["observacoes"] = "1' OR '1'='1" - form = CadastrarPacienteForm(data=malicious_data) - self.assertTrue(form.is_valid()) - paciente = form.save() - self.assertEqual(paciente.observacoes, "1' OR '1'='1") - - def test_telefone_celular_valid_formats(self): - """Testa formatos válidos de telefone.""" - # TODO: Este teste está falhando devido a diferenças entre SQLite e PostgreSQL - # Os formatos são válidos na prática, mas há incompatibilidades no ambiente de teste - self.skipTest( - "Teste temporariamente desabilitado devido a diferenças entre bancos de dados" - ) - - def test_telefone_celular_invalid_formats(self): - """Testa formatos inválidos de telefone.""" - invalid_formats = [ - "12345", # Muito curto - "(11) 9999-9999", # Sem 9 no início - "1199999999", # 10 dígitos - "abc123", # Não numérico - ] - for telefone in invalid_formats: - data = self.valid_data.copy() - data["telefone_celular"] = telefone - form = CadastrarPacienteForm(data=data) - self.assertFalse( - form.is_valid(), f"Telefone {telefone} deveria ser inválido" - ) - self.assertIn("telefone_celular", form.errors) - - def test_telefone_celular_edge_cases(self): - """Testa casos extremos de telefone.""" - edge_cases = [ - "00000000000", # Todos zeros - "99999999999", # Todos noves - "(00) 00000-0000", # DDD zero - "(99) 99999-9999", # DDD alto - ] - for telefone in edge_cases: - data = self.valid_data.copy() - data["telefone_celular"] = telefone - form = CadastrarPacienteForm(data=data) - # Alguns podem ser válidos, outros não - o importante é que não quebre - paciente = form.save() if form.is_valid() else None - if paciente: - self.assertIsNotNone(paciente.telefone_celular) - - def test_cartao_sus_validation(self): - """Testa validação do cartão SUS.""" - # Cartão SUS válido (até 20 dígitos) - formulário não valida formato específico - data = self.valid_data.copy() - data["cartao_sus"] = "123456789012345" - form = CadastrarPacienteForm(data=data) - self.assertTrue(form.is_valid()) - - # Cartão SUS muito longo - Django ModelForm valida max_length do modelo - data["cartao_sus"] = "1" * 21 - form = CadastrarPacienteForm(data=data) - self.assertFalse(form.is_valid()) # Deve falhar por max_length - self.assertIn("cartao_sus", form.errors) - - def test_tipo_senha_choices(self): - """Testa choices válidos para tipo_senha.""" - tipos_validos = ["E", "C", "P", "G", "D", "A", "NH", "H", "U"] - for tipo in tipos_validos: - data = self.valid_data.copy() - data["tipo_senha"] = tipo - form = CadastrarPacienteForm(data=data) - self.assertTrue(form.is_valid(), f"Tipo {tipo} deveria ser válido") - - def test_tipo_senha_invalid_choice(self): - """Testa choice inválido para tipo_senha.""" - data = self.valid_data.copy() - data["tipo_senha"] = "X" # Inválido - form = CadastrarPacienteForm(data=data) - # Django ChoiceField valida choices - self.assertFalse(form.is_valid()) - self.assertIn("tipo_senha", form.errors) - - def test_required_fields(self): - """Testa campos obrigatórios.""" - required_fields = [ - "tipo_senha" - ] # Apenas tipo_senha é obrigatório no formulário - for field in required_fields: - data = self.valid_data.copy() - data[field] = "" - form = CadastrarPacienteForm(data=data) - self.assertFalse(form.is_valid(), f"Campo {field} deveria ser obrigatório") - self.assertIn(field, form.errors) - - def test_optional_fields(self): - """Testa campos opcionais.""" - optional_fields = [ - "cartao_sus", - "profissional_saude", - "observacoes", - "telefone_celular", - ] - data = self.valid_data.copy() - for field in optional_fields: - data[field] = "" - data["horario_agendamento"] = "" # Também opcional - form = CadastrarPacienteForm(data=data) - self.assertTrue(form.is_valid()) - - def test_horario_agendamento_validation(self): - """Testa validação de horário de agendamento.""" - # Data futura - future_date = timezone.now() + timezone.timedelta(days=1) - data = self.valid_data.copy() - data["horario_agendamento"] = future_date - form = CadastrarPacienteForm(data=data) - self.assertTrue(form.is_valid()) - - # Data passada - past_date = timezone.now() - timezone.timedelta(days=1) - data["horario_agendamento"] = past_date - form = CadastrarPacienteForm(data=data) - self.assertTrue(form.is_valid()) # Não há validação de data passada - - def test_profissional_saude_queryset(self): - """Testa que queryset de profissional_saude filtra corretamente.""" - # Criar usuários de diferentes funções - admin = CustomUser.objects.create_user( - cpf="11122233344", - username="11122233344", - password="testpass", - funcao="administrador", - ) - recepcionista = CustomUser.objects.create_user( - cpf="22233344455", - username="22233344455", - password="testpass", - funcao="recepcionista", - ) - - form = CadastrarPacienteForm() - # O queryset deve conter apenas profissionais de saúde - profissionais = form.fields["profissional_saude"].queryset - self.assertIn(self.profissional, profissionais) - self.assertNotIn(admin, profissionais) - self.assertNotIn(recepcionista, profissionais) - - def test_form_with_profissionais_param(self): - """Testa formulário com parâmetro profissionais_de_saude.""" - # Form sem parâmetro deve ter queryset padrão - form_default = CadastrarPacienteForm() - self.assertTrue( - all( - user.funcao == "profissional_saude" - for user in form_default.fields["profissional_saude"].queryset - ) - ) - - # Form com parâmetro personalizado - profissionais_custom = CustomUser.objects.filter(funcao="administrador") - form_custom = CadastrarPacienteForm(profissionais_de_saude=profissionais_custom) - # Como o campo é definido na classe após __init__, o parâmetro pode não funcionar - # Vamos testar apenas que o form pode ser criado - self.assertIsInstance(form_custom, CadastrarPacienteForm) - - -class CadastrarFuncionarioFormTest(TestCase): - """Testes abrangentes para CadastrarFuncionarioForm com foco em segurança.""" - - def setUp(self): - self.valid_data = { - "cpf": "52998224725", # CPF válido - "username": "52998224725", - "first_name": "João", - "last_name": "Silva", - "email": "joao.silva@test.com", - "funcao": "administrador", - "password1": "testpass123", - "password2": "testpass123", - } - - def test_valid_form(self): - """Testa formulário válido.""" - form = CadastrarFuncionarioForm(data=self.valid_data) - self.assertTrue(form.is_valid()) - user = form.save() - self.assertEqual(user.cpf, "52998224725") - self.assertEqual(user.username, "52998224725") # Username definido como CPF - self.assertTrue(user.check_password("testpass123")) - - def test_cpf_validation_valid(self): - """Testa validação de CPF válido.""" - cpfs_validos = [ - "12345678909", # CPF válido calculado - "52998224725", # CPF válido - "11144477735", # CPF válido - ] - for cpf in cpfs_validos: - data = self.valid_data.copy() - data["cpf"] = cpf - data["username"] = cpf - form = CadastrarFuncionarioForm(data=data) - self.assertTrue(form.is_valid(), f"CPF {cpf} deveria ser válido") - - def test_cpf_validation_invalid(self): - """Testa validação de CPF inválido.""" - cpfs_invalidos = [ - "123", # Muito curto - "123456789012", # Muito longo - "abc123def45", # Não numérico - "", # Vazio - "1234567890", # 10 dígitos - "1234567890123", # 13 dígitos - ] - for cpf in cpfs_invalidos: - data = self.valid_data.copy() - data["cpf"] = cpf - data["username"] = cpf - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid(), f"CPF {cpf} deveria ser inválido") - self.assertIn("cpf", form.errors) - - def test_cpf_unique_constraint(self): - """Testa constraint de unicidade do CPF.""" - # Criar primeiro usuário - form1 = CadastrarFuncionarioForm(data=self.valid_data) - self.assertTrue(form1.is_valid()) - form1.save() - - # Tentar criar segundo usuário com mesmo CPF - form2 = CadastrarFuncionarioForm(data=self.valid_data) - self.assertFalse(form2.is_valid()) - self.assertIn("cpf", form2.errors) - - def test_sql_injection_cpf(self): - """Testa proteção contra SQL injection no CPF.""" - malicious_data = self.valid_data.copy() - malicious_data["cpf"] = "123'; DROP TABLE customuser; --" - malicious_data["username"] = malicious_data["cpf"] - form = CadastrarFuncionarioForm(data=malicious_data) - self.assertFalse(form.is_valid()) - self.assertIn("cpf", form.errors) - - def test_xss_first_name(self): - """Testa proteção contra XSS no first_name.""" - xss_data = self.valid_data.copy() - xss_data["first_name"] = '' - form = CadastrarFuncionarioForm(data=xss_data) - self.assertFalse(form.is_valid()) - self.assertIn("first_name", form.errors) - self.assertIn( - "Entrada inválida: scripts não são permitidos.", - str(form.errors["first_name"]), - ) - - def test_funcao_choices_valid(self): - """Testa choices válidos para função.""" - funcoes_validas = [ - "administrador", - "recepcionista", - "guiche", - "profissional_saude", - ] - for funcao in funcoes_validas: - data = self.valid_data.copy() - data["funcao"] = funcao - form = CadastrarFuncionarioForm(data=data) - self.assertTrue(form.is_valid(), f"Função {funcao} deveria ser válida") - - def test_funcao_choices_invalid(self): - """Testa choice inválido para função.""" - data = self.valid_data.copy() - data["funcao"] = "funcao_invalida" - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("funcao", form.errors) - - def test_password_validation(self): - """Testa validação de senha.""" - # Senhas iguais - data = self.valid_data.copy() - form = CadastrarFuncionarioForm(data=data) - self.assertTrue(form.is_valid()) - - # Senhas diferentes - data["password2"] = "diferente" - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("password2", form.errors) - - def test_password_too_short(self): - """Testa senha muito curta.""" - data = self.valid_data.copy() - data["password1"] = "123" - data["password2"] = "123" - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - # UserCreationForm coloca erros de validação de senha em password2 - self.assertIn("password2", form.errors) - - def test_email_validation(self): - """Testa validação de email.""" - # Email válido - data = self.valid_data.copy() - form = CadastrarFuncionarioForm(data=data) - self.assertTrue(form.is_valid()) - - # Email inválido - data["email"] = "invalid-email" - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("email", form.errors) - - # Email vazio (opcional) - data["email"] = "" - form = CadastrarFuncionarioForm(data=data) - self.assertTrue(form.is_valid()) - - def test_required_fields(self): - """Testa campos obrigatórios.""" - # Campos obrigatórios do UserCreationForm + campos customizados - required_fields = ["cpf", "funcao", "password1", "password2"] - for field in required_fields: - data = self.valid_data.copy() - data[field] = "" - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid(), f"Campo {field} deveria ser obrigatório") - self.assertIn(field, form.errors) - - def test_username_field_hidden(self): - """Testa que campo username é tratado corretamente.""" - # O username deve ser definido como CPF no save - form = CadastrarFuncionarioForm(data=self.valid_data) - self.assertTrue(form.is_valid()) - user = form.save() - self.assertEqual(user.username, user.cpf) - - def test_cpf_validation_digit2_ten_becomes_zero(self): - """Testa CPF onde segundo dígito verificador seria 10, vira 0.""" - # CPF 10000002810 faz digit2 = 10 -> 0, mas vamos alterar último dígito para falhar - cpf_with_digit2_ten = "10000002811" # Último dígito alterado para falhar - data = self.valid_data.copy() - data["cpf"] = cpf_with_digit2_ten - data["username"] = cpf_with_digit2_ten - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("cpf", form.errors) - - def test_cpf_validation_second_digit_check_fails(self): - """Testa CPF que passa primeira verificação mas falha na segunda.""" - # CPF válido 52998224725, alterando último dígito - cpf_second_digit_wrong = "52998224726" - data = self.valid_data.copy() - data["cpf"] = cpf_second_digit_wrong - data["username"] = cpf_second_digit_wrong - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("cpf", form.errors) - - def test_cpf_validation_digit1_ten_becomes_zero(self): - """Testa CPF onde primeiro dígito verificador seria 10, vira 0.""" - # CPF 10000000108 faz digit1 = 10 -> 0, mas vamos alterar penúltimo dígito para falhar - cpf_with_digit1_ten = "10000000118" # Penúltimo dígito alterado - data = self.valid_data.copy() - data["cpf"] = cpf_with_digit1_ten - data["username"] = cpf_with_digit1_ten - form = CadastrarFuncionarioForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("cpf", form.errors) - - def test_cpf_validation_digit1_ten_valid_cpf(self): - """Testa CPF válido onde primeiro dígito verificador é 10 (vira 0).""" - # CPF 10000000108: primeiro dígito calculado é 10 -> 0, segundo é 8 - cpf_valid_digit1_ten = "10000000108" - data = self.valid_data.copy() - data["cpf"] = cpf_valid_digit1_ten - data["username"] = cpf_valid_digit1_ten - form = CadastrarFuncionarioForm(data=data) - self.assertTrue(form.is_valid()) - - -class LoginFormTest(TestCase): - """Testes abrangentes para LoginForm com foco em segurança.""" - - def setUp(self): - self.user = CustomUser.objects.create_user( - cpf="99900011122", - username="99900011122", - password="testpass123", - first_name="Test", - last_name="User", - ) - self.valid_data = { - "cpf": "99900011122", - "password": "testpass123", - } - - def test_valid_login(self): - """Testa login válido.""" - form = LoginForm(data=self.valid_data) - self.assertTrue(form.is_valid()) - self.assertIn("user", form.cleaned_data) - self.assertEqual(form.cleaned_data["user"], self.user) - - def test_invalid_cpf(self): - """Testa CPF inválido.""" - data = self.valid_data.copy() - data["cpf"] = "invalid_cpf" - form = LoginForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - - def test_invalid_password(self): - """Testa senha inválida.""" - data = self.valid_data.copy() - data["password"] = "wrongpass" - form = LoginForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - - def test_nonexistent_user(self): - """Testa usuário inexistente.""" - data = self.valid_data.copy() - data["cpf"] = "00000000000" - form = LoginForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - - def test_inactive_user(self): - """Testa usuário inativo.""" - self.user.is_active = False - self.user.save() - - form = LoginForm(data=self.valid_data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - - def test_sql_injection_cpf(self): - """Testa proteção contra SQL injection no CPF.""" - malicious_data = self.valid_data.copy() - malicious_data["cpf"] = "999' OR '1'='1" - form = LoginForm(data=malicious_data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - - def test_sql_injection_password(self): - """Testa proteção contra SQL injection na senha.""" - malicious_data = self.valid_data.copy() - malicious_data["password"] = "' OR '1'='1" - form = LoginForm(data=malicious_data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - - def test_empty_fields(self): - """Testa campos vazios.""" - # Ambos vazios - form = LoginForm(data={}) - self.assertFalse(form.is_valid()) - self.assertIn("cpf", form.errors) - self.assertIn("password", form.errors) - - # CPF vazio - form = LoginForm(data={"password": "testpass123"}) - self.assertFalse(form.is_valid()) - self.assertIn("cpf", form.errors) - - # Senha vazia - form = LoginForm(data={"cpf": "99900011122"}) - self.assertFalse(form.is_valid()) - self.assertIn("password", form.errors) - - def test_whitespace_handling(self): - """Testa tratamento de espaços em branco.""" - # CPF com espaços - data = self.valid_data.copy() - data["cpf"] = " 99900011122 " - form = LoginForm(data=data) - self.assertTrue( - form.is_valid() - ) # Django CharField remove espaços automaticamente - - def test_case_sensitivity_cpf(self): - """Testa sensibilidade a maiúsculas/minúsculas no CPF.""" - # CPF em maiúsculas (se fosse alfanumérico) - data = self.valid_data.copy() - data["cpf"] = "99900011122" - form = LoginForm(data=data) - self.assertTrue(form.is_valid()) - - def test_max_length_cpf(self): - """Testa limite de tamanho do CPF.""" - data = self.valid_data.copy() - data["cpf"] = "1" * 20 # Maior que max_length - form = LoginForm(data=data) - # Django permite input maior, mas falha na autenticação - self.assertFalse(form.is_valid()) - - def test_brute_force_protection(self): - """Testa proteção contra força bruta (bloqueio após 4 tentativas).""" - # 3 tentativas falhidas - for i in range(3): - data = self.valid_data.copy() - data["password"] = f"wrongpass{i}" - form = LoginForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - self.assertIn("CPF ou senha incorretos.", str(form.errors["__all__"])) - - # 4ª tentativa deve bloquear - data = self.valid_data.copy() - data["password"] = "wrongpass3" - form = LoginForm(data=data) - self.assertFalse(form.is_valid()) - self.assertIn("__all__", form.errors) - self.assertIn( - "Conta bloqueada por tentativas excessivas.", str(form.errors["__all__"]) - ) - - # Verificar que o usuário está bloqueado - user = CustomUser.objects.get(cpf=self.valid_data["cpf"]) - self.assertIsNotNone(user.lockout_until) - self.assertGreater(user.lockout_until, timezone.now()) - - # Tentativa adicional deve mostrar mensagem de bloqueio - form2 = LoginForm(data=data) - self.assertFalse(form2.is_valid()) - self.assertIn("__all__", form2.errors) - self.assertIn("Conta bloqueada.", str(form2.errors["__all__"])) - - def test_timing_attack_resistance(self): - """Testa resistência a ataques de temporização.""" - start_time = timezone.now() - form_valid = LoginForm(data=self.valid_data) - valid_time = timezone.now() - start_time - - start_time = timezone.now() - data_invalid = self.valid_data.copy() - data_invalid["password"] = "wrong" - form_invalid = LoginForm(data=data_invalid) - invalid_time = timezone.now() - start_time - - self.assertTrue(abs((valid_time - invalid_time).total_seconds()) < 1.0) - - -# Views -class CoreViewsTest(TestCase): - def setUp(self): - self.client = Client() - self.user = CustomUser.objects.create_user( - cpf="00011122233", - username="00011122233", - password="testpass", - ) - - def test_login_view_get(self): - response = self.client.get(reverse("login")) - self.assertEqual(response.status_code, 200) - - def test_login_view_post_valid(self): - response = self.client.post( - reverse("login"), - {"cpf": "00011122233", "password": "testpass"}, - follow=True, - ) - self.assertEqual(response.status_code, 200) - # After login, try to access a login-required page to check authentication - response2 = self.client.get(reverse("pagina_inicial")) - self.assertTrue(response2.context["user"].is_authenticated) - - def test_login_view_post_invalid(self): - response = self.client.post( - reverse("login"), - {"cpf": "00011122233", "password": "wrongpass"}, - ) - self.assertEqual(response.status_code, 200) - self.assertFalse(response.context["user"].is_authenticated) - - def test_login_redirect_based_on_role(self): - """Testa redirecionamento após login baseado na função do usuário.""" - # Teste para administrador - admin_user = CustomUser.objects.create_user( - cpf="11122233344", - username="11122233344", - password="adminpass", - funcao="administrador", - is_staff=True, - is_superuser=True, - ) - response = self.client.post( - reverse("login"), - {"cpf": "11122233344", "password": "adminpass"}, - follow=True, - ) - self.assertRedirects(response, reverse("administrador:listar_funcionarios")) - - self.client.logout() - - # Teste para recepcionista - recep_user = CustomUser.objects.create_user( - cpf="22233344455", - username="22233344455", - password="receptionpass", - funcao="recepcionista", - ) - response = self.client.post( - reverse("login"), - {"cpf": "22233344455", "password": "receptionpass"}, - follow=True, - ) - self.assertRedirects(response, reverse("recepcionista:cadastrar_paciente")) - - self.client.logout() - - # Teste para guiche - guiche_user = CustomUser.objects.create_user( - cpf="33344455566", - username="33344455566", - password="guichepass", - funcao="guiche", - ) - response = self.client.post( - reverse("login"), - {"cpf": "33344455566", "password": "guichepass"}, - follow=True, - ) - self.assertRedirects(response, reverse("guiche:selecionar_guiche")) - - self.client.logout() - - # Teste para profissional_saude - prof_user = CustomUser.objects.create_user( - cpf="44455566677", - username="44455566677", - password="profpass", - funcao="profissional_saude", - ) - response = self.client.post( - reverse("login"), - {"cpf": "44455566677", "password": "profpass"}, - follow=True, - ) - self.assertRedirects( - response, reverse("profissional_saude:painel_profissional") - ) - - def test_login_view_post_form_invalid(self): - """Testa login com formulário inválido (CPF vazio).""" - response = self.client.post( - reverse("login"), - {"cpf": "", "password": "testpass"}, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Este campo é obrigatório") # Ou similar - self.assertFalse(response.context["user"].is_authenticated) - - def test_login_redirect_unknown_role(self): - """Testa redirecionamento para função desconhecida.""" - unknown_user = CustomUser.objects.create_user( - cpf="55566677788", - username="55566677788", - password="unknownpass", - funcao="desconhecida", # Função não reconhecida - ) - response = self.client.post( - reverse("login"), - {"cpf": "55566677788", "password": "unknownpass"}, - follow=True, - ) - self.assertRedirects(response, reverse("pagina_inicial")) - - def test_admin_access_registro_acesso(self): - """Testa acesso à página admin de RegistroDeAcesso para cobrir configuração.""" - admin_user = CustomUser.objects.create_user( - cpf="11122233344", - username="11122233344", - password="adminpass", - funcao="administrador", - is_staff=True, - is_superuser=True, - ) - self.client.login(cpf="11122233344", password="adminpass") - response = self.client.get("/admin/core/registrodeacesso/") - self.assertEqual(response.status_code, 200) - - def test_logout_view(self): - self.client.login(cpf="00011122233", password="testpass") - response = self.client.get(reverse("logout"), follow=True) - self.assertEqual(response.status_code, 200) - - def test_login_creates_registro_acesso(self): - """Testa se login cria RegistroDeAcesso via sinal.""" - from core.models import RegistroDeAcesso - - initial_count = RegistroDeAcesso.objects.count() - response = self.client.post( - reverse("login"), - {"cpf": "00011122233", "password": "testpass"}, - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(RegistroDeAcesso.objects.count(), initial_count + 1) - registro = RegistroDeAcesso.objects.last() - self.assertEqual(registro.tipo_de_acesso, "login") - - def test_pagina_inicial_requires_login(self): - response = self.client.get(reverse("pagina_inicial")) - self.assertEqual(response.status_code, 302) # Redirect to login - - -# Teste de integração: fluxo completo de cadastro, login, acesso e logout -class IntegracaoFluxoCompletoTest(TestCase): - def setUp(self): - self.client = Client() - self.funcionario = CustomUser.objects.create_user( - cpf="12312312399", - username="12312312399", - password="funcpass", - first_name="Func", - last_name="Test", - funcao="administrador", - ) - - def test_fluxo_completo(self): - # Cadastro de paciente via model (simulando formulário) - paciente = Paciente.objects.create( - nome_completo="Paciente Integração", - cartao_sus="99988877766", - horario_agendamento=timezone.now(), - profissional_saude=self.funcionario, - tipo_senha="G", - ) - self.assertIsNotNone(paciente.id) - - # Login - login = self.client.login(cpf="12312312399", password="funcpass") - self.assertTrue(login) - - # Acesso à página inicial (protegida) - response = self.client.get(reverse("pagina_inicial")) - self.assertEqual(response.status_code, 200) - self.assertTrue(response.context["user"].is_authenticated) - - # Logout - response = self.client.get(reverse("logout"), follow=True) - self.assertEqual(response.status_code, 200) - # Após logout, usuário não está autenticado - response2 = self.client.get(reverse("pagina_inicial")) - self.assertEqual(response2.status_code, 302) - - -class UtilsTest(TestCase): - """Testes para funções utilitárias em core.utils.""" - - @patch("core.utils.Client") - def test_enviar_whatsapp_sucesso(self, mock_client): - """Testa envio bem-sucedido de WhatsApp.""" - from core.utils import enviar_whatsapp - from django.conf import settings - - # Mock das configurações - settings.TWILIO_ACCOUNT_SID = "test_sid" - settings.TWILIO_AUTH_TOKEN = "test_token" - settings.TWILIO_WHATSAPP_NUMBER = "+1234567890" - - # Mock do cliente e mensagem - mock_message = mock_client.return_value.messages.create.return_value - mock_message.sid = "test_sid" - - resultado = enviar_whatsapp("+5511999999999", "Teste mensagem") - - self.assertTrue(resultado) - mock_client.assert_called_once_with("test_sid", "test_token") - mock_client.return_value.messages.create.assert_called_once_with( - from_="whatsapp:+1234567890", - body="Teste mensagem", - to="whatsapp:+5511999999999", - ) - - def test_enviar_whatsapp_credenciais_ausentes(self): - """Testa falha quando credenciais Twilio não estão configuradas.""" - from core.utils import enviar_whatsapp - from django.conf import settings - - # Simular credenciais ausentes - settings.TWILIO_ACCOUNT_SID = None - settings.TWILIO_AUTH_TOKEN = "test_token" - settings.TWILIO_WHATSAPP_NUMBER = "+1234567890" - - resultado = enviar_whatsapp("+5511999999999", "Teste mensagem") - - self.assertFalse(resultado) - - @patch("core.utils.Client") - def test_enviar_whatsapp_erro_api(self, mock_client): - """Testa falha na API do Twilio.""" - from core.utils import enviar_whatsapp - from django.conf import settings - - # Mock das configurações - settings.TWILIO_ACCOUNT_SID = "test_sid" - settings.TWILIO_AUTH_TOKEN = "test_token" - settings.TWILIO_WHATSAPP_NUMBER = "+1234567890" - - # Mock do cliente para lançar exceção - mock_client.return_value.messages.create.side_effect = Exception("Erro na API") - - resultado = enviar_whatsapp("+5511999999999", "Teste mensagem") - - self.assertFalse(resultado) - mock_client.assert_called_once_with("test_sid", "test_token") - - -class DecoratorTest(TestCase): - """Testes para os decorators de permissões.""" - - def setUp(self): - self.client = Client() - # Cria usuário recepcionista (não administrador) - self.user = CustomUser.objects.create_user( - cpf="11122233344", - username="11122233344", - password="testpass123", - first_name="Maria", - last_name="Santos", - email="maria.santos@test.com", - funcao="recepcionista", - ) - - def test_admin_required_redirects_non_admin(self): - """Testa que admin_required redireciona usuário não administrador.""" - from core.decorators import admin_required - from django.http import HttpRequest - - # Cria uma view mock - def mock_admin_view(request): - return HttpResponse("Acesso permitido") - - # Decora a view - decorated_view = admin_required(mock_admin_view) - - # Cria request mock com usuário não admin - request = HttpRequest() - request.user = self.user - - # Chama a view decorada - response = decorated_view(request) - - # Deve redirecionar para pagina_inicial - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, reverse("pagina_inicial")) - - -class TemplateTagsTest(TestCase): - """Testes para template tags em core.templatetags.core_tags.""" - - def setUp(self): - from guiche.forms import GuicheForm - - # Cria um formulário GuicheForm que tem os campos proporcao_* - self.form = GuicheForm() - - def test_get_proporcao_field_with_empty_value(self): - """Testa get_proporcao_field quando o valor está vazio.""" - from core.templatetags.core_tags import get_proporcao_field - from guiche.forms import GuicheForm - - # Modifica o form para simular um campo vazio - # Como o form é dinâmico, vamos criar um form com dados que façam value() retornar vazio - form_data = {"proporcao_g": ""} # Campo vazio - form = GuicheForm(data=form_data) - - result = get_proporcao_field(form, "tipo_senha_g") - - # Deve retornar o widget com value="1" porque o valor está vazio - self.assertIn('value="1"', str(result)) - - def test_get_proporcao_field_with_value(self): - """Testa get_proporcao_field quando o valor não está vazio.""" - from core.templatetags.core_tags import get_proporcao_field - from guiche.forms import GuicheForm - - # Campo com valor - form_data = {"proporcao_g": "5"} - form = GuicheForm(data=form_data) - - result = get_proporcao_field(form, "tipo_senha_g") - - # Deve retornar o campo original (não modificado) - self.assertEqual(result, form["proporcao_g"]) - - def test_add_class_filter(self): - """Testa o filtro add_class.""" - from core.templatetags.core_tags import add_class - from guiche.forms import GuicheForm - - form = GuicheForm() - field = form["proporcao_g"] - - result = add_class(field, "my-custom-class") - - # Deve conter a classe CSS adicionada - self.assertIn('class="my-custom-class"', str(result)) diff --git a/core/utils.py b/core/utils.py index d9a3ef3..cdfea24 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,33 +1,175 @@ # core/utils.py +import logging import os from twilio.rest import Client from django.conf import settings +logger = logging.getLogger(__name__) -def enviar_whatsapp(numero_destino: str, mensagem: str): + +def enviar_whatsapp( + numero_destino: str, + mensagem: str = None, + content_sid: str = None, + content_variables: dict = None, +): """ Envia uma mensagem via WhatsApp usando a API do Twilio. + Pode usar texto simples ou template aprovado. + + Args: + numero_destino: Número do destinatário em formato E.164 + mensagem: Texto da mensagem (para mensagens simples) + content_sid: SID do template aprovado (para templates) + content_variables: Variáveis do template (opcional) + + Retorna um dicionário com status e detalhes. """ if ( not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN or not settings.TWILIO_WHATSAPP_NUMBER ): - print( - "Erro: Credenciais Twilio não configuradas. Verifique as variáveis de ambiente." + error_msg = ( + "Credenciais Twilio não configuradas. Verifique as variáveis de ambiente." + ) + logger.error(f"Erro: {error_msg}") + return {"status": "error", "error": error_msg} + + # Validar parâmetros + if not mensagem and not content_sid: + return {"status": "error", "error": "Deve fornecer 'mensagem' ou 'content_sid'"} + + if content_sid and mensagem: + return { + "status": "error", + "error": "Use 'mensagem' OU 'content_sid', não ambos", + } + + try: + client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + + # Preparar parâmetros da mensagem + message_params = { + "from_": f"whatsapp:{settings.TWILIO_WHATSAPP_NUMBER}", + "to": f"whatsapp:{numero_destino}", + } + + if mensagem: + # Mensagem de texto simples + message_params["body"] = mensagem + elif content_sid: + # Template aprovado + message_params["content_sid"] = content_sid + if content_variables: + import json + + message_params["content_variables"] = json.dumps(content_variables) + + message = client.messages.create(**message_params) + logger.info(f"Mensagem enviada com SID: {message.sid}") + return { + "status": "success", + "sid": message.sid, + "to": numero_destino, + "message_status": message.status, + "date_created": ( + message.date_created.isoformat() if message.date_created else None + ), + "direction": message.direction, + "price": message.price, + "error_message": message.error_message, + "message_type": "template" if content_sid else "text", + } + except Exception as e: + logger.error(f"Erro ao enviar mensagem via WhatsApp: {e}") + return {"status": "error", "error": str(e)} + + +def enviar_sms_ou_whatsapp( + numero_destino: str, + mensagem: str, + content_sid: str = None, + content_variables: dict = None, +): + """ + Tenta enviar SMS primeiro, se falhar usa WhatsApp como fallback. + + Args: + numero_destino: Número do destinatário em formato E.164 + mensagem: Texto da mensagem + content_sid: SID do template aprovado (opcional, apenas para WhatsApp) + content_variables: Variáveis do template (opcional, apenas para WhatsApp) + + Retorna um dicionário com status e detalhes. + """ + if not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN: + error_msg = ( + "Credenciais Twilio não configuradas. Verifique as variáveis de ambiente." ) - return False + logger.error(f"Erro: {error_msg}") + return {"status": "error", "error": error_msg} + # Primeiro tentar SMS try: client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + # Usar o número SMS do Twilio (não WhatsApp) + sms_number = os.environ.get( + "TWILIO_SMS_NUMBER", "TWILIO_WHATSAPP_NUMBER" + ) # Número verificado para SMS + message = client.messages.create( - from_=f"whatsapp:{settings.TWILIO_WHATSAPP_NUMBER}", # Seu número Twilio - body=mensagem, - to=f"whatsapp:{numero_destino}", # Número do paciente + from_=sms_number, body=mensagem, to=numero_destino ) - print(f"Mensagem enviada com SID: {message.sid}") - return True - except Exception as e: - print(f"Erro ao enviar mensagem via WhatsApp: {e}") - return False + + logger.info(f"SMS enviado com sucesso. SID: {message.sid}") + return { + "status": "success", + "sid": message.sid, + "to": numero_destino, + "message_status": message.status, + "date_created": ( + message.date_created.isoformat() if message.date_created else None + ), + "direction": message.direction, + "price": message.price, + "error_message": message.error_message, + "message_type": "sms", + } + + except Exception as sms_error: + logger.warning( + f"Falha ao enviar SMS: {sms_error}. Tentando WhatsApp como fallback..." + ) + + # Se SMS falhar, tentar WhatsApp + try: + whatsapp_result = enviar_whatsapp( + numero_destino, mensagem, content_sid, content_variables + ) + + if whatsapp_result["status"] == "success": + logger.info("WhatsApp enviado com sucesso como fallback") + whatsapp_result["fallback_used"] = True + whatsapp_result["original_error"] = str(sms_error) + return whatsapp_result + else: + logger.error( + f"WhatsApp também falhou: {whatsapp_result.get('error', 'Erro desconhecido')}" + ) + return { + "status": "error", + "error": f"SMS e WhatsApp falharam. SMS: {str(sms_error)}, WhatsApp: {whatsapp_result.get('error', 'Erro desconhecido')}", + "sms_error": str(sms_error), + "whatsapp_error": whatsapp_result.get("error"), + } + + except Exception as whatsapp_error: + logger.error(f"Erro crítico no fallback WhatsApp: {whatsapp_error}") + return { + "status": "error", + "error": f"SMS e WhatsApp falharam. SMS: {str(sms_error)}, WhatsApp: {str(whatsapp_error)}", + "sms_error": str(sms_error), + "whatsapp_error": str(whatsapp_error), + } diff --git a/core/views.py b/core/views.py index 3046a5e..472d77d 100644 --- a/core/views.py +++ b/core/views.py @@ -18,6 +18,9 @@ def login_view(request): logger.info("A view de login foi chamada.") if request.method == "POST": form = LoginForm(request.POST) + print("DEBUG: form.is_valid() =", form.is_valid()) + print("DEBUG: form.errors =", form.errors) + print("DEBUG: form.data =", form.data) logger.info("Formulário POST recebido.") if form.is_valid(): cpf = form.cleaned_data["cpf"] @@ -29,22 +32,32 @@ def login_view(request): f"Autenticação bem-sucedida para o usuário: {user.username}" ) login(request, user) - # Redirecionamento baseado na função do usuário + # Forçar redirecionamento baseado na função do usuário (mesmo para superusers) + next_url = None if user.funcao == "administrador": - return redirect("administrador:listar_funcionarios") + next_url = "administrador:listar_funcionarios" elif user.funcao == "recepcionista": - return redirect("recepcionista:cadastrar_paciente") + next_url = "recepcionista:cadastrar_paciente" elif user.funcao == "guiche": - return redirect("guiche:selecionar_guiche") + next_url = "guiche:selecionar_guiche" elif user.funcao == "profissional_saude": - return redirect("profissional_saude:painel_profissional") + if user.sala: + next_url = "profissional_saude:painel_profissional" + else: + next_url = "profissional_saude:selecionar_sala" + else: + next_url = "pagina_inicial" + + if next_url: + return redirect(next_url) else: return redirect("pagina_inicial") else: - logger.warning("Credenciais incorretas.") + print(f"Autenticação falhou para CPF: {cpf}") form.add_error(None, "CPF ou senha incorretos.") else: - logger.warning("Formulário inválido.") + print(f"Form inválido: {form.errors}") + form.add_error(None, "Dados inválidos. Verifique o CPF.") else: form = LoginForm() return render(request, "login.html", {"form": form}) @@ -57,6 +70,18 @@ def pagina_inicial(request): @login_required def logout_view(request): + # Liberar guichê se o usuário for do tipo guiche + if hasattr(request.user, "funcao") and request.user.funcao == "guiche": + guiche_id = request.session.get("guiche_id") + if guiche_id: + try: + from core.models import Guiche + + guiche = Guiche.objects.get(id=guiche_id) + guiche.funcionario = None + guiche.save() + except Guiche.DoesNotExist: + pass RegistroDeAcesso.objects.create( usuario=request.user, tipo_de_acesso="logout", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..47b6312 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,50 @@ +services: + db: + image: postgres:15 + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data_prod:/var/lib/postgresql/data + networks: + - sga_network + + web: + build: . + volumes: + - .:/app + - staticfiles:/app/staticfiles + env_file: + - .env + environment: + DJANGO_SETTINGS_MODULE: sga.settings + DEBUG: '0' + SECRET_KEY: ${SECRET_KEY} + DJANGO_ENV: production + depends_on: + - db + networks: + - sga_network + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - staticfiles:/app/staticfiles + depends_on: + - web + networks: + - sga_network + +volumes: + postgres_data_prod: + staticfiles: + +networks: + sga_network: + driver: bridge \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..996198c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,120 @@ +#!/bin/sh +set -e + +echo "Entrypoint: aguardando banco de dados..." + +# Simple wait-for-postgres loop using psql (if available) or python fallback +if command -v pg_isready >/dev/null 2>&1; then + until pg_isready -h "${DB_HOST:-db}" -p "${DB_PORT:-5432}" -U "${POSTGRES_USER:-postgres}"; do + echo "Aguardando Postgres..." + sleep 1 + done +else + python - < -
-

Painel do Guichê - {% if request.session.guiche_id %} - — Guichê selecionado: {{ request.session.guiche_id }} - {% endif %} -

- - - Selecionar/Trocar Guichê - +
+ +
+
+
+

+ Painel do Guichê + {% if request.session.guiche_id %} + — Guichê selecionado: {{ request.session.guiche_id }} + {% endif %} +

+

Gerencie as senhas e atendimentos do seu guichê

+
+ +
+
-

Selecione os tipos de senha que deseja atender:

+ +
+

Configuração de Atendimento

+

Selecione os tipos de senha que deseja atender e defina as proporções:

-
+ {% csrf_token %} -
- -
-
- {% for field in form %} - {% if "tipo_senha" in field.name %} - {% with tipo_senha=field.name|slice:"11:" %} - {% with proporcao=form|get_proporcao_field:field.name %} -
-
-
- {{ field|add_class:"form-check-input tipo-checkbox me-2" }} - -
-
- {{ proporcao|add_class:"form-control form-control-sm text-end proporcao-input border border-primary" }} -
-
-
- {% endwith %} - {% endwith %} - {% endif %} - {% endfor %} -
-
+
+ {% for field in form %} + {% if "tipo_senha" in field.name %} + {% with tipo_senha=field.name|slice:"11:" %} + {% with proporcao=form|get_proporcao_field:field.name %} +
+
+ {{ field|add_class:"tipo-checkbox mr-3 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded" }} + +
+
+ {{ proporcao|add_class:"proporcao-input w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-primary focus:border-primary" }} +
+
+ {% endwith %} + {% endwith %} + {% endif %} + {% endfor %}
-
-
-
-

Senhas Geradas Hoje

+ +
+

Senhas Geradas Hoje

{% if senhas %} -
+
{% for senha in senhas %} -
-
- {{ senha.senha }} - {{ senha.nome_completo }} -
-
- - - +
+
+
+ {{ senha.senha }} + {{ senha.nome_completo }} +
+
+ + + +
+ {% if senha.observacoes %} +
+
+ +
+ Observações: +

{{ senha.observacoes }}

+
+
+
+ {% endif %}
{% endfor %}
{% else %} -

Nenhuma senha gerada hoje.

+
+ +

Nenhuma senha gerada hoje.

+
{% endif %}
-
-

Histórico de Chamadas

+ +
+

Histórico de Chamadas

{% if historico_chamadas %} -
    +
    {% for chamada in historico_chamadas %} -
  • {{ chamada }}
  • +
    + {{ chamada }} +
    {% endfor %} -
+
{% else %} -

Nenhuma chamada registrada.

+
+ +

Nenhuma chamada registrada.

+
{% endif %}
{% endblock %} \ No newline at end of file diff --git a/guiche/templates/guiche/selecionar_guiche.html b/guiche/templates/guiche/selecionar_guiche.html index 87ed4c1..59a913d 100644 --- a/guiche/templates/guiche/selecionar_guiche.html +++ b/guiche/templates/guiche/selecionar_guiche.html @@ -1,17 +1,65 @@ {% extends 'base.html' %} {% block content %} -
-
-

Selecione seu Guichê

-
- {% csrf_token %} -
- {{ form.guiche.label_tag }}
- {{ form.guiche }} -
- - Voltar -
-
+
+
+
+

Selecionar Guichê

+

Escolha o guichê que você irá operar

+
+ +
+ {% csrf_token %} + + + {% if messages %} +
+ {% for message in messages %} +
+
+
+ {% if message.tags == 'success' %} + + {% elif message.tags == 'error' %} + + {% else %} + + {% endif %} +
+
+

{{ message }}

+
+
+
+ {% endfor %} +
+ {% endif %} + + +
+ + {{ form.guiche }} + {% if form.guiche.errors %} +

{{ form.guiche.errors.0 }}

+ {% endif %} + {% if form.guiche.help_text %} +

{{ form.guiche.help_text }}

+ {% endif %} +
+ + +
+ + Voltar ao Painel + + +
+
+
{% endblock %} \ No newline at end of file diff --git a/guiche/templates/guiche/tv1.html b/guiche/templates/guiche/tv1.html index 59793a8..50467dc 100644 --- a/guiche/templates/guiche/tv1.html +++ b/guiche/templates/guiche/tv1.html @@ -162,7 +162,7 @@

Histórico de Chamadas

    {% for chamada in historico_senhas %}
  • - {{ chamada.paciente.nome_completo }} - Guichê {{ chamada.guiche.numero }} + ✓ {{ chamada.paciente.nome_completo }} - Guichê {{ chamada.guiche.numero }}
  • {% endfor %}
@@ -219,10 +219,39 @@

Histórico de Chamadas

}); } + function atualizarHistorico() { + console.log("Atualizando histórico..."); + $.ajax({ + url: "{% url 'guiche:tv1_historico_api' %}", + type: "GET", + dataType: "json", + success: function(data) { + console.log("Dados do histórico recebidos:", data); + + if (data.historico && data.historico.length > 0) { + var historicoHtml = ''; + data.historico.forEach(function(chamada) { + historicoHtml += '
  • ✓ ' + chamada.paciente_nome + ' - Guichê ' + chamada.guiche_numero + '
  • '; + }); + + $('.historico-senhas ul').html(historicoHtml); + } + }, + error: function(jqXHR, textStatus, errorThrown) { + console.error("Erro ao atualizar o histórico: " + textStatus); + } + }); + } + // Função para chamar o paciente function falarSoletracao(nomeCompleto, numeroGuiche) { console.log("Função falarSoletracao chamada!"); + if (!('speechSynthesis' in window)) { + console.log("Speech Synthesis não suportado neste navegador."); + return; + } + if (!synth) { console.error("speechSynthesis não está disponível!"); return; @@ -258,7 +287,11 @@

    Histórico de Chamadas

    } utterThis.onerror = function(event) { - console.error('Erro ao falar:', event.error); + if (event.error === 'not-allowed') { + console.log('Fala bloqueada pelo navegador. Possivelmente requer interação do usuário ou HTTPS.'); + } else { + console.error('Erro ao falar:', event.error); + } } synth.speak(utterThis); @@ -303,8 +336,12 @@

    Histórico de Chamadas

    // Atualiza o horário a cada segundo setInterval(displayTime, 1000); - // Inicializa a atualização da tela + // Inicializa a atualização da tela e do histórico setInterval(atualizarTela, 5000); + setInterval(atualizarHistorico, 5000); + + // Atualiza o histórico imediatamente ao carregar a página + atualizarHistorico(); }); diff --git a/guiche/urls.py b/guiche/urls.py index e107db9..1947dcc 100644 --- a/guiche/urls.py +++ b/guiche/urls.py @@ -18,4 +18,5 @@ ), path("tv1/", views.tv1_view, name="tv1"), path("tv1/api/", views.tv1_api_view, name="tv1_api"), + path("tv1/historico/api/", views.tv1_historico_api_view, name="tv1_historico_api"), ] diff --git a/guiche/views.py b/guiche/views.py index 9afa8f9..3489c2e 100644 --- a/guiche/views.py +++ b/guiche/views.py @@ -1,9 +1,11 @@ # guiche/views.py import datetime +import logging import os import tempfile from collections import defaultdict, deque from itertools import cycle +from typing import Any, Dict, List from django import forms from django.contrib.auth.decorators import login_required @@ -17,10 +19,12 @@ from core.decorators import guiche_required from core.models import Chamada, Guiche, Paciente -from core.utils import enviar_whatsapp # Importe a função de utilidade +from core.utils import enviar_sms_ou_whatsapp # Importe a nova função from .forms import GuicheForm +logger = logging.getLogger(__name__) + @guiche_required @login_required @@ -126,7 +130,6 @@ def painel_guiche(request): "form": form, "senhas": senhas, "historico_chamadas": historico_chamadas, - "form": form, }, ) @@ -138,7 +141,7 @@ def chamar_senha(request, paciente_id): paciente = get_object_or_404(Paciente, id=paciente_id) guiche = get_guiche_do_usuario(request.user, request=request) - # 1. Cria o registro da chamada (já está feito) + # 1. Cria o registro da chamada e envia WhatsApp (através de realizar_acao_senha) response_chamada = realizar_acao_senha( request, paciente.senha, @@ -148,20 +151,7 @@ def chamar_senha(request, paciente_id): "chamada", ) - # 2. Tenta enviar mensagem via WhatsApp - numero_celular_paciente = paciente.telefone_e164() # Obtém o número formatado - if numero_celular_paciente: - mensagem = ( - f"Olá {paciente.nome_completo.split()[0]}! " - f"Seu atendimento foi iniciado. Por favor, dirija-se ao Guichê {guiche.numero}." - ) - enviar_whatsapp(numero_celular_paciente, mensagem) - else: - print( - f"Aviso: Não foi possível enviar WhatsApp para o paciente {paciente.nome_completo} (ID: {paciente_id}) - telefone inválido ou ausente." - ) - - # 3. Retorna o JsonResponse original + # 2. Retorna o JsonResponse original return response_chamada @@ -217,21 +207,26 @@ def realizar_acao_senha(request, senha, guiche_numero, nome, paciente_id, acao): data_for_tv = {"senha": senha, "nome_completo": nome, "guiche": guiche_numero} # --- LÓGICA DE ENVIO DE SMS --- + twilio_response = None if acao in ["chamada", "reanuncio"] and paciente.telefone_celular: numero_e164 = paciente.telefone_e164() if numero_e164: mensagem = ( - f"Seu atendimento foi iniciado. Por favor, dirija-se ao Guichê {guiche_numero}. " + f"Por favor, dirija-se ao Guichê {guiche_numero}. " f"Chamado: {senha} - {nome}." ) - enviar_whatsapp(numero_e164, mensagem) + twilio_response = enviar_sms_ou_whatsapp(numero_e164, mensagem) else: - print( - f"Telefone inválido para o paciente {nome} (ID: {paciente_id}). SMS não enviado." - ) + twilio_response = { + "status": "error", + "error": f"Telefone inválido para o paciente {nome} (ID: {paciente_id}). SMS não enviado.", + } # --- FIM DA LÓGICA DE ENVIO DE SMS --- - return JsonResponse({"status": "ok", "data": data_for_tv}) + response_data = {"status": "ok", "data": data_for_tv} + if twilio_response: + response_data["twilio"] = twilio_response + return JsonResponse(response_data) @never_cache @@ -244,7 +239,9 @@ def tv1_view(request): senha_chamada = ultima_chamada.paciente nome_completo = ultima_chamada.paciente.nome_completo numero_guiche = ultima_chamada.guiche.numero # Obtém o número do guichê - historico_chamadas = Chamada.objects.order_by("-data_hora")[:5] + historico_chamadas = Chamada.objects.filter(acao="confirmado").order_by( + "-data_hora" + )[:5] except Chamada.DoesNotExist: senha_chamada = None nome_completo = None @@ -318,21 +315,72 @@ def tv1_api_view(request): return JsonResponse(data) +def tv1_historico_api_view(request) -> JsonResponse: + """API para obter apenas o histórico de chamadas da TV1""" + try: + historico_chamadas = ( + Chamada.objects.filter(acao="confirmado") + .select_related("paciente", "guiche") + .order_by("-data_hora")[:8] + ) + + historico_data: List[Dict[str, Any]] = [] + for chamada in historico_chamadas: + historico_data.append( + { + "id": chamada.id, + "paciente_nome": chamada.paciente.nome_completo, + "guiche_numero": chamada.guiche.numero, + "acao": chamada.acao, + "data_hora": chamada.data_hora.strftime("%H:%M:%S"), + } + ) + + data: Dict[str, Any] = {"historico": historico_data} + except Exception as e: + data = {"historico": [], "error": str(e)} + + return JsonResponse(data) + + class SelecionarGuicheForm(forms.Form): guiche = forms.ModelChoiceField( - queryset=Guiche.objects.all().order_by("numero"), + queryset=Guiche.objects.none(), empty_label="Selecione um guichê", label="Guichê do dia", ) + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + # Mostrar guichês não atribuídos OU já atribuídos ao usuário atual + if user is not None: + self.fields["guiche"].queryset = Guiche.objects.filter( + funcionario__isnull=True + ).order_by("numero") | Guiche.objects.filter(funcionario=user) + else: + self.fields["guiche"].queryset = Guiche.objects.filter( + funcionario__isnull=True + ).order_by("numero") + @login_required @guiche_required def selecionar_guiche(request): if request.method == "POST": - form = SelecionarGuicheForm(request.POST) + form = SelecionarGuicheForm(request.POST, user=request.user) if form.is_valid(): g = form.cleaned_data["guiche"] + # Liberar guichê anterior se existir + old_guiche_id = request.session.get("guiche_id") + if old_guiche_id: + try: + old_guiche = Guiche.objects.get(id=old_guiche_id) + old_guiche.funcionario = None + old_guiche.save() + except Guiche.DoesNotExist: + pass + g.funcionario = request.user + g.save() request.session["guiche_id"] = g.id request.session.modified = True return redirect("guiche:painel_guiche") @@ -341,6 +389,6 @@ def selecionar_guiche(request): gid = request.session.get("guiche_id") if gid: initial["guiche"] = gid - form = SelecionarGuicheForm(initial=initial) + form = SelecionarGuicheForm(initial=initial, user=request.user) return render(request, "guiche/selecionar_guiche.html", {"form": form}) diff --git a/mypy.ini b/mypy.ini index c50dbe8..c0e0c8e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -18,4 +18,7 @@ ignore_missing_imports = True ignore_errors = True [mypy-*.tests.*] +ignore_errors = True + +[mypy-test_fluxocompleto2] ignore_errors = True \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..bffc751 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,51 @@ +events { + worker_connections 1024; +} + +# Segurança: Restringir acesso por IP +# Para permitir apenas dispositivos específicos (ex.: rede do hospital), adicione as linhas abaixo no bloco 'server'. +# Exemplo: +# allow 192.168.1.0/24; # Permite sub-rede do hospital (ajuste o IP/sub-rede real) +# deny all; # Bloqueia todos os outros acessos +# +# Nota: Se o servidor usar ufw (Ubuntu/Debian), configure o firewall no host: +# sudo ufw allow from 192.168.1.0/24 to any port 8080 # Permite sub-rede na porta do Nginx (ajuste porta se mudar) +# sudo ufw deny 8080 # Bloqueia outros na mesma porta +# Isso adiciona uma camada extra de segurança além do Nginx. + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + upstream django { + server web:8000; + } + + server { + listen 80; + server_name localhost; + + # Exemplo de restrição por IP (descomente e ajuste): + # allow 192.168.1.0/24; # Permite sub-rede do hospital + # deny all; # Bloqueia todos os outros + + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /static/ { + alias /app/staticfiles/; + } + + location /media/ { + alias /app/media/; + } + } +} \ No newline at end of file diff --git a/profissional_saude/forms.py b/profissional_saude/forms.py index c9119cb..a16582f 100644 --- a/profissional_saude/forms.py +++ b/profissional_saude/forms.py @@ -66,3 +66,20 @@ def clean(self): acao_selecionada = True return cleaned_data + + +class SelecionarSalaForm(forms.Form): + """ + Formulário para seleção de sala do profissional de saúde. + """ + + sala = forms.ChoiceField( + choices=[(str(i), f"Sala {i}") for i in range(1, 21)], # Salas 1 a 20 + label="Sala do dia", + widget=forms.Select( + attrs={ + "class": "form-control", + "style": "font-size: 1.2rem; padding: 0.75rem;", + } + ), + ) diff --git a/profissional_saude/templates/profissional_saude/painel_profissional.html b/profissional_saude/templates/profissional_saude/painel_profissional.html index 42f066c..17d459f 100644 --- a/profissional_saude/templates/profissional_saude/painel_profissional.html +++ b/profissional_saude/templates/profissional_saude/painel_profissional.html @@ -1,71 +1,156 @@ {% extends 'base.html' %} {% load core_tags %} -{% block title %}Painel do Profissional de Saúde{% endblock %} +{% block title %}Painel do Profissional de Saúde{% endblock %} {% block content %} -
    -
    -

    Painel do Profissional de Saúde

    -

    Lista de pacientes para atendimento:

    +
    + +
    +
    +
    +

    Painel do Profissional de Saúde

    +

    Gerencie seus pacientes e atendimentos

    +
    + +
    +
    + + +
    +

    Pacientes para Atendimento

    {% if pacientes %} -
    +
    {% for paciente in pacientes %} -
    -
    - {{ paciente.senha }} - {{ paciente.nome_completo }} -
    -
    - - - - - + + + +
    + + +
    +
    + {% if paciente.observacoes %} +
    +
    + +
    + Observações: +

    {{ paciente.observacoes }}

    +
    +
    +
    + {% endif %}
    {% endfor %}
    {% else %} -

    Nenhum paciente agendado para você.

    +
    + +

    Nenhum paciente agendado

    +

    Você não tem pacientes agendados para atendimento no momento.

    +
    {% endif %}
    +
    -
    -

    Histórico de Chamadas

    + +
    +

    Histórico de Chamadas

    {% if historico_chamadas %} -
      +
      {% for chamada in historico_chamadas %} -
    • - {{ chamada.paciente.senha }} - {{ chamada.paciente.nome_completo }}
      - Ação: {{ chamada.get_acao_display }} | {{ chamada.data_hora|date:"d/m/Y H:i" }} -
    • +
      +
      +
      + {{ chamada.paciente.senha }} + {{ chamada.paciente.nome_completo }} +
      +
      +
      {{ chamada.get_acao_display }}
      +
      {{ chamada.data_hora|date:"d/m/Y H:i" }}
      +
      +
      +
      {% endfor %} -
    +
    {% else %} -

    Nenhuma chamada registrada.

    +
    + +

    Nenhuma chamada registrada.

    +
    {% endif %}
    diff --git a/profissional_saude/urls.py b/profissional_saude/urls.py index 3fd88b5..02db036 100644 --- a/profissional_saude/urls.py +++ b/profissional_saude/urls.py @@ -5,6 +5,7 @@ app_name = "profissional_saude" urlpatterns = [ + path("selecionar_sala/", views.selecionar_sala, name="selecionar_sala"), path("painel/", views.painel_profissional, name="painel_profissional"), path( "acao///", @@ -13,4 +14,5 @@ ), path("tv2/", views.tv2_view, name="tv2"), path("tv2/api/", views.tv2_api_view, name="tv2_api"), + path("tv2/historico/api/", views.tv2_historico_api_view, name="tv2_historico_api"), ] diff --git a/profissional_saude/views.py b/profissional_saude/views.py index 5036e63..a8fcd22 100644 --- a/profissional_saude/views.py +++ b/profissional_saude/views.py @@ -1,4 +1,6 @@ # profissional_saude/views.py +import logging +from typing import Any, Dict, List, Optional from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -7,8 +9,12 @@ from core.decorators import profissional_saude_required from core.models import ChamadaProfissional, CustomUser, Paciente + +logger = logging.getLogger(__name__) from core.utils import enviar_whatsapp # Importe a função de utilidade +from .forms import SelecionarSalaForm + @login_required @profissional_saude_required @@ -16,6 +22,10 @@ def painel_profissional(request): """ Painel do profissional de saúde. """ + # Verificar se o profissional tem sala atribuída + if not request.user.sala: + return redirect("profissional_saude:selecionar_sala") + pacientes = Paciente.objects.filter( profissional_saude=request.user, atendido=True ).order_by("horario_agendamento") @@ -46,6 +56,7 @@ def realizar_acao_profissional(request, paciente_id, acao): """ paciente = get_object_or_404(Paciente, id=paciente_id) profissional_saude = request.user + twilio_response: Optional[Dict[str, Any]] = None if acao == "chamar": ChamadaProfissional.objects.create( @@ -57,38 +68,60 @@ def realizar_acao_profissional(request, paciente_id, acao): if numero_celular_paciente: mensagem = ( f"Olá {paciente.nome_completo.split()[0]}! " - f"Seu atendimento com o(a) Dr(a). {profissional_saude.first_name} " - f"na Sala {profissional_saude.sala} foi iniciado. Por favor, aguarde." + f"O(a) Dr(a). {profissional_saude.first_name}, lhe aguarda. " + f"Por favor, dirija-se à Sala {profissional_saude.sala}." ) - enviar_whatsapp(numero_celular_paciente, mensagem) + twilio_response = enviar_whatsapp(numero_celular_paciente, mensagem) else: - print( - f"Aviso: Não foi possível enviar WhatsApp para o paciente {paciente.nome_completo} (ID: {paciente_id}) - telefone inválido ou ausente." + logger.warning( + f"Telefone inválido ou ausente para o paciente {paciente.nome_completo} (ID: {paciente_id}). " + "WhatsApp não será enviado." ) + twilio_response = { + "status": "error", + "error": f"Telefone inválido ou ausente para o paciente {paciente.nome_completo} (ID: {paciente_id}).", + } - return JsonResponse( - {"status": "success", "mensagem": "Senha chamada com sucesso."} - ) + response_data = {"status": "success", "mensagem": "Senha chamada com sucesso."} + if twilio_response: + response_data["twilio"] = twilio_response # type: ignore + return JsonResponse(response_data) elif acao == "reanunciar": ChamadaProfissional.objects.create( paciente=paciente, profissional_saude=profissional_saude, acao="reanuncio" ) - # Opcional: Reenviar o WhatsApp também no reanuncio? - # numero_celular_paciente = paciente.telefone_e164() - # if numero_celular_paciente: - # mensagem = ( - # f"Olá {paciente.nome_completo.split()[0]}! " - # f"O(A) Dr(a). {profissional_saude.first_name} está chamando novamente. " - # f"Por favor, dirija-se à Sala {profissional_saude.sala}." - # ) - # enviar_whatsapp(numero_celular_paciente, mensagem) - # else: - # print(f"Aviso: Não foi possível enviar WhatsApp no reanuncio para o paciente {paciente.nome_completo} (ID: {paciente_id}) - telefone inválido ou ausente.") - return JsonResponse( - {"status": "success", "mensagem": "Senha reanunciada com sucesso."} - ) + # Reenviar o WhatsApp no reanuncio + numero_celular_paciente = paciente.telefone_e164() + if numero_celular_paciente: + mensagem = ( + f"Olá {paciente.nome_completo.split()[0]}! " + f"O(A) Dr(a). {profissional_saude.first_name} está chamando novamente. " + f"Por favor, dirija-se à Sala {profissional_saude.sala}." + ) + twilio_response = enviar_whatsapp(numero_celular_paciente, mensagem) + else: + logger.warning( + f"Telefone inválido ou ausente para o paciente {paciente.nome_completo} (ID: {paciente_id}). " + "WhatsApp não será enviado." + ) + twilio_response = { + "status": "error", + "error": f"Telefone inválido ou ausente para o paciente {paciente.nome_completo} (ID: {paciente_id}).", + } + + response_data = { + "status": "success", + "mensagem": "Senha reanunciada com sucesso.", + } + if twilio_response: + response_data["twilio"] = twilio_response # type: ignore + return JsonResponse(response_data) elif acao == "confirmar": + # Criar registro de confirmação para o histórico da TV2 + ChamadaProfissional.objects.create( + paciente=paciente, profissional_saude=profissional_saude, acao="confirmado" + ) paciente.atendido = False # Marcar como não atendido para sair da lista paciente.save() return JsonResponse( @@ -134,30 +167,23 @@ def tv2_view(request): ).latest("data_hora") senha_chamada = ultima_chamada.paciente nome_completo = ultima_chamada.paciente.nome_completo - # Busca a sala do profissional que fez a chamada - sala_consulta = ( - ultima_chamada.profissional_saude.sala - ) # Acessa a sala através do profissional - profissional_nome = ultima_chamada.profissional_saude.get_full_name() + # Busca o número da sala do profissional que fez a chamada + sala_profissional = ultima_chamada.profissional_saude.sala - # Pega as 5 chamadas mais recentes, excluindo a última chamada - historico_chamadas = ( - ChamadaProfissional.objects.filter(acao__in=["chamada", "reanuncio"]) - .exclude(id=ultima_chamada.id) - .order_by("-data_hora")[:5] - ) + # Pega as 5 confirmações mais recentes + historico_chamadas = ChamadaProfissional.objects.filter( + acao="confirmado" + ).order_by("-data_hora")[:5] except ChamadaProfissional.DoesNotExist: senha_chamada = None nome_completo = None - sala_consulta = None - profissional_nome = None + sala_profissional = None historico_chamadas = [] context = { "senha_chamada": senha_chamada, "nome_completo": nome_completo, - "sala_consulta": sala_consulta, - "profissional_nome": profissional_nome, + "sala_profissional": sala_profissional, "historico_senhas": historico_chamadas, "ultima_chamada": ( ultima_chamada if senha_chamada else None @@ -177,17 +203,18 @@ def tv2_api_view(request): ).latest("data_hora") senha = ultima_chamada.paciente.senha # Pega a senha nome_completo = ultima_chamada.paciente.nome_completo - # Busca a sala do profissional que fez a chamada - sala_consulta = ( - ultima_chamada.profissional_saude.sala - ) # Acessa a sala através do profissional - profissional_nome = ultima_chamada.profissional_saude.get_full_name() + # Busca o número da sala do profissional que fez a chamada + sala_profissional = ultima_chamada.profissional_saude.sala + profissional_nome = ( + ultima_chamada.profissional_saude.get_full_name() + or ultima_chamada.profissional_saude.username + ) chamada_id = ultima_chamada.id data = { "senha": senha, # Envia a senha "nome_completo": nome_completo, - "sala_consulta": sala_consulta, + "sala_profissional": sala_profissional, "profissional_nome": profissional_nome, "id": chamada_id, } @@ -195,9 +222,84 @@ def tv2_api_view(request): data = { "senha": "", "nome_completo": "", - "sala_consulta": "", + "sala_profissional": "", "profissional_nome": "", "id": "", } return JsonResponse(data) + + +def tv2_historico_api_view(request) -> JsonResponse: + """API para obter apenas o histórico de confirmações da TV2""" + try: + # Obtém as últimas 5 confirmações + historico_chamadas = ( + ChamadaProfissional.objects.filter(acao="confirmado") + .select_related("paciente", "profissional_saude") + .order_by("-data_hora")[:5] + ) + + historico_data: List[Dict[str, Any]] = [] + for chamada in historico_chamadas: + historico_data.append( + { + "id": chamada.id, + "paciente_nome": chamada.paciente.nome_completo, + "paciente_senha": chamada.paciente.senha, + "sala_profissional": chamada.profissional_saude.sala, + "data_hora": chamada.data_hora.strftime("%H:%M:%S"), + } + ) + + data: Dict[str, Any] = {"historico": historico_data} + except Exception as e: + data = {"historico": [], "error": str(e)} + + return JsonResponse(data) + + +@login_required +@profissional_saude_required +def selecionar_sala(request): + """ + View para o profissional de saúde selecionar sua sala. + """ + if request.method == "POST": + form = SelecionarSalaForm(request.POST) + if form.is_valid(): + sala_numero = int(form.cleaned_data["sala"]) + + # Verificar se outro profissional já está usando esta sala + outro_profissional = ( + CustomUser.objects.filter(funcao="profissional_saude", sala=sala_numero) + .exclude(id=request.user.id) + .first() + ) + + if outro_profissional: + # Sala já ocupada - mostrar erro + from django.contrib import messages + + messages.error( + request, + f"A sala {sala_numero} já está sendo usada pelo profissional {outro_profissional.first_name} {outro_profissional.last_name}.", + ) + else: + # Sala disponível - salvar + request.user.sala = sala_numero + request.user.save() + from django.contrib import messages + + messages.success( + request, f"Sala {sala_numero} selecionada com sucesso!" + ) + return redirect("profissional_saude:painel_profissional") + else: + # Inicializar form com sala atual se existir + initial = {} + if request.user.sala: + initial["sala"] = str(request.user.sala) + form = SelecionarSalaForm(initial=initial) + + return render(request, "profissional_saude/selecionar_sala.html", {"form": form}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9006234 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[tool:pytest] +DJANGO_SETTINGS_MODULE = sga.tests.settings_test +addopts = --html=report.html --self-contained-html \ No newline at end of file diff --git a/recepcionista/templates/recepcionista/cadastrar_paciente.html b/recepcionista/templates/recepcionista/cadastrar_paciente.html index be08355..63bd1d1 100644 --- a/recepcionista/templates/recepcionista/cadastrar_paciente.html +++ b/recepcionista/templates/recepcionista/cadastrar_paciente.html @@ -2,44 +2,110 @@ {% load tz %} {% block content %} -
    -

    Cadastrar Paciente

    -

    Preencha o formulário abaixo para cadastrar um novo paciente:

    +
    +
    +
    +

    Cadastrar Paciente

    +

    Preencha o formulário abaixo para cadastrar um novo paciente no sistema.

    +
    -
    + {% csrf_token %} -
    - {{ form.nome_completo.label_tag }} {{ form.nome_completo }} - {{ form.nome_completo.errors }} -
    -
    - {{ form.cartao_sus.label_tag }} {{ form.cartao_sus }} - {{ form.cartao_sus.errors }} -
    -
    - {{ form.horario_agendamento.label_tag }} {{ form.horario_agendamento }} -
    -
    - {{ form.profissional_saude.label_tag }} {{ form.profissional_saude }} -
    -
    - {{ form.tipo_senha.label_tag }} {{ form.tipo_senha }} -
    -
    - {{ form.telefone_celular.label_tag }} {{ form.telefone_celular }} - {% if form.telefone_celular.help_text %} - {{ form.telefone_celular.help_text }} - {% endif %} - {{ form.telefone_celular.errors }} -
    -
    - {{ form.observacoes.label_tag }} {{ form.observacoes }} + +
    + +
    + + {{ form.nome_completo }} + {% if form.nome_completo.errors %} +

    {{ form.nome_completo.errors.0 }}

    + {% endif %} +
    + + +
    + + {{ form.cartao_sus }} + {% if form.cartao_sus.errors %} +

    {{ form.cartao_sus.errors.0 }}

    + {% endif %} +
    + + +
    + + {{ form.horario_agendamento }} + {% if form.horario_agendamento.errors %} +

    {{ form.horario_agendamento.errors.0 }}

    + {% endif %} +
    + + +
    + + {{ form.profissional_saude }} + {% if form.profissional_saude.errors %} +

    {{ form.profissional_saude.errors.0 }}

    + {% endif %} +
    + + +
    + + {{ form.tipo_senha }} + {% if form.tipo_senha.errors %} +

    {{ form.tipo_senha.errors.0 }}

    + {% endif %} +
    + + +
    + + {{ form.telefone_celular }} + {% if form.telefone_celular.help_text %} +

    {{ form.telefone_celular.help_text }}

    + {% endif %} + {% if form.telefone_celular.errors %} +

    {{ form.telefone_celular.errors.0 }}

    + {% endif %} +
    + + +
    + + {{ form.observacoes }} + {% if form.observacoes.errors %} +

    {{ form.observacoes.errors.0 }}

    + {% endif %} +
    -
    - + + +
    + + Cancelar + +
    +
    ' - xss_data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, xss_data, follow=True) - self.assertEqual(resp.status_code, 200) - # Verificar que o formulário é inválido devido à validação XSS - self.assertContains(resp, "Entrada inválida: scripts não são permitidos.") - paciente = Paciente.objects.filter( - nome_completo='' - ) - self.assertFalse(paciente.exists()) - - def test_sql_injection_observacoes(self): - """Testa proteção contra SQL injection nas observações.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - malicious_data = self.valid_data.copy() - malicious_data["observacoes"] = "1' OR '1'='1" - malicious_data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, malicious_data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(observacoes="1' OR '1'='1") - self.assertTrue(paciente.exists()) - - def test_telefone_celular_formats(self): - """Testa diferentes formatos de telefone.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - valid_formats = [ - "(11) 91234-5678", - "11 91234 5678", - "11912345678", - "(11)91234-5678", - ] - - for telefone in valid_formats: - data = self.valid_data.copy() - data["nome_completo"] = f"Paciente {telefone}" - data["telefone_celular"] = telefone - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo=f"Paciente {telefone}") - self.assertTrue(paciente.exists()) - - def test_telefone_celular_invalid_formats(self): - """Testa formatos inválidos de telefone.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - invalid_formats = [ - "12345", # Muito curto - "(11) 9123-4567", # Sem 9 no início - "1191234567", # 10 dígitos - "abc123", # Não numérico - ] - - for telefone in invalid_formats: - data = self.valid_data.copy() - data["nome_completo"] = f"Paciente Inválido {telefone}" - data["telefone_celular"] = telefone - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, 200) - # Paciente não deve ser criado devido ao telefone inválido - paciente = Paciente.objects.filter( - nome_completo=f"Paciente Inválido {telefone}" - ) - self.assertFalse(paciente.exists()) - - def test_cartao_sus_validation(self): - """Testa validação do cartão SUS.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - # Cartão SUS muito longo (deve falhar) - data = self.valid_data.copy() - data["cartao_sus"] = "1" * 21 - data["nome_completo"] = "Paciente Cartão Longo" - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, 200) - # Verificar que não foi criado - paciente = Paciente.objects.filter(cartao_sus="1" * 21) - self.assertFalse(paciente.exists()) - paciente = Paciente.objects.filter(nome_completo="Paciente Cartão Longo") - self.assertFalse(paciente.exists()) # Não deve ser criado - - def test_tipo_senha_choices(self): - """Testa choices válidos para tipo_senha.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - tipos_validos = ["E", "C", "P", "G", "D", "A", "NH", "H", "U"] - for tipo in tipos_validos: - data = self.valid_data.copy() - data["nome_completo"] = f"Paciente {tipo}" - data["tipo_senha"] = tipo - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo=f"Paciente {tipo}") - self.assertTrue(paciente.exists()) - - def test_tipo_senha_invalid_choice(self): - """Testa choice inválido para tipo_senha.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - data = self.valid_data.copy() - data["nome_completo"] = "Paciente Tipo Inválido" - data["tipo_senha"] = "X" # Inválido - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo="Paciente Tipo Inválido") - self.assertFalse(paciente.exists()) - - def test_required_fields(self): - """Testa que alguns campos podem ser vazios.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - # Campos opcionais vazios, mas campos obrigatórios preenchidos - data = { - "nome_completo": "Paciente Teste", - "tipo_senha": "G", # Obrigatório - "telefone_celular": "", - "cartao_sus": "", - "horario_agendamento": "", - "profissional_saude": "", - "observacoes": "", - } - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - # Deve criar paciente - paciente = Paciente.objects.filter(nome_completo="Paciente Teste") - self.assertTrue(paciente.exists()) - - def test_optional_fields(self): - """Testa campos opcionais.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - data = { - "nome_completo": "Paciente Mínimo", - "tipo_senha": "G", - # Outros campos opcionais omitidos - } - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo="Paciente Mínimo") - self.assertTrue(paciente.exists()) - - def test_profissional_saude_optional(self): - """Testa que profissional_saude é opcional.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - data = self.valid_data.copy() - data["nome_completo"] = "Paciente Sem Profissional" - data["profissional_saude"] = "" # Vazio - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo="Paciente Sem Profissional") - self.assertTrue(paciente.exists()) - self.assertIsNone(paciente.first().profissional_saude) - - def test_horario_agendamento_validation(self): - """Testa validação de horário de agendamento.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - # Data futura - future_date = timezone.now() + timezone.timedelta(days=1) - data = self.valid_data.copy() - data["nome_completo"] = "Paciente Futuro" - data["horario_agendamento"] = future_date - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo="Paciente Futuro") - self.assertTrue(paciente.exists()) - - # Data passada (deve ser aceita) - past_date = timezone.now() - timezone.timedelta(days=1) - data["nome_completo"] = "Paciente Passado" - data["horario_agendamento"] = past_date - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo="Paciente Passado") - self.assertTrue(paciente.exists()) - - def test_permissao_apenas_recepcionista(self): - """Testa permissões de acesso.""" - url = reverse("recepcionista:cadastrar_paciente") - - # Não logado - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - - # Logado como profissional de saúde - self.client.login(cpf="22233344455", password="profpass") - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - - # Logado como administrador - self.client.login(cpf="99988877766", password="adminpass") - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - - # Logado como recepcionista (deve funcionar) - self.client.login(cpf="11122233344", password="receppass") - resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) - - def test_post_permissao_apenas_recepcionista(self): - """Testa permissões de POST.""" - url = reverse("recepcionista:cadastrar_paciente") - - # Não logado - resp = self.client.post(url, self.valid_data) - self.assertEqual(resp.status_code, 302) - - # Logado como profissional de saúde - self.client.login(cpf="22233344455", password="profpass") - resp = self.client.post(url, self.valid_data) - self.assertEqual(resp.status_code, 302) - - # Logado como recepcionista (deve funcionar) - self.client.login(cpf="11122233344", password="receppass") - resp = self.client.post(url, self.valid_data, follow=True) - self.assertEqual(resp.status_code, 200) - - def test_data_integrity_multiple_submissions(self): - """Testa integridade de dados em múltiplas submissões.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - # Submeter mesmo dados múltiplas vezes - for i in range(3): - data = self.valid_data.copy() - data["nome_completo"] = f"Paciente {i}" - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo=f"Paciente {i}") - self.assertTrue(paciente.exists()) - - # Verificar que todos foram criados - self.assertEqual( - Paciente.objects.filter(nome_completo__startswith="Paciente ").count(), 3 - ) - - def test_large_input_handling(self): - """Testa tratamento de inputs grandes.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - # Nome muito longo - data = self.valid_data.copy() - data["nome_completo"] = "A" * 300 # Maior que max_length - data["cartao_sus"] = self.get_unique_cartao_sus() - data["observacoes"] = "B" * 1000 # Campo sem limite específico - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, 200) - # Verificar se foi criado (Django pode truncar ou rejeitar) - paciente = Paciente.objects.filter(nome_completo__startswith="A" * 255) - if paciente.exists(): - self.assertTrue(True) # Aceitou truncado - else: - # Verificar se erro foi mostrado - self.assertContains(resp, "erro") - - def test_special_characters(self): - """Testa caracteres especiais.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - special_names = [ - "José da Silva", - "João & Maria", - "Ana-Maria", - "José María", - "João Paulo", - ] - - for name in special_names: - data = self.valid_data.copy() - data["nome_completo"] = name - data["cartao_sus"] = self.get_unique_cartao_sus() - resp = self.client.post(url, data, follow=True) - self.assertEqual(resp.status_code, 200) - paciente = Paciente.objects.filter(nome_completo=name) - self.assertTrue(paciente.exists()) - - def test_form_validation_errors_display(self): - """Testa exibição de erros de validação.""" - self.client.login(cpf="11122233344", password="receppass") - url = reverse("recepcionista:cadastrar_paciente") - - # Dados inválidos - invalid_data = { - "nome_completo": "", # Obrigatório vazio - "tipo_senha": "X", # Inválido - "telefone_celular": "123", # Inválido - } - - resp = self.client.post(url, invalid_data) - self.assertEqual(resp.status_code, 200) - # Verificar que erros são mostrados - self.assertContains( - resp, "Erro ao cadastrar o paciente" - ) # Mensagem de erro genérica diff --git a/recepcionista/views.py b/recepcionista/views.py index f2d18e7..c04706a 100644 --- a/recepcionista/views.py +++ b/recepcionista/views.py @@ -2,6 +2,7 @@ from django.contrib import messages from django.shortcuts import redirect, render from django.urls import reverse +from django.utils import timezone from core.decorators import recepcionista_required from core.forms import CadastrarPacienteForm @@ -18,7 +19,23 @@ def cadastrar_paciente(request): request.POST, profissionais_de_saude=profissionais_de_saude ) if form.is_valid(): - paciente = form.save() + paciente = form.save(commit=False) + + # Adicionar hora de entrada às observações + hora_entrada = timezone.now().strftime("%H:%M") + observacoes_existentes = paciente.observacoes or "" + + if observacoes_existentes.strip(): + # Se já tem observações, adiciona a hora embaixo + paciente.observacoes = ( + f"{observacoes_existentes}\nHora de entrada: {hora_entrada}" + ) + else: + # Se não tem observações, coloca apenas a hora + paciente.observacoes = f"Hora de entrada: {hora_entrada}" + + paciente.save() + messages.success( request, f"Paciente {paciente.nome_completo} cadastrado com senha: {paciente.senha}!", diff --git a/requirements-docker.txt b/requirements-docker.txt new file mode 100644 index 0000000..8a0c612 --- /dev/null +++ b/requirements-docker.txt @@ -0,0 +1,10 @@ +Django==5.2.9 +djangorestframework==3.16.1 +gunicorn==23.0.0 +gTTS==2.5.4 +whitenoise==6.11.0 +dj-database-url==3.0.1 +psycopg2-binary==2.9.11 +python-decouple==3.8 +twilio==9.8.5 +python-dotenv==1.2.1 diff --git a/requirements.txt b/requirements.txt index f785576..baf75d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==5.2.7 +Django==5.2.9 djangorestframework==3.16.1 gunicorn==23.0.0 gTTS==2.5.4 diff --git a/sga/settings.py b/sga/settings.py index a46e64e..11753ce 100644 --- a/sga/settings.py +++ b/sga/settings.py @@ -55,6 +55,7 @@ "recepcionista", "guiche", "profissional_saude", + "tests", ] MIDDLEWARE = [ @@ -175,6 +176,8 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +TEST_RUNNER = "django.test.runner.DiscoverRunner" + # Twilio Settings TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID") # <<-- Use o NOME DA VARIAVEL TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN") # <<-- Use o NOME DA VARIAVEL diff --git a/sga/tests/__init__.py b/sga/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sga/settings_test.py b/sga/tests/settings_test.py similarity index 68% rename from sga/settings_test.py rename to sga/tests/settings_test.py index 4858fe6..0ca497a 100644 --- a/sga/settings_test.py +++ b/sga/tests/settings_test.py @@ -1,6 +1,6 @@ import os -from .settings import * +from ..settings import * if os.environ.get("GITHUB_ACTIONS") == "true": # Use PostgreSQL @@ -23,10 +23,16 @@ } } -print("USANDO BANCO:", DATABASES["default"]["ENGINE"]) +# print("USANDO BANCO:", DATABASES["default"]["ENGINE"]) PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] -LOGGING["loggers"]["core.views"]["level"] = "WARNING" # type: ignore +LOGGING["loggers"]["core.views"] = {"level": "CRITICAL", "handlers": []} # type: ignore + +LOGGING["handlers"]["console"] = {"class": "logging.NullHandler"} + +LOGGING["root"] = {"handlers": [], "level": "CRITICAL"} # type: ignore + +LOGGING["disable_existing_loggers"] = True diff --git a/static/css/estilos.css b/static/css/estilos.css index b05016c..17c71ae 100644 --- a/static/css/estilos.css +++ b/static/css/estilos.css @@ -1,252 +1,149 @@ -/* static/css/estilos.css */ - -/* Geral */ -body { - font-family: 'Roboto', sans-serif; - margin: 0; - padding: 0; - background-color: #FFFFFF !important; /* Cor de fundo do body */ - color: #333; -} - -.container { - width: 80%; - margin: auto; - overflow: hidden; -} - -/* Cabeçalho */ -.topo { - background-color: #a9a7a7; /* Cinza escuro moderno */ - color: #fff; - padding: 25px 0; - overflow: hidden; - position: relative; - box-shadow: 0px 2px 5px rgba(0,0,0,0.1); -} - -.logo { - width: 150px; - float: left; -} - -/* Menu */ -.menu { - background-color: transparent; /* Remova a cor de fundo do menu */ - color: #fff; - padding: 0; /* Remova o padding do menu */ -} - -.menu ul { - margin: 0; - padding: 0; - list-style: none; -} - -.menu li { - display: inline-block; - margin: 0 15px; -} - -.menu a { - color: #F5F5DC; - text-decoration: none; - padding: 10px 15px; /* Aumente o padding para facilitar o clique */ - border-radius: 5px; - transition: background-color 0.3s ease; /* Transição suave */ -} - -.menu a:hover { - background-color: #545353; /* Cor de fundo mais clara no hover */ -} - -/* Menu Container */ -.menu-container { - display: flex; - align-items: center; - float: right; -} - -.menu-container form { - margin-left: auto; -} -/* Submenu */ -.submenu { - display: none; - position: absolute; - background-color: #444; - box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); - z-index: 1; -} - -.menu li:hover .submenu { - display: block; -} -/* Conteúdo Principal */ -main { - padding: 20px; - min-height: 600px; /* Altura mínima para o conteúdo */ -} - -/* Rodapé */ -/* Rodapé */ -.rodape { - background-color: #333; - color: #fff; - text-align: center; - padding: 10px 0; - /*position: fixed; Remova essa linha */ - bottom: 0; - width: 100%; -} - -.rodape img { - width: 100px; /* Ajuste o tamanho da logo do rodapé */ -} - -/* Formulários */ -form { - margin-top: 20px; -} - -label { - display: block; - margin-bottom: 5px; -} +/* static/css/estilos.css - Estilos complementares ao Tailwind */ +/* Formulários - Estilos específicos não cobertos pelo Tailwind */ input[type="text"], input[type="email"], input[type="password"], input[type="date"], -select { +input[type="tel"], +select, +textarea { width: 100%; - padding: 8px; - margin-bottom: 10px; - border: 1px solid #ccc; - border-radius: 4px; - box-sizing: border-box; /* Garante que o padding não aumenta o tamanho do elemento */ -} - -button[type="submit"] { - background-color: #ed3925; - color: white; - padding: 10px 20px; - border: none; - border-radius: 4px; - cursor: pointer; -} - -button[type="submit"]:hover { - background-color: #ff5e00; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + transition: all 0.2s; + background-color: #f9fafb; /* Fundo sutil para identificar campos clicáveis */ +} + +input[type="text"]:focus, +input[type="email"]:focus, +input[type="password"]:focus, +input[type="date"]:focus, +input[type="tel"]:focus, +select:focus, +textarea:focus { + outline: none; + box-shadow: 0 0 0 2px #ed3925; + border-color: #ed3925; + background-color: #ffffff; /* Fundo branco no foco */ } /* Tabelas */ table { width: 100%; border-collapse: collapse; - margin-top: 20px; + background-color: white; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + border-radius: 0.5rem; + overflow: hidden; } th, td { - border: 1px solid #ddd; - padding: 8px; + padding: 0.75rem 1.5rem; text-align: left; + border-bottom: 1px solid #e5e7eb; } th { - background-color: #f2f2f2; + background-color: #f9fafb; + font-weight: 600; + color: #111827; } -/* Alertas */ -.alert { - padding: 15px; - margin-bottom: 20px; - border: 1px solid transparent; - border-radius: 4px; +tbody tr:hover { + background-color: #f9fafb; } -.alert-success { - color: #3c763d; - background-color: #dff0d8; - border-color: #d6e9c6; +/* Botões personalizados */ +.btn-primary { + background-color: #ed3925; + color: white; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + transition: all 0.2s; + transform: scale(1); + border: none; + cursor: pointer; } -.alert-danger { - color: #a94442; - background-color: #f2dede; - border-color: #ebccd1; +.btn-primary:hover { + background-color: #dc2626; + transform: scale(1.05); } -/* Estilos para a mensagem de boas-vindas (seção de conteúdo) */ -.alert-success { - text-align: center; /* Centraliza o texto */ - margin-top: 20px; /* Adiciona um espaço acima do alerta */ - padding: 10px; /* Adiciona um preenchimento interno para melhor aparência */ +.btn-primary:focus { + outline: none; + box-shadow: 0 0 0 2px #ed3925, 0 0 0 4px rgba(237, 57, 37, 0.1); } -/* Estilos para o botão de logout */ -.logout-btn { - background-color: #e74c3c; /* Vermelho moderno */ +.btn-secondary { + background-color: #6b7280; color: white; - padding: 10px 15px; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + transition: all 0.2s; border: none; - border-radius: 5px; cursor: pointer; - font-size: 14px; - text-decoration: none; - position: absolute; - top: 20px; - right: 20px; - transition: background-color 0.3s ease; /* Transição suave */ } -.logout-btn:hover { - background-color: #c0392b; /* Vermelho mais escuro no hover */ +.btn-secondary:hover { + background-color: #4b5563; } -/*Media Queries*/ -/*Para telas menores que 768px*/ -@media (max-width: 768px) { - .menu ul { - text-align: left; /* Alinha o menu à esquerda */ - } +.btn-secondary:focus { + outline: none; + box-shadow: 0 0 0 2px #6b7280, 0 0 0 4px rgba(107, 114, 128, 0.1); +} - .menu li { - display: block; /* Exibe os itens do menu em bloco */ - margin: 10px 0; /* Adiciona margem vertical */ - } +/* Cards */ +.card { + background-color: white; + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + padding: 1.5rem; +} - .topo { - padding: 10px 0; /* Reduz o padding do cabeçalho */ - } +.card-header { + border-bottom: 1px solid #e5e7eb; + padding-bottom: 1rem; + margin-bottom: 1rem; +} - .logo { - width: 120px; /* Reduz o tamanho do logo */ - } +.card-title { + font-size: 1.25rem; + font-weight: 600; + color: #111827; +} - .container { - width: 95%; /* Aumenta a largura do container */ +/* Animações */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); } - - /* Estilos para o botão de logout */ - .logout-btn { - position: static; - margin-top: 10px; - margin-left: 0; + to { + opacity: 1; + transform: translateY(0); } +} - main { - padding: 20px; - min-height: 70vh; /* Altura mínima para o conteúdo */ - } +.fade-in { + animation: fadeIn 0.5s ease-out; +} - .rodape { - position: relative; /* Retorna o rodapé ao fluxo normal */ +/* Responsividade adicional */ +@media (max-width: 640px) { + .card { + padding: 1rem; } - .rodape img { - width: 80px; /* Reduz o tamanho do logo do rodapé */ + th, td { + padding: 0.5rem; + font-size: 0.875rem; } - } diff --git a/static/js/atividade.js b/static/js/atividade.js new file mode 100644 index 0000000..396e410 --- /dev/null +++ b/static/js/atividade.js @@ -0,0 +1,64 @@ +// static/js/atividade.js +document.addEventListener('DOMContentLoaded', function() { + let ultimaAtividade = Date.now(); + const INTERVALO_PING = 30000; // 30 segundos + const INTERVALO_INATIVIDADE = 120000; // 2 minutos + + // Função para registrar atividade + function registrarAtividade() { + ultimaAtividade = Date.now(); + + // Enviar ping para o servidor + fetch('/administrador/registrar-atividade/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + credentials: 'same-origin' + }).catch(error => { + console.log('Erro ao registrar atividade:', error); + }); + } + + // Função para obter cookie CSRF + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + // Registrar atividade em vários eventos + const eventos = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']; + + eventos.forEach(evento => { + document.addEventListener(evento, function() { + // Debounce: só registrar se passou tempo suficiente + if (Date.now() - ultimaAtividade > 5000) { // 5 segundos + registrarAtividade(); + } + }, true); + }); + + // Ping periódico a cada 30 segundos + setInterval(function() { + const tempoDesdeUltimaAtividade = Date.now() - ultimaAtividade; + + // Só enviar ping se teve atividade recente (nos últimos 2 minutos) + if (tempoDesdeUltimaAtividade < INTERVALO_INATIVIDADE) { + registrarAtividade(); + } + }, INTERVALO_PING); + + // Registrar atividade inicial + registrarAtividade(); +}); \ No newline at end of file diff --git a/staticfiles/admin/css/widgets.css b/staticfiles/admin/css/widgets.css index 538af2e..a5f615a 100644 --- a/staticfiles/admin/css/widgets.css +++ b/staticfiles/admin/css/widgets.css @@ -49,7 +49,7 @@ padding: 8px; } -.aligned .selector-chosen-title label { +.selector-chosen-title label { color: var(--header-link-color); width: 100%; } @@ -60,7 +60,7 @@ padding: 8px; } -.aligned .selector-available-title label { +.selector-available-title label { width: 100%; } diff --git a/staticfiles/admin/js/SelectFilter2.js b/staticfiles/admin/js/SelectFilter2.js index 08d47fc..970b511 100644 --- a/staticfiles/admin/js/SelectFilter2.js +++ b/staticfiles/admin/js/SelectFilter2.js @@ -72,7 +72,8 @@ Requires core.js and SelectBox.js. selector_available, interpolate(gettext('Choose all %s'), [field_name]), 'id', field_id + '_add_all', - 'class', 'selector-chooseall' + 'class', 'selector-chooseall', + 'type', 'button' ); //
      @@ -83,14 +84,16 @@ Requires core.js and SelectBox.js. quickElement('li', selector_chooser), interpolate(gettext('Choose selected %s'), [field_name]), 'id', field_id + '_add', - 'class', 'selector-add' + 'class', 'selector-add', + 'type', 'button' ); const remove_button = quickElement( 'button', quickElement('li', selector_chooser), interpolate(gettext('Remove selected %s'), [field_name]), 'id', field_id + '_remove', - 'class', 'selector-remove' + 'class', 'selector-remove', + 'type', 'button' ); //
      @@ -142,7 +145,8 @@ Requires core.js and SelectBox.js. selector_chosen, interpolate(gettext('Remove all %s'), [field_name]), 'id', field_id + '_remove_all', - 'class', 'selector-clearall' + 'class', 'selector-clearall', + 'type', 'button' ); from_box.name = from_box.name + '_old'; diff --git a/templates/base.html b/templates/base.html index d119604..39efc50 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,89 +3,202 @@ + SGA - Sistema de Gestão de Atendimento - - + + + - + + -
      - - - - - +
      + + + +
    -
    - {% if messages %} - {% for message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - - {% block content %} - {% endblock %} +
    +
    + {% if messages %} + {% for message in messages %} +
    + {{ message }} +
    + {% endfor %} + {% endif %} + + {% block content %} + {% endblock %} +
    -