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 %} +
    -