18 Commits

Author SHA1 Message Date
LS
911ead7835 fix: Corrige relacionamentos entre modelos na estrutura MVC
- Adiciona os modelos que faltavam na arquitetura MVC
- Corrige relacionamentos de referência cruzada entre modelos
- Atualiza script de preparação para criar arquivos __init__.py adequados
- Torna o script de preparação executável

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-04-23 08:16:53 -03:00
LS
91d9bef6c6 rebase usando pagamentos 2025-04-22 18:14:32 -03:00
andersonid
4742a888b7 tela de administração em ajustes 2025-04-22 18:13:33 -03:00
LS
6a3675b735 rebase usando pagamentos 2025-04-22 18:13:24 -03:00
LS
bb6e5c887b rebase usando pagamentos 2025-04-22 18:11:32 -03:00
LS
63ebf09fb6 refactor: renomeia pagamentos para comprovantes e adiciona novos tipos 2025-04-22 18:10:25 -03:00
andersonid
f87e03640d Correções nas mensagens de notificação 2025-04-22 18:10:25 -03:00
andersonid
debcbe6663 Corrige erro de sintaxe no template de login 2025-04-22 18:10:25 -03:00
andersonid
d45fefd72c mensagem de notificação movida para fora do card de formulario de login 2025-04-22 18:10:25 -03:00
LS
62aaec3fbe refactor: Implementa arquitetura MVC limpa
- Separa modelos em entidades individuais
- Cria camada de serviços para acesso a dados
- Implementa controladores para lógica de negócio
- Organiza rotas em blueprints por funcionalidade
- Adiciona documentação de arquitetura no README
- Cria script para preparação da estrutura

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-04-22 16:35:08 -03:00
andersonid
e6057cd566 fix(#11): Corrige inicialização do sistema para não recriar usuários a cada execução 2025-04-15 15:10:21 -03:00
andersonid
8255f1d933 feat(#11): Adiciona módulo create_test_users 2025-04-15 15:09:03 -03:00
andersonid
0f32eae5cf refactor(#11): Integra listagem de usuários no dashboard 2025-04-15 14:26:02 -03:00
andersonid
47f13e7c18 feat(#11): Implementa estrutura inicial da área administrativa - Cria blueprint administrativo com rotas básicas - Implementa templates base para área administrativa - Adiciona dashboard administrativo - Implementa gerenciamento de usuários - Organiza rotas em pacote separado 2025-04-15 10:49:15 -03:00
andersonid
53769cf080 Adicionado parâmetro de versão aleatório no CSS para evitar cache 2025-04-15 10:21:41 -03:00
andersonid
92bc21dbd8 Tela inicial de administração desenvolvida. 2025-04-15 10:19:59 -03:00
andersonid
5057802220 tela de administração em ajustes 2025-04-13 22:48:27 -03:00
andersonid
e43b089155 fix: Correções na página de administração e suas dependências 2025-04-13 22:30:05 -03:00
59 changed files with 4451 additions and 830 deletions

View File

@@ -1,19 +1,7 @@
.PHONY: install run test clean refresh
install: install:
pip install -r requirements.txt pip install -r requirements.txt
pip install pytest pytest-cov
clean: clean:
find . -type d -name "__pycache__" -exec rm -r {} +
find . -type f -name "*.pyc" -delete
find . -type f -name "*.pyo" -delete
find . -type f -name "*.pyd" -delete
find . -type f -name ".coverage" -delete
find . -type d -name "*.egg-info" -exec rm -r {} +
find . -type d -name "*.egg" -exec rm -r {} +
find . -type d -name ".pytest_cache" -exec rm -r {} +
find . -type d -name "htmlcov" -exec rm -r {} +
rm -rf ~/.local/share/controles/database.db* rm -rf ~/.local/share/controles/database.db*
rm -f admin_qr.png rm -f admin_qr.png
@@ -23,16 +11,13 @@ init-db: clean
seed: init-db seed: init-db
python seed.py python seed.py
init:
python app.py --init
run: run:
python app.py python app.py
run-with-seed: seed run run-with-seed: seed init run
reset-admin: clean reset-admin: clean
python create_admin.py python create_admin.py
test:
pytest tests/ --cov=app --cov=functions --cov-report=term-missing
refresh: clean install test
python app.py

184
README.md
View File

@@ -1,134 +1,116 @@
# Sistema de Controle de Militantes # Sistema de Controles
Sistema para gerenciamento de militantes, células, setores e comitês regionais. Sistema de gestão para controle de militantes, pagamentos, cotas e relatórios.
## Estrutura de Permissões (RBAC) ## Arquitetura MVC
O sistema utiliza um sistema de controle de acesso baseado em papéis (RBAC) com a seguinte hierarquia: O projeto segue a arquitetura Model-View-Controller (MVC) para separação de responsabilidades:
### Níveis de Papéis ### Models
1. **Militante Básico** (Nível 1) Os modelos representam as entidades do sistema e estão organizados em:
- Visualizar próprios dados
- Editar próprios dados
- Visualizar dados da célula
2. **Secretário de Célula** (Nível 2) - **models/entities/**: Classes de entidades do banco de dados (SQLAlchemy)
- Todas as permissões do Militante Básico - `base.py`: Configuração do SQLAlchemy e classe Base
- Gerenciar membros da célula - `usuario.py`: Modelo de usuário
- Criar membros na célula - `militante.py`: Modelo de militante
- Visualizar relatórios da célula - `cota_mensal.py`: Modelo de cota mensal
- etc.
3. **Membro de Setor** (Nível 3) ### Controllers
- Todas as permissões do Secretário de Célula
- Visualizar relatórios do setor
4. **Secretário de Setor** (Nível 4) Os controladores contêm a lógica de negócio e manipulam os dados dos modelos:
- Todas as permissões do Membro de Setor
- Gerenciar células do setor
- Criar células no setor
5. **Membro de CR** (Nível 5) - **controllers/**: Implementação dos controladores
- Todas as permissões do Secretário de Setor - `auth_controller.py`: Controle de autenticação
- Visualizar relatórios do CR - `usuario_controller.py`: Operações com usuários
- `militante_controller.py`: Operações com militantes
- `home_controller.py`: Controlador da página inicial
- etc.
6. **Secretário de CR** (Nível 6) ### Views
- Todas as permissões do Membro de CR
- Gerenciar setores do CR
- Criar setores no CR
7. **Membro do CC** (Nível 7) As views são os templates que exibem os dados para o usuário:
- Todas as permissões do Secretário de CR
- Visualizar relatórios nacionais
8. **Secretário Geral** (Nível 8) - **templates/**: Templates Jinja2
- Todas as permissões do Membro do CC - Organizados por funcionalidade (admin, militantes, cotas, etc.)
- Gerenciar CRs
- Criar CRs ### Services
- Configurar sistema
Camada adicional para encapsular a lógica de acesso a dados:
- **services/**: Serviços para acesso a dados
- `database_service.py`: Gerenciamento de conexões com o banco
- `usuario_service.py`: Acesso a dados de usuários
- `militante_service.py`: Acesso a dados de militantes
- etc.
### Routes
Rotas da aplicação organizadas em blueprints:
- **routes/**: Módulos de rotas (Flask Blueprints)
- `main.py`: Rotas principais
- `auth.py`: Rotas de autenticação
- `admin.py`: Rotas administrativas
- `militante.py`: Rotas para gerenciamento de militantes
- etc.
## Instalação ## Instalação
1. Clone o repositório 1. Clone o repositório:
2. Crie um ambiente virtual:
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
# ou
venv\Scripts\activate # Windows
``` ```
git clone [URL_DO_REPOSITORIO]
```
2. Crie e ative um ambiente virtual:
```
python -m venv myenv
source myenv/bin/activate # Linux/Mac
myenv\Scripts\activate # Windows
```
3. Instale as dependências: 3. Instale as dependências:
```bash ```
pip install -r requirements.txt pip install -r requirements.txt
``` ```
4. Execute as migrações do banco de dados:
```bash 4. Inicialize o banco de dados:
python sql/migrate_db.py
``` ```
5. Configure as variáveis de ambiente no arquivo `.env`: python app.py --init
```
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=sua_chave_secreta
MAIL_SERVER=seu_servidor_smtp
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=seu_email
MAIL_PASSWORD=sua_senha
```
6. Execute o aplicativo:
```bash
flask run
``` ```
## Uso 5. Execute a aplicação:
```
python app.py
```
### Decoradores de Permissão ## Credenciais padrão
O sistema fornece três decoradores para controle de acesso: - **Administrador**:
- Usuário: admin
- Senha: admin123
1. `@require_permission(permission_name)` ## Desenvolvimento
- Verifica se o usuário tem uma permissão específica
- Exemplo: `@require_permission('create_cell_member')`
2. `@require_role(role_name)` Para adicionar novos recursos, siga a arquitetura MVC:
- Verifica se o usuário tem um papel específico
- Exemplo: `@require_role('Secretário de Célula')`
3. `@require_minimum_role(min_level)` 1. Crie modelos necessários em `models/entities/`
- Verifica se o usuário tem um papel com nível mínimo 2. Implemente serviços para acesso a dados em `services/`
- Exemplo: `@require_minimum_role(Role.SECRETARIO_CR)` 3. Crie controladores com lógica de negócio em `controllers/`
4. Adicione rotas em módulos existentes ou crie novos em `routes/`
5. Desenvolva templates em `templates/`
### Verificando Permissões no Código ## Testes
```python Execute os testes usando pytest:
# Verificar se um usuário tem uma permissão
if user.has_permission('create_cell_member'):
# Faça algo
# Verificar se um usuário tem um papel ```
if user.has_role('Secretário de Célula'): python -m pytest
# Faça algo
# Obter o papel mais alto do usuário
highest_role = user.get_highest_role()
if highest_role and highest_role.nivel >= Role.SECRETARIO_CR:
# Faça algo
``` ```
## Estrutura do Banco de Dados Ou use o script de teste:
O sistema utiliza as seguintes tabelas para o RBAC: ```
./run_tests.sh
- `roles`: Armazena os papéis disponíveis ```
- `permissions`: Armazena as permissões disponíveis
- `role_permissions`: Mapeia papéis para permissões
- `user_roles`: Mapeia usuários para papéis
## Segurança
- Todas as senhas são armazenadas com hash bcrypt
- Sessões expiram após período de inatividade
- Controle de acesso granular baseado em papéis
- Proteção contra CSRF
- Validação de entrada de dados

1001
app.py

File diff suppressed because it is too large Load Diff

123
app.py.new Normal file
View File

@@ -0,0 +1,123 @@
from flask import Flask
from flask_bootstrap import Bootstrap5
from flask_mail import Mail
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv
import os
import secrets
import logging
# Importações de configurações
from models.entities.base import Base, engine
from routes.main import main_bp
from routes.admin import admin_bp
from routes.auth import auth_bp
from routes.militante import militante_bp
from routes.pagamento import pagamento_bp
from routes.relatorio import relatorio_bp
from routes.cota import cota_bp
# Configuração do logger
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Carregar variáveis de ambiente
load_dotenv()
def create_app():
"""Factory para criação da aplicação Flask"""
app = Flask(__name__)
# Configuração secreta
app.secret_key = os.getenv('SECRET_KEY', secrets.token_hex(16))
# Configuração de Bootstrap
bootstrap = Bootstrap5(app)
# Registrar blueprints
app.register_blueprint(main_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(militante_bp)
app.register_blueprint(pagamento_bp)
app.register_blueprint(relatorio_bp)
app.register_blueprint(cota_bp)
# Configurar proteção CSRF
csrf = CSRFProtect()
csrf.init_app(app)
app.config['WTF_CSRF_CHECK_DEFAULT'] = False
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken']
# Configurar Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
# Função para carregar usuário no login_manager
from models.entities.usuario import Usuario
from services.database_service import DatabaseService
from sqlalchemy.orm import joinedload
@login_manager.user_loader
def load_user(user_id):
"""Carrega o usuário pelo ID"""
db = DatabaseService.get_db_connection()
try:
user = db.query(Usuario).options(
joinedload(Usuario.roles)
).get(user_id)
return user
finally:
db.close()
# Adicionar filtros Jinja2
@app.template_filter('bitwise_and')
def bitwise_and(value1, value2):
"""Filtro para operação bit a bit AND"""
return value1 & value2
# Configurar Flask-Mail
app.config['MAIL_SERVER'] = os.getenv('MAIL_SERVER', 'smtp.gmail.com')
app.config['MAIL_PORT'] = int(os.getenv('MAIL_PORT', 587))
app.config['MAIL_USE_TLS'] = os.getenv('MAIL_USE_TLS', 'True').lower() == 'true'
app.config['MAIL_USERNAME'] = os.getenv('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.getenv('MAIL_PASSWORD')
app.config['MAIL_DEFAULT_SENDER'] = os.getenv('MAIL_DEFAULT_SENDER')
# Inicializar Mail
mail = Mail(app)
return app
def init_system():
"""Inicializa o sistema com banco de dados e usuários padrão"""
from functions.database import init_database
# Inicializar banco de dados
logger.info("Inicializando banco de dados...")
init_database()
# Outros procedimentos de inicialização podem ser adicionados aqui
def main():
"""Inicializa e retorna a aplicação Flask"""
return create_app()
# Criar a aplicação
app = main()
if __name__ == '__main__':
import sys
# Verificar se é para inicializar o sistema
if '--init' in sys.argv:
init_system()
else:
# Executar a aplicação
app.run(
host='0.0.0.0',
port=5000,
debug=os.getenv('FLASK_ENV') == 'development'
)

View File

@@ -0,0 +1,124 @@
from flask import session, flash, redirect, url_for, request
from flask_login import login_user, logout_user, current_user
from datetime import datetime
import pyotp
import qrcode
from io import BytesIO
import base64
from models.entities.usuario import Usuario
from services.database_service import DatabaseService
class AuthController:
"""Controlador para funções de autenticação"""
@staticmethod
def login():
"""Processa o login de usuário"""
if request.method != "POST":
return False
email_or_username = request.form.get("email")
password = request.form.get("password")
otp = request.form.get("otp")
if not all([email_or_username, password]):
flash("Email/usuário e senha são obrigatórios.", "danger")
return False
db = DatabaseService.get_db_connection()
try:
# Tenta encontrar o usuário por email ou username
user = db.query(Usuario).filter(
(Usuario.email == email_or_username) |
(Usuario.username == email_or_username)
).first()
if not user or not user.check_password(password):
flash("Email/usuário ou senha incorretos.", "danger")
return False
# Verificar OTP se o usuário tiver configurado
if user.otp_secret and not otp:
flash("Código OTP é obrigatório para sua conta.", "danger")
return False
if user.otp_secret and not user.verify_otp(otp):
flash("Código OTP inválido.", "danger")
return False
# Atualizar último login
user.ultimo_login = datetime.utcnow()
db.commit()
# Fazer login e setar sessão
login_user(user)
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin
return True
finally:
db.close()
@staticmethod
def logout():
"""Processa o logout de usuário"""
db = DatabaseService.get_db_connection()
try:
user = current_user
if user.is_authenticated:
user.logout()
db.commit()
logout_user()
flash('Logout realizado com sucesso!', 'success')
return True
finally:
db.close()
@staticmethod
def alterar_senha(user_id, senha_atual, nova_senha, confirmar_senha):
"""Altera a senha do usuário"""
if not all([senha_atual, nova_senha, confirmar_senha]):
flash("Todos os campos são obrigatórios.", "error")
return False
if nova_senha != confirmar_senha:
flash("As senhas não coincidem.", "error")
return False
db = DatabaseService.get_db_connection()
try:
user = db.query(Usuario).get(user_id)
if not user:
flash("Usuário não encontrado.", "error")
return False
if not user.check_password(senha_atual):
flash("Senha atual incorreta.", "error")
return False
user.set_password(nova_senha)
db.commit()
flash("Senha alterada com sucesso!", "success")
return True
finally:
db.close()
@staticmethod
def generate_qr_code(user):
"""Gera um QR code para o usuário"""
if not user.otp_secret:
user.otp_secret = pyotp.random_base32()
totp = pyotp.TOTP(user.otp_secret)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp.provisioning_uri(user.email, issuer_name="Sistema de Controles"))
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
qr_code = base64.b64encode(buffer.getvalue()).decode('utf-8')
return qr_code

View File

@@ -0,0 +1,80 @@
from flask import session, render_template
from datetime import datetime
from sqlalchemy.sql import func
from models.entities.militante import Militante
from models.entities.cota_mensal import CotaMensal
from models.entities.material_vendido import MaterialVendido
from models.entities.assinatura_anual import AssinaturaAnual
from models.entities.pagamento import Pagamento
from models.entities.tipo_pagamento import TipoPagamento
from models.entities.usuario import Usuario
from services.database_service import DatabaseService
class HomeController:
"""Controlador para página inicial e dashboard"""
@staticmethod
def dashboard():
"""Gera dados para o dashboard principal"""
db = DatabaseService.get_db_connection()
try:
# Buscar nome do usuário
usuario = db.query(Usuario).get(session.get('user_id'))
nome_usuario = usuario.username if usuario else "Usuário"
# Formatar data atual em português
data_atual = datetime.now().strftime("%d de %B de %Y")
# Buscar dados para o dashboard
total_militantes = db.query(Militante).count()
total_cotas = db.query(func.sum(CotaMensal.valor_novo)).scalar() or 0
total_materiais = db.query(MaterialVendido).count()
total_assinaturas = db.query(AssinaturaAnual).count()
# Buscar últimos militantes cadastrados
ultimos_militantes = db.query(Militante)\
.order_by(Militante.id.desc())\
.limit(5)\
.all()
# Buscar últimos pagamentos
ultimos_pagamentos = db.query(Pagamento)\
.join(Militante)\
.order_by(Pagamento.data_pagamento.desc())\
.limit(5)\
.all()
# Buscar tipos de pagamento
tipos_pagamento = db.query(TipoPagamento).all()
return {
'nome_usuario': nome_usuario,
'data_atual': data_atual,
'total_militantes': total_militantes,
'total_cotas': "{:.2f}".format(total_cotas),
'total_materiais': total_materiais,
'total_assinaturas': total_assinaturas,
'ultimos_militantes': ultimos_militantes,
'ultimos_pagamentos': ultimos_pagamentos,
'tipos_pagamento': tipos_pagamento
}
except Exception as e:
print(f"Erro ao carregar dashboard: {e}")
import traceback
traceback.print_exc()
return {
'nome_usuario': "Usuário",
'data_atual': datetime.now().strftime("%d/%m/%Y"),
'total_militantes': 0,
'total_cotas': "0.00",
'total_materiais': 0,
'total_assinaturas': 0,
'ultimos_militantes': [],
'ultimos_pagamentos': [],
'tipos_pagamento': []
}
finally:
db.close()

View File

@@ -0,0 +1,270 @@
from flask import request, jsonify, flash
from datetime import datetime
from werkzeug.exceptions import NotFound
from services.militante_service import MilitanteService
from models.entities.militante import Militante, EstadoMilitante
from models.entities.endereco import Endereco
from models.entities.email_militante import EmailMilitante
from utils.date_utils import validar_data, converter_data, validar_sequencia_datas, calcular_idade
class MilitanteController:
"""Controlador para operações com militantes"""
@staticmethod
def listar_militantes():
"""Lista todos os militantes"""
return MilitanteService.listar_militantes()
@staticmethod
def buscar_militante(militante_id):
"""Busca um militante pelo ID"""
militante = MilitanteService.buscar_militante(militante_id)
if not militante:
raise NotFound(f"Militante com ID {militante_id} não encontrado")
return militante
@staticmethod
def criar_militante(form_data):
"""Cria um novo militante"""
# Validar CPF
from functions.validations import validar_cpf
cpf = form_data.get('cpf')
if not validar_cpf(cpf):
return jsonify({
'status': 'error',
'message': 'CPF inválido'
}), 400
# Verificar se já existe militante com este CPF
if MilitanteService.buscar_por_cpf(cpf):
return jsonify({
'status': 'error',
'message': 'CPF já cadastrado'
}), 400
try:
# Criar endereço
endereco = Endereco(
cep=form_data.get('cep'),
estado=form_data.get('estado'),
cidade=form_data.get('cidade'),
bairro=form_data.get('bairro'),
rua=form_data.get('logradouro'),
numero=form_data.get('numero'),
complemento=form_data.get('complemento')
)
# Salvar endereço para obter ID
endereco_id = MilitanteService.salvar_endereco(endereco)
# Processar datas
data_nascimento = datetime.strptime(form_data.get('data_nascimento'), '%Y-%m-%d') if form_data.get('data_nascimento') else None
data_entrada_oci = datetime.strptime(form_data.get('data_entrada_oci'), '%Y-%m-%d') if form_data.get('data_entrada_oci') else None
data_efetivacao_oci = datetime.strptime(form_data.get('data_efetivacao_oci'), '%Y-%m-%d') if form_data.get('data_efetivacao_oci') else None
# Criar militante
militante = Militante(
# Dados Básicos
nome=form_data.get('nome'),
cpf=cpf,
titulo_eleitoral=form_data.get('titulo_eleitoral'),
data_nascimento=data_nascimento,
data_entrada_oci=data_entrada_oci,
data_efetivacao_oci=data_efetivacao_oci,
# Contato
telefone1=form_data.get('telefone1'),
telefone2=form_data.get('telefone2'),
endereco_id=endereco_id,
# Profissional
profissao=form_data.get('profissao'),
regime_trabalho=form_data.get('regime_trabalho'),
empresa=form_data.get('empresa'),
contratante=form_data.get('contratante'),
# Acadêmico
instituicao_ensino=form_data.get('instituicao_ensino'),
tipo_instituicao=form_data.get('tipo_instituicao'),
# Sindical
sindicato=form_data.get('sindicato'),
cargo_sindical=form_data.get('cargo_sindical'),
central_sindical=form_data.get('central_sindical'),
dirigente_sindical=form_data.get('dirigente_sindical') == 'on',
# Organização
estado=EstadoMilitante(form_data.get('estado', 'ATIVO')),
celula_id=form_data.get('celula_id', type=int),
responsabilidades=form_data.get('responsabilidades', type=int, default=0),
# Por padrão, todo novo militante é aspirante
aspirante=True,
data_inicio_aspirante=datetime.now()
)
# Salvar militante para obter ID
militante_id = MilitanteService.salvar_militante(militante)
# Adicionar email principal se fornecido
email = form_data.get('email')
if email:
email_militante = EmailMilitante(
endereco_email=email,
militante_id=militante_id
)
MilitanteService.salvar_email_militante(email_militante)
return jsonify({
'status': 'success',
'message': 'Militante criado com sucesso!',
'id': militante_id
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Erro ao criar militante: {str(e)}'
}), 500
@staticmethod
def atualizar_militante(militante_id, form_data):
"""Atualiza um militante existente"""
try:
militante = MilitanteService.buscar_militante(militante_id)
if not militante:
return jsonify({
'status': 'error',
'message': 'Militante não encontrado'
}), 404
# Obter dados do formulário
nome = form_data.get('nome')
cpf = form_data.get('cpf')
titulo_eleitoral = form_data.get('titulo_eleitoral')
data_nascimento = form_data.get('data_nascimento')
data_entrada_oci = form_data.get('data_entrada_oci')
data_efetivacao_oci = form_data.get('data_efetivacao_oci')
telefone1 = form_data.get('telefone1')
telefone2 = form_data.get('telefone2')
email = form_data.get('email')
# Validar e converter datas
try:
data_nascimento = converter_data(data_nascimento) if data_nascimento else None
data_entrada_oci = converter_data(data_entrada_oci) if data_entrada_oci else None
data_efetivacao_oci = converter_data(data_efetivacao_oci) if data_efetivacao_oci else None
# Validar sequência lógica das datas
validar_sequencia_datas(
data_nascimento=data_nascimento,
data_entrada=data_entrada_oci,
data_efetivacao=data_efetivacao_oci
)
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Atualizar dados básicos
if nome: militante.nome = nome
if cpf: militante.cpf = cpf
if titulo_eleitoral: militante.titulo_eleitoral = titulo_eleitoral
militante.data_nascimento = data_nascimento
militante.data_entrada_oci = data_entrada_oci
militante.data_efetivacao_oci = data_efetivacao_oci
militante.telefone1 = telefone1
militante.telefone2 = telefone2
# Calcular idade
if data_nascimento:
militante.idade = calcular_idade(data_nascimento)
# Atualizar ou criar email
if email:
MilitanteService.atualizar_email_militante(militante_id, email)
# Salvar alterações
MilitanteService.salvar_militante(militante)
return jsonify({
'status': 'success',
'message': 'Militante atualizado com sucesso',
'data': {
'nome': militante.nome,
'cpf': militante.cpf,
'idade': militante.idade if hasattr(militante, 'idade') else None,
'emails': [e.endereco_email for e in militante.emails],
'telefone1': militante.telefone1,
'celula_id': str(militante.celula_id) if militante.celula_id else None,
'responsabilidades_valor': militante.responsabilidades
}
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Erro ao atualizar militante: {str(e)}'
}), 500
@staticmethod
def excluir_militante(militante_id):
"""Exclui um militante"""
try:
if MilitanteService.excluir_militante(militante_id):
flash('Militante excluído com sucesso!', 'success')
return True
else:
flash('Militante não encontrado', 'danger')
return False
except Exception as e:
flash(f'Erro ao excluir militante: {str(e)}', 'danger')
return False
@staticmethod
def buscar_dados_militante(militante_id):
"""Busca os dados de um militante específico"""
militante = MilitanteService.buscar_militante(militante_id)
if not militante:
return jsonify({
'status': 'error',
'message': 'Militante não encontrado'
}), 404
# Função auxiliar para formatar data com validação
def formatar_data_segura(data):
try:
if not data:
return None
return data.strftime('%Y-%m-%d')
except Exception as e:
print(f"Erro ao formatar data: {str(e)}, valor: {data}")
return None
# Formatar datas com validação
data_nascimento = formatar_data_segura(militante.data_nascimento)
data_entrada_oci = formatar_data_segura(militante.data_entrada_oci)
data_efetivacao_oci = formatar_data_segura(militante.data_efetivacao_oci)
return jsonify({
'status': 'success',
'id': militante.id,
'nome': militante.nome,
'cpf': militante.cpf,
'titulo_eleitoral': militante.titulo_eleitoral,
'data_nascimento': data_nascimento,
'data_entrada_oci': data_entrada_oci,
'data_efetivacao_oci': data_efetivacao_oci,
'emails': [email.endereco_email for email in militante.emails] if militante.emails else [],
'telefone1': militante.telefone1,
'telefone2': militante.telefone2,
'celula_id': militante.celula_id,
'responsabilidades_valor': militante.responsabilidades,
'sindicato': militante.sindicato,
'cargo_sindical': militante.cargo_sindical,
'central_sindical': militante.central_sindical,
'dirigente_sindical': militante.dirigente_sindical
})

View File

@@ -0,0 +1,202 @@
from flask import request, jsonify, flash, session
from flask_login import current_user
from datetime import datetime
import secrets
import pyotp
from models.entities.usuario import Usuario
from services.usuario_service import UsuarioService
from services.database_service import DatabaseService
class UsuarioController:
"""Controlador para operações com usuários"""
@staticmethod
def listar_usuarios():
"""Lista todos os usuários do sistema"""
return UsuarioService.listar_usuarios()
@staticmethod
def buscar_usuario(user_id):
"""Busca um usuário pelo ID"""
return UsuarioService.buscar_usuario(user_id)
@staticmethod
def criar_usuario(data):
"""Cria um novo usuário"""
# Verificar campos obrigatórios
required_fields = ['username', 'password', 'email']
for field in required_fields:
if field not in data:
flash(f'Campo {field} é obrigatório.', 'danger')
return False
# Verificar se usuário já existe
if UsuarioService.buscar_por_username(data['username']):
flash('Nome de usuário já existe.', 'danger')
return False
try:
# Criar usuário
usuario = Usuario(
username=data['username'],
email=data['email'],
nome=data.get('nome'),
is_admin=data.get('is_admin', False)
)
# Definir senha
usuario.set_password(data['password'])
# Gerar OTP secret
usuario.otp_secret = pyotp.random_base32()
# Definir outros campos
if 'role_id' in data:
usuario.role_id = data['role_id']
if 'setor_id' in data:
usuario.setor_id = data['setor_id']
# Salvar no banco
result = UsuarioService.salvar_usuario(usuario)
if result:
flash('Usuário cadastrado com sucesso!', 'success')
return True
else:
flash('Erro ao cadastrar usuário.', 'danger')
return False
except Exception as e:
flash(f'Erro ao cadastrar usuário: {str(e)}', 'danger')
return False
@staticmethod
def atualizar_usuario(user_id, data):
"""Atualiza um usuário existente"""
usuario = UsuarioService.buscar_usuario(user_id)
if not usuario:
flash('Usuário não encontrado.', 'danger')
return False
# Atualizar campos
if 'email' in data:
usuario.email = data['email']
if 'nome' in data:
usuario.nome = data['nome']
if 'role_id' in data:
usuario.role_id = data['role_id']
if 'setor_id' in data:
usuario.setor_id = data['setor_id']
if 'is_admin' in data:
usuario.is_admin = data['is_admin']
if 'password' in data and data['password']:
usuario.set_password(data['password'])
# Salvar alterações
result = UsuarioService.salvar_usuario(usuario)
if result:
flash('Usuário atualizado com sucesso!', 'success')
return True
else:
flash('Erro ao atualizar usuário.', 'danger')
return False
@staticmethod
def toggle_status(user_id):
"""Ativa/desativa um usuário"""
if not current_user.is_admin:
return jsonify({
'success': False,
'error': 'Você não tem permissão para alterar o status de usuários.'
}), 403
usuario = UsuarioService.buscar_usuario(user_id)
if not usuario:
return jsonify({
'success': False,
'error': 'Usuário não encontrado.'
}), 404
usuario.ativo = not usuario.ativo
if UsuarioService.salvar_usuario(usuario):
return jsonify({
'success': True,
'message': f'Usuário {\'ativado\' if usuario.ativo else \'desativado\'}'
})
else:
return jsonify({
'success': False,
'error': 'Erro ao salvar alterações.'
}), 500
@staticmethod
def reset_password(user_id):
"""Reseta a senha de um usuário"""
usuario = UsuarioService.buscar_usuario(user_id)
if not usuario:
flash('Usuário não encontrado.', 'danger')
return False, None
# Gerar nova senha
new_password = secrets.token_urlsafe(8)
usuario.set_password(new_password)
# Salvar alterações
if UsuarioService.salvar_usuario(usuario):
return True, new_password
else:
flash('Erro ao resetar senha.', 'danger')
return False, None
@staticmethod
def reset_otp(user_id):
"""Reseta o OTP de um usuário"""
usuario = UsuarioService.buscar_usuario(user_id)
if not usuario:
flash('Usuário não encontrado.', 'danger')
return False
# Gerar novo OTP secret
usuario.otp_secret = pyotp.random_base32()
# Salvar alterações
if UsuarioService.salvar_usuario(usuario):
flash(f'OTP resetado com sucesso para {usuario.email}.', 'success')
return True
else:
flash('Erro ao resetar OTP.', 'danger')
return False
@staticmethod
def check_session():
"""Verifica status da sessão"""
if 'user_id' not in session:
return {'status': 'expired'}
if 'last_activity' in session:
last_activity = datetime.fromtimestamp(session['last_activity'])
now = datetime.now()
if now - last_activity > timedelta(hours=2):
# Registrar o logout por timeout
try:
user = UsuarioService.buscar_usuario(session.get('user_id'))
if user:
user.ultimo_logout = datetime.now()
user.motivo_logout = "Timeout de sessão"
UsuarioService.salvar_usuario(user)
except Exception as e:
print(f"Erro ao registrar logout por timeout: {e}")
session.clear()
return {'status': 'expired'}
return {'status': 'active'}

View File

@@ -41,37 +41,111 @@ def generate_qr_code(user):
return qr_path, otp_uri return qr_path, otp_uri
def create_admin_user(): def create_admin_user():
"""Cria o usuário admin do sistema""" """Cria ou atualiza o usuário admin"""
session = get_db_connection()
try: try:
# Buscar role de administrador # Inicializar banco de dados
admin_role = session.query(Role).filter_by(nome="Administrador").first() init_database()
if not admin_role:
print("Role de administrador não encontrada!") # Criar sessão
return db = get_db_connection()
# Verificar se o usuário admin já existe try:
if not session.query(Usuario).filter_by(username="admin").first(): # Verificar se já existe um usuário admin
admin = Usuario( admin = db.query(Usuario).filter_by(username="admin").first()
username="admin",
email="admin@example.com", if admin:
is_admin=True print("\n=== Usuário Admin Encontrado ===")
) if not admin.otp_secret:
admin.set_password("admin123") print("Gerando novo segredo OTP...")
admin.tipo = "ADMIN" admin.generate_otp_secret()
admin.roles.append(admin_role) db.commit()
session.add(admin) else:
session.commit() print("\n=== Criando Novo Usuário Admin ===")
print("Usuário admin criado com sucesso!") # Criar novo usuário admin
else: admin = Usuario(
print("Usuário admin já existe!") username="admin",
email="admin@example.com",
is_admin=True
)
admin.set_password("admin123")
admin.generate_otp_secret()
# Buscar ou criar role de admin
admin_role = db.query(Role).filter_by(nome="admin").first()
if not admin_role:
admin_role = Role(nome="admin", nivel=0) # Nível 0 é o mais alto
db.add(admin_role)
# Adicionar role ao usuário
admin.roles.append(admin_role)
# Adicionar e fazer commit
db.add(admin)
db.commit()
# Gerar QR code apenas se solicitado ou se for novo usuário
if not os.path.exists('admin_qr.png'):
qr_path, otp_uri = generate_qr_code(admin)
print("\n=== QR Code Gerado ===")
print(f"QR Code salvo em: {qr_path}")
print(f"URI do OTP: {otp_uri}")
else:
print("\n=== QR Code Existente ===")
print("Usando QR Code existente em: admin_qr.png")
qr_path = 'admin_qr.png'
# Mostrar informações
print("\n=== Informações do Admin ===")
print(f"Username: {admin.username}")
print(f"Email: {admin.email}")
print(f"Senha: admin123")
print(f"Segredo OTP: {admin.otp_secret}")
# Gerar código atual para verificação
totp = pyotp.TOTP(admin.otp_secret)
current_code = totp.now()
print("\n=== Verificação do OTP ===")
print(f"Código OTP atual: {current_code}")
print(f"Verificação do código: {totp.verify(current_code)}")
print("\n=== Instruções para Configuração ===")
print("1. Instale um aplicativo autenticador no seu celular")
print(" (Google Authenticator, Microsoft Authenticator, etc)")
print("2. Abra o aplicativo")
print("3. Selecione a opção para adicionar uma nova conta")
print("4. Escaneie o QR Code salvo em:", qr_path)
print("\nOU configure manualmente:")
print(f"- Nome da conta: {admin.username}")
print(f"- Segredo: {admin.otp_secret}")
print("- Tipo: Baseado em tempo (TOTP)")
print("- Algoritmo: SHA1")
print("- Dígitos: 6")
print("- Intervalo: 30 segundos")
# Verificação final
print("\n=== Teste de Verificação ===")
test_code = totp.now()
print(f"Código de teste: {test_code}")
is_valid = admin.verify_otp(test_code)
print(f"Verificação do código: {'Sucesso' if is_valid else 'Falha'}")
if not is_valid:
print("\nALERTA: Verificação do OTP falhou!")
print("Por favor, verifique se o segredo OTP está correto.")
# Fazer commit final para garantir que tudo foi salvo
db.commit()
except Exception as e:
db.rollback()
raise e
finally:
db.close()
except Exception as e: except Exception as e:
print(f"Erro ao criar usuário admin: {e}") print(f"\nErro durante a execução: {e}")
session.rollback() import traceback
raise traceback.print_exc()
finally:
session.close()
if __name__ == "__main__": if __name__ == "__main__":
create_admin_user() create_admin_user()

View File

@@ -1,65 +1,56 @@
from functions.database import Usuario, Role, get_db_connection from functions.database import get_db_connection, Usuario, Role
from werkzeug.security import generate_password_hash
def create_test_users(): def create_test_users():
"""Cria usuários de teste para o sistema""" """Cria usuários de teste"""
session = get_db_connection() db = get_db_connection()
try: try:
# Buscar roles # Lista de usuários de teste
secretario_celula = session.query(Role).filter_by(nivel=Role.SECRETARIO_CELULA).first() test_users = [
secretario_setor = session.query(Role).filter_by(nivel=Role.SECRETARIO_SETOR).first()
secretario_cr = session.query(Role).filter_by(nivel=Role.SECRETARIO_CR).first()
secretario_geral = session.query(Role).filter_by(nivel=Role.SECRETARIO_GERAL).first()
# Criar usuários de teste
usuarios = [
{ {
'username': 'celula', 'username': 'aligner',
'email': 'celula@example.com', 'email': 'aligner@test.com',
'password': 'celula123', 'password': 'Test123!@#',
'role': secretario_celula, 'is_admin': False
'tipo': 'SECRETARIO_CELULA'
}, },
{ {
'username': 'setor', 'username': 'tester',
'email': 'setor@example.com', 'email': 'tester@test.com',
'password': 'setor123', 'password': 'Test123!@#',
'role': secretario_setor, 'is_admin': False
'tipo': 'SECRETARIO_SETOR'
}, },
{ {
'username': 'cr', 'username': 'deployer',
'email': 'cr@example.com', 'email': 'deployer@test.com',
'password': 'cr123', 'password': 'Test123!@#',
'role': secretario_cr, 'is_admin': False
'tipo': 'SECRETARIO_CR'
},
{
'username': 'geral',
'email': 'geral@example.com',
'password': 'geral123',
'role': secretario_geral,
'tipo': 'SECRETARIO_GERAL'
} }
] ]
for user_data in usuarios: # Criar cada usuário
# Verificar se o usuário já existe for user_data in test_users:
if not session.query(Usuario).filter_by(username=user_data['username']).first(): user = db.query(Usuario).filter_by(username=user_data['username']).first()
if not user:
user = Usuario( user = Usuario(
username=user_data['username'], username=user_data['username'],
email=user_data['email'] email=user_data['email'],
is_admin=user_data['is_admin']
) )
user.set_password(user_data['password']) user.set_password(user_data['password'])
user.tipo = user_data['tipo'] db.add(user)
user.roles.append(user_data['role']) print(f"Usuário {user_data['username']} criado")
session.add(user) else:
print(f"Usuário {user_data['username']} já existe")
session.commit() db.commit()
print("Usuários de teste criados com sucesso!") print("Usuários de teste criados com sucesso")
except Exception as e: except Exception as e:
print(f"Erro ao criar usuários de teste: {e}") print(f"Erro ao criar usuários de teste: {str(e)}")
session.rollback() db.rollback()
raise
finally: finally:
session.close() db.close()
if __name__ == "__main__":
create_test_users()

View File

@@ -1,94 +0,0 @@
# Regras de Negócio - Comprovantes
## 1. Estrutura do Comprovante
### 1.1 Dados Básicos
- Todo comprovante deve ter:
- Militante associado (obrigatório)
- Data do comprovante (obrigatório)
- Forma de pagamento (obrigatório)
- Campanha financeira (opcional)
### 1.2 Formas de Pagamento
- As formas de pagamento aceitas são:
- PIX
- Transferência/DOC
- Depósito
- Maquininha
## 2. Centralizações
### 2.1 Tipos de Centralização
- Cada comprovante pode ter uma ou mais centralizações
- Os tipos de centralização são:
- Cota
- Jornal
- Assinatura
### 2.2 Valores
- Cada centralização deve ter:
- Tipo (obrigatório)
- Valor (obrigatório, maior que zero)
## 3. Transações PIX
### 3.1 Dados da Transação
- Para pagamentos via PIX, o comprovante deve incluir:
- Chave PIX
- Valor
- Data de geração
- Data de pagamento
- Status (Pendente, Pago, Expirado)
- QR Code (quando aplicável)
## 4. Validações
### 4.1 Obrigatoriedades
- Um comprovante deve ter pelo menos uma centralização
- O valor total do comprovante deve ser igual à soma das centralizações
- A data do comprovante não pode ser futura
### 4.2 Restrições
- Não é permitido excluir comprovantes com centralizações já registradas
- Não é permitido alterar valores de centralizações após confirmação
- O militante associado deve estar ativo no sistema
## 5. Permissões
### 5.1 Acesso
- Apenas usuários com permissão `MANAGE_MATERIALS` podem:
- Criar comprovantes
- Editar comprovantes
- Excluir comprovantes
- Visualizar lista de comprovantes
### 5.2 Restrições
- Usuários só podem editar comprovantes de sua própria célula/setor/CR
- Apenas administradores podem editar comprovantes de qualquer nível
## 6. Relacionamentos
### 6.1 Militante
- Todo comprovante deve estar associado a um militante
- O militante deve estar ativo no sistema
- O militante deve pertencer a uma célula/setor/CR válido
### 6.2 Campanha Financeira
- O comprovante pode estar associado a uma campanha financeira
- A campanha deve estar ativa no período do comprovante
- O valor do comprovante é contabilizado no total da campanha
## 7. Histórico
### 7.1 Registro
- Todas as alterações em comprovantes devem ser registradas
- O sistema mantém histórico de:
- Data de criação
- Usuário que criou
- Data de alteração
- Usuário que alterou
### 7.2 Auditoria
- Os comprovantes são auditáveis
- O sistema mantém logs de todas as operações
- As alterações podem ser rastreadas por usuário e data

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric, Date, Enum, create_engine, text from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric, Date, Enum, create_engine, text, Float
from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.orm import sessionmaker, relationship, backref
import os import os
import pyotp import pyotp
@@ -14,9 +14,6 @@ from flask_login import UserMixin
from .rbac import Role, Permission, role_permissions, user_roles from .rbac import Role, Permission, role_permissions, user_roles
from .base import Base, engine, Session from .base import Base, engine, Session
import logging import logging
import qrcode
from PIL import Image
import re
# Configurar caminho do banco de dados # Configurar caminho do banco de dados
db_dir = Path.home() / '.local' / 'share' / 'controles' db_dir = Path.home() / '.local' / 'share' / 'controles'
@@ -190,10 +187,11 @@ class Militante(Base):
cotas_mensais = relationship("CotaMensal", back_populates="militante") cotas_mensais = relationship("CotaMensal", back_populates="militante")
pagamentos = relationship("Pagamento", back_populates="militante") pagamentos = relationship("Pagamento", back_populates="militante")
materiais_vendidos = relationship("MaterialVendido", back_populates="militante") materiais_vendidos = relationship("MaterialVendido", back_populates="militante")
vendas_jornais = relationship("VendaJornalAvulso", back_populates="militante") vendas_jornais = relationship("VendaJornal", back_populates="militante")
assinaturas = relationship("AssinaturaAnual", back_populates="militante") assinaturas = relationship("AssinaturaJornal", back_populates="militante")
celula = relationship("Celula", back_populates="militantes", foreign_keys=[celula_id]) celula = relationship("Celula", back_populates="militantes", foreign_keys=[celula_id])
comprovantes = relationship("Comprovante", back_populates="militante") comprovantes = relationship("Comprovante", back_populates="militante")
vendas_jornais_avulsos = relationship("VendaJornalAvulso", back_populates="militante")
# Constantes para responsabilidades # Constantes para responsabilidades
SECRETARIO = 1 SECRETARIO = 1
@@ -332,6 +330,7 @@ class Pagamento(Base):
data_pagamento = Column(Date, nullable=False) data_pagamento = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="pagamentos") militante = relationship("Militante", back_populates="pagamentos")
transacoes_pix = relationship("TransacaoPIX", back_populates="pagamento")
class TipoMaterial(Base): class TipoMaterial(Base):
__tablename__ = 'tipos_materiais' __tablename__ = 'tipos_materiais'
@@ -364,7 +363,7 @@ class VendaJornalAvulso(Base):
valor_total = Column(Numeric(10, 2), nullable=False) valor_total = Column(Numeric(10, 2), nullable=False)
data_venda = Column(Date, nullable=False) data_venda = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="vendas_jornais") militante = relationship("Militante", back_populates="vendas_jornais_avulsos")
class AssinaturaAnual(Base): class AssinaturaAnual(Base):
__tablename__ = 'assinaturas_anuais' __tablename__ = 'assinaturas_anuais'
@@ -444,6 +443,7 @@ class Usuario(Base, UserMixin):
username = Column(String(50), unique=True, nullable=False) username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
email = Column(String(100), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False)
nome = Column(String(100)) # Nome completo do usuário
otp_secret = Column(String(32)) otp_secret = Column(String(32))
role_id = Column(Integer, ForeignKey('roles.id')) role_id = Column(Integer, ForeignKey('roles.id'))
setor_id = Column(Integer, ForeignKey('setores.id')) setor_id = Column(Integer, ForeignKey('setores.id'))
@@ -467,11 +467,11 @@ class Usuario(Base, UserMixin):
cr = relationship('ComiteRegional', back_populates='usuarios') cr = relationship('ComiteRegional', back_populates='usuarios')
celula = relationship('Celula', back_populates='usuarios') celula = relationship('Celula', back_populates='usuarios')
def __init__(self, username, email=None, is_admin=False): def __init__(self, username, email=None, is_admin=False, nome=None):
self.username = username self.username = username
self.email = email self.email = email
self.is_admin = is_admin self.is_admin = is_admin
self.email = email self.nome = nome
self.ativo = True self.ativo = True
self.session_timeout = 30 self.session_timeout = 30
self.tipo = "USUARIO" self.tipo = "USUARIO"
@@ -552,6 +552,10 @@ class Usuario(Base, UserMixin):
self.motivo_logout = "Logout manual" self.motivo_logout = "Logout manual"
self.ultima_atividade = None self.ultima_atividade = None
def is_admin_user(self):
"""Verifica se o usuário é admin"""
return self.is_admin or any(role.nome == "admin" for role in self.roles)
class PagamentoCelula(Base): class PagamentoCelula(Base):
__tablename__ = 'pagamentos_celula' __tablename__ = 'pagamentos_celula'
@@ -610,49 +614,6 @@ class Relatorio(Base):
setor = relationship("Setor", foreign_keys=[setor_id]) setor = relationship("Setor", foreign_keys=[setor_id])
cr = relationship("ComiteRegional", foreign_keys=[cr_id]) cr = relationship("ComiteRegional", foreign_keys=[cr_id])
class CampanhaFinanceira(Base):
__tablename__ = 'campanhas_financeiras'
id = Column(Integer, primary_key=True, autoincrement=True)
nome = Column(String(100), nullable=False)
descricao = Column(Text)
data_inicio = Column(Date, nullable=False)
data_fim = Column(Date, nullable=False)
meta = Column(Numeric(10, 2), nullable=False)
valor_arrecadado = Column(Numeric(10, 2), default=0)
status = Column(String(20), default='Em andamento') # Em andamento, Concluída, Cancelada
comprovantes = relationship("Comprovante", back_populates="campanha")
class TipoComprovante(Base):
__tablename__ = 'tipos_comprovante'
id = Column(Integer, primary_key=True)
descricao = Column(String(50), nullable=False)
valor = Column(Numeric(10, 2), nullable=False)
class CentralizacaoComprovante(Base):
__tablename__ = 'centralizacoes_comprovante'
id = Column(Integer, primary_key=True, autoincrement=True)
comprovante_id = Column(Integer, ForeignKey('comprovantes.id'), nullable=False)
tipo_comprovante = Column(String(50), nullable=False) # Cota, Jornal, Assinatura, etc.
valor = Column(Numeric(10, 2), nullable=False)
comprovante = relationship("Comprovante", back_populates="centralizacoes")
class Comprovante(Base):
__tablename__ = 'comprovantes'
id = Column(Integer, primary_key=True)
militante_id = Column(Integer, ForeignKey('militantes.id'), nullable=False)
data_comprovante = Column(Date, nullable=False)
forma_pagamento = Column(String(20), nullable=False) # PIX, transferência/DOC, depósito, maquininha
campanha_id = Column(Integer, ForeignKey('campanhas_financeiras.id'))
militante = relationship("Militante", back_populates="comprovantes")
transacoes_pix = relationship("TransacaoPIX", back_populates="comprovante")
campanha = relationship("CampanhaFinanceira", back_populates="comprovantes")
centralizacoes = relationship("CentralizacaoComprovante", back_populates="comprovante", cascade="all, delete-orphan")
class TransacaoPIX(Base): class TransacaoPIX(Base):
__tablename__ = 'transacoes_pix' __tablename__ = 'transacoes_pix'
@@ -663,10 +624,53 @@ class TransacaoPIX(Base):
data_pagamento = Column(DateTime) data_pagamento = Column(DateTime)
status = Column(String(20)) # Pendente, Pago, Expirado status = Column(String(20)) # Pendente, Pago, Expirado
qr_code = Column(Text) qr_code = Column(Text)
pagamento_id = Column(Integer, ForeignKey('pagamentos.id'))
comprovante_id = Column(Integer, ForeignKey('comprovantes.id')) comprovante_id = Column(Integer, ForeignKey('comprovantes.id'))
pagamento = relationship("Pagamento", back_populates="transacoes_pix")
comprovante = relationship("Comprovante", back_populates="transacoes_pix") comprovante = relationship("Comprovante", back_populates="transacoes_pix")
class TipoComprovante(Base):
__tablename__ = 'tipos_comprovante'
id = Column(Integer, primary_key=True)
descricao = Column(String(50), nullable=False)
valor = Column(Float, nullable=False)
class Comprovante(Base):
__tablename__ = 'comprovantes'
id = Column(Integer, primary_key=True)
militante_id = Column(Integer, ForeignKey('militantes.id'), nullable=False)
tipo_comprovante = Column(String(50)) # Cota, Jornal, Assinatura, etc.
data_comprovante = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="comprovantes")
transacoes_pix = relationship("TransacaoPIX", back_populates="comprovante")
class VendaJornal(Base):
__tablename__ = 'vendas_jornais'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
quantidade = Column(Integer, nullable=False)
valor_total = Column(Numeric(10, 2), nullable=False)
data_venda = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="vendas_jornais")
class AssinaturaJornal(Base):
__tablename__ = 'assinaturas_jornais'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
tipo_material_id = Column(Integer, ForeignKey('tipos_materiais.id'))
quantidade = Column(Integer, nullable=False)
valor_total = Column(Numeric(10, 2), nullable=False)
data_inicio = Column(Date, nullable=False)
data_fim = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="assinaturas")
tipo_material = relationship("TipoMaterial", back_populates="assinaturas")
def init_database(): def init_database():
"""Inicializa o banco de dados com dados básicos""" """Inicializa o banco de dados com dados básicos"""
print("Inicializando banco de dados...") print("Inicializando banco de dados...")
@@ -706,30 +710,9 @@ def init_database():
session.add(comite) session.add(comite)
session.commit() session.commit()
# Verificar se existe QR code do admin # Gerar OTP para admin
admin_otp_secret = None admin_otp_secret = pyotp.random_base32()
qr_path = 'admin_qr.png' print(f"Novo OTP gerado: {admin_otp_secret}")
if os.path.exists(qr_path):
try:
# Tentar ler o QR code existente
from pyzbar.pyzbar import decode
qr_data = decode(Image.open(qr_path))
if qr_data:
# O URI do OTP está no formato: otpauth://totp/Sistema%20de%20Controles:admin?secret=XXXXX&issuer=Sistema%20de%20Controles
uri = qr_data[0].data.decode('utf-8')
# Extrair o secret do URI
match = re.search(r'secret=([A-Z0-9]+)', uri)
if match:
admin_otp_secret = match.group(1)
print("OTP existente encontrado no QR code")
except Exception as e:
print(f"Erro ao ler QR code existente: {e}")
if not admin_otp_secret:
# Se não conseguiu ler o QR code ou ele não existe, gera um novo
admin_otp_secret = pyotp.random_base32()
print(f"Novo OTP gerado: {admin_otp_secret}")
# Criar usuário admin # Criar usuário admin
admin_role = session.query(Role).filter_by(nome="Administrador").first() admin_role = session.query(Role).filter_by(nome="Administrador").first()
@@ -748,23 +731,23 @@ def init_database():
session.add(admin) session.add(admin)
session.commit() session.commit()
# Gerar QR code apenas se não existir # Gerar QR code
if not os.path.exists(qr_path): totp = pyotp.totp.TOTP(admin_otp_secret)
totp = pyotp.totp.TOTP(admin_otp_secret) provisioning_uri = totp.provisioning_uri("admin", issuer_name="Sistema de Controles")
provisioning_uri = totp.provisioning_uri("admin", issuer_name="Sistema de Controles")
import qrcode
qr = qrcode.QRCode(version=1, box_size=10, border=5) qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri) qr.add_data(provisioning_uri)
qr.make(fit=True) qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white") img = qr.make_image(fill_color="black", back_color="white")
img.save(qr_path) img.save('admin_qr.png')
print("=== Usuário Admin Criado ===") print("=== Usuário Admin Criado ===")
print(f"Username: admin") print(f"Username: admin")
print(f"Senha: admin123") print(f"Senha: admin123")
print(f"Email: {admin.email}") print(f"Email: {admin.email}")
print(f"OTP Secret: {admin_otp_secret}") print(f"OTP Secret: {admin_otp_secret}")
print(f"QR Code: {qr_path}") print(f"QR Code: admin_qr.png")
# Importar e executar o seed após criar todas as dependências # Importar e executar o seed após criar todas as dependências
from seed_data import seed_database from seed_data import seed_database

View File

@@ -2,7 +2,7 @@ from functools import wraps
from flask import session, redirect, url_for, flash from flask import session, redirect, url_for, flash
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from .database import get_db_connection, Usuario from .database import get_db_connection, Usuario, Role
from .rbac import Permission from .rbac import Permission
def require_login(f): def require_login(f):
@@ -15,9 +15,13 @@ def require_login(f):
db = get_db_connection() db = get_db_connection()
try: try:
# Carregar o usuário com suas roles # Carregar o usuário com suas roles e permissões
user = db.query(Usuario).options( user = db.query(Usuario).options(
joinedload(Usuario.roles) joinedload(Usuario.roles).joinedload(Role.permissions),
joinedload(Usuario.militante),
joinedload(Usuario.cr),
joinedload(Usuario.setor),
joinedload(Usuario.celula)
).get(current_user.id) ).get(current_user.id)
if not user: if not user:
@@ -28,7 +32,15 @@ def require_login(f):
user.update_last_activity() user.update_last_activity()
db.commit() db.commit()
# Substituir o current_user pelo usuário carregado
setattr(current_user, '_get_current_object', lambda: user)
# Executar a função com o usuário carregado
return f(*args, **kwargs) return f(*args, **kwargs)
except Exception as e:
db.rollback()
flash('Erro ao carregar dados do usuário.', 'danger')
return redirect(url_for('login'))
finally: finally:
db.close() db.close()
return decorated_function return decorated_function
@@ -39,14 +51,38 @@ def require_permission(permission_name):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if not current_user.is_authenticated: if not current_user.is_authenticated:
flash('Por favor, faça login para acessar esta página.', 'danger') flash('Você precisa estar logado para acessar esta página.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
if not current_user.has_permission(permission_name): db = get_db_connection()
flash('Você não tem permissão para acessar esta página.', 'danger') try:
return redirect(url_for('home')) # Carregar o usuário com suas roles e permissões
user = db.query(Usuario).options(
return f(*args, **kwargs) joinedload(Usuario.roles).joinedload(Role.permissions),
joinedload(Usuario.militante),
joinedload(Usuario.cr),
joinedload(Usuario.setor),
joinedload(Usuario.celula)
).get(current_user.id)
if not user:
flash('Usuário não encontrado.', 'error')
return redirect(url_for('login'))
if not user.has_permission(permission_name):
flash('Você não tem permissão para acessar esta página.', 'error')
return redirect(url_for('index'))
# Atualiza timestamp da última atividade
user.update_last_activity()
db.commit()
# Substituir o current_user pelo usuário carregado
setattr(current_user, '_get_current_object', lambda: user)
return f(*args, **kwargs)
finally:
db.close()
return decorated_function return decorated_function
return decorator return decorator

View File

@@ -68,8 +68,6 @@ class Permission(Base):
EDIT_OWN_DATA = "edit_own_data" EDIT_OWN_DATA = "edit_own_data"
VIEW_CELL_DATA = "view_cell_data" VIEW_CELL_DATA = "view_cell_data"
CREATE_MILITANT = "create_militant" # Nova permissão para criar militantes CREATE_MILITANT = "create_militant" # Nova permissão para criar militantes
MANAGE_MATERIALS = "manage_materials" # Nova permissão para gerenciar materiais
MANAGE_REPORTS = "manage_reports" # Nova permissão para gerenciar relatórios
# Permissões de célula # Permissões de célula
MANAGE_CELL_MEMBERS = "manage_cell_members" MANAGE_CELL_MEMBERS = "manage_cell_members"
@@ -104,15 +102,13 @@ class Permission(Base):
(Permission.VIEW_OWN_DATA, "Visualizar próprios dados"), (Permission.VIEW_OWN_DATA, "Visualizar próprios dados"),
(Permission.EDIT_OWN_DATA, "Editar próprios dados"), (Permission.EDIT_OWN_DATA, "Editar próprios dados"),
(Permission.VIEW_CELL_DATA, "Visualizar dados da célula"), (Permission.VIEW_CELL_DATA, "Visualizar dados da célula"),
(Permission.CREATE_MILITANT, "Criar novos militantes"), (Permission.CREATE_MILITANT, "Criar novos militantes"), # Nova permissão
(Permission.MANAGE_MATERIALS, "Gerenciar materiais"),
(Permission.MANAGE_REPORTS, "Gerenciar relatórios"),
# Permissões de célula # Permissões de célula
(Permission.MANAGE_CELL_MEMBERS, "Gerenciar membros da célula"), (Permission.MANAGE_CELL_MEMBERS, "Gerenciar membros da célula"),
(Permission.CREATE_CELL_MEMBER, "Criar membros na célula"), (Permission.CREATE_CELL_MEMBER, "Criar membros na célula"),
(Permission.VIEW_CELL_REPORTS, "Visualizar relatórios da célula"), (Permission.VIEW_CELL_REPORTS, "Visualizar relatórios da célula"),
(Permission.MANAGE_CELL_REPORTS, "Gerenciar relatórios da célula"), (Permission.MANAGE_CELL_REPORTS, "Gerenciar relatórios da célula"), # Nova permissão
(Permission.REGISTER_CELL_PAYMENT, "Registrar pagamentos da célula"), (Permission.REGISTER_CELL_PAYMENT, "Registrar pagamentos da célula"),
# Permissões de setor # Permissões de setor
@@ -197,8 +193,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.CREATE_CELL_MEMBER).first(), session.query(Permission).filter_by(nome=Permission.CREATE_CELL_MEMBER).first(),
session.query(Permission).filter_by(nome=Permission.VIEW_CELL_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_CELL_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.MANAGE_CELL_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_CELL_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_CELL_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_CELL_PAYMENT).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
# Membro de Setor # Membro de Setor
@@ -212,8 +207,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.VIEW_CELL_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_CELL_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.MANAGE_CELL_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_CELL_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.VIEW_SECTOR_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_SECTOR_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_SECTOR_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_SECTOR_PAYMENT).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
# Secretário de Setor # Secretário de Setor
@@ -229,8 +223,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.VIEW_SECTOR_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_SECTOR_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.MANAGE_SECTOR_CELLS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_SECTOR_CELLS).first(),
session.query(Permission).filter_by(nome=Permission.CREATE_SECTOR_CELL).first(), session.query(Permission).filter_by(nome=Permission.CREATE_SECTOR_CELL).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_SECTOR_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_SECTOR_PAYMENT).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
# Membro de CR # Membro de CR
@@ -247,8 +240,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.MANAGE_SECTOR_CELLS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_SECTOR_CELLS).first(),
session.query(Permission).filter_by(nome=Permission.CREATE_SECTOR_CELL).first(), session.query(Permission).filter_by(nome=Permission.CREATE_SECTOR_CELL).first(),
session.query(Permission).filter_by(nome=Permission.VIEW_CR_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_CR_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_CR_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_CR_PAYMENT).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
# Secretário de CR # Secretário de CR
@@ -267,8 +259,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.VIEW_CR_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_CR_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.MANAGE_CR_SECTORS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_CR_SECTORS).first(),
session.query(Permission).filter_by(nome=Permission.CREATE_CR_SECTOR).first(), session.query(Permission).filter_by(nome=Permission.CREATE_CR_SECTOR).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_CR_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_CR_PAYMENT).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
# Membro do CC # Membro do CC
@@ -288,8 +279,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.MANAGE_CR_SECTORS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_CR_SECTORS).first(),
session.query(Permission).filter_by(nome=Permission.CREATE_CR_SECTOR).first(), session.query(Permission).filter_by(nome=Permission.CREATE_CR_SECTOR).first(),
session.query(Permission).filter_by(nome=Permission.VIEW_CC_REPORTS).first(), session.query(Permission).filter_by(nome=Permission.VIEW_CC_REPORTS).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_CC_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_CC_PAYMENT).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
# Secretário Geral # Secretário Geral
@@ -312,8 +302,7 @@ def init_rbac():
session.query(Permission).filter_by(nome=Permission.MANAGE_CC_CRS).first(), session.query(Permission).filter_by(nome=Permission.MANAGE_CC_CRS).first(),
session.query(Permission).filter_by(nome=Permission.CREATE_CC_CR).first(), session.query(Permission).filter_by(nome=Permission.CREATE_CC_CR).first(),
session.query(Permission).filter_by(nome=Permission.REGISTER_CC_PAYMENT).first(), session.query(Permission).filter_by(nome=Permission.REGISTER_CC_PAYMENT).first(),
session.query(Permission).filter_by(nome=Permission.SYSTEM_CONFIG).first(), session.query(Permission).filter_by(nome=Permission.SYSTEM_CONFIG).first()
session.query(Permission).filter_by(nome=Permission.MANAGE_MATERIALS).first()
] ]
session.commit() session.commit()

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date
from sqlalchemy.orm import relationship
from models.entities.base import Base
class AssinaturaJornal(Base):
__tablename__ = 'assinaturas_jornais'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
tipo_material_id = Column(Integer, ForeignKey('tipos_materiais.id'))
quantidade = Column(Integer, nullable=False)
valor_total = Column(Numeric(10, 2), nullable=False)
data_inicio = Column(Date, nullable=False)
data_fim = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="assinaturas", foreign_keys=[militante_id])
tipo_material = relationship("TipoMaterial", back_populates="assinaturas")

17
models/entities/base.py Normal file
View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric, Date, Enum, create_engine, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship, backref
from pathlib import Path
import os
# Configurar caminho do banco de dados
db_dir = Path.home() / '.local' / 'share' / 'controles'
db_dir.mkdir(parents=True, exist_ok=True)
db_path = db_dir / 'database.db'
DATABASE_URL = f"sqlite:///{db_path}"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base SQLAlchemy
Base = declarative_base()

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date
from sqlalchemy.orm import relationship
from models.entities.base import Base
class Comprovante(Base):
__tablename__ = 'comprovantes'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'), nullable=False)
tipo_comprovante = Column(String(50)) # Cota, Jornal, Assinatura, etc.
data_comprovante = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="comprovantes")
transacoes_pix = relationship("TransacaoPIX", back_populates="comprovante")

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date, Boolean
from sqlalchemy.orm import relationship
from models.entities.base import Base
class CotaMensal(Base):
__tablename__ = 'cotas_mensais'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
valor_antigo = Column(Numeric(10, 2), nullable=False)
valor_novo = Column(Numeric(10, 2), nullable=False)
data_alteracao = Column(Date, nullable=False)
data_vencimento = Column(Date, nullable=False)
pago = Column(Boolean, default=False)
militante = relationship("Militante", back_populates="cotas_mensais")

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from models.entities.base import Base
class EmailMilitante(Base):
__tablename__ = 'emails_militantes'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
endereco_email = Column(String(100))
# Relacionamentos
militante = relationship("Militante", back_populates="emails")

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from models.entities.base import Base
class Endereco(Base):
__tablename__ = 'enderecos'
id = Column(Integer, primary_key=True, autoincrement=True)
estado = Column(String(2))
cidade = Column(String(50))
bairro = Column(String(50))
rua = Column(String(100))
numero = Column(String(10))
complemento = Column(String(50))
cep = Column(String(9))
# Relacionamentos
militantes = relationship("Militante", back_populates="endereco")

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date
from sqlalchemy.orm import relationship
from models.entities.base import Base
class MaterialVendido(Base):
__tablename__ = 'materiais_vendidos'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
tipo_material_id = Column(Integer, ForeignKey('tipos_materiais.id'))
descricao = Column(String(255), nullable=False)
valor = Column(Numeric(10, 2), nullable=False)
data_venda = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="materiais_vendidos")
tipo_material = relationship("TipoMaterial", back_populates="materiais_vendidos")

View File

@@ -0,0 +1,155 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Date, Text, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
import secrets
from models.entities.base import Base
class EstadoMilitante(enum.Enum):
ATIVO = 'ativo'
DESLIGADO = 'desligado'
SUSPENSO = 'suspenso'
AFASTADO = 'afastado'
class Militante(Base):
__tablename__ = 'militantes'
id = Column(Integer, primary_key=True, autoincrement=True)
nome = Column(String(100), nullable=False)
cpf = Column(String(14), unique=True)
# Novos campos básicos
titulo_eleitoral = Column(String(20))
data_nascimento = Column(Date)
data_entrada_oci = Column(Date)
data_efetivacao_oci = Column(Date)
# Campos de contato
telefone1 = Column(String(15))
telefone2 = Column(String(15))
# Relacionamento para múltiplos emails
emails = relationship("EmailMilitante", back_populates="militante")
# Endereço
endereco_id = Column(Integer, ForeignKey('enderecos.id', use_alter=True, name='fk_militante_endereco'))
endereco = relationship("Endereco", back_populates="militantes")
# Redes sociais
redes_sociais = relationship("RedeSocial", back_populates="militante")
# Campos profissionais
profissao = Column(String(100))
regime_trabalho = Column(String(50)) # CLT, Estatutário, etc.
empresa = Column(String(100))
contratante = Column(String(100)) # Para terceirizados
# Campos acadêmicos
instituicao_ensino = Column(String(100))
tipo_instituicao = Column(String(20)) # Federal, Estadual, etc.
# Campos sindicais
sindicato = Column(String(100))
cargo_sindical = Column(String(50))
dirigente_sindical = Column(Boolean)
central_sindical = Column(String(100))
# Responsável pelo cadastro
registrado_por = Column(Integer, ForeignKey('militantes.id', use_alter=True, name='fk_militante_registrado_por'))
# Campos existentes
celula_id = Column(Integer, ForeignKey('celulas.id', use_alter=True, name='fk_militante_celula'))
responsabilidades = Column(Integer, default=0)
otp_secret = Column(String(32))
temp_token = Column(String(64))
temp_token_expiry = Column(DateTime)
# Novo campo para Quadro-Orientador
quadro_orientador = Column(Boolean, default=False)
# Campos para Aspirante
aspirante = Column(Boolean, default=True) # Por padrão, todo novo militante é aspirante
data_inicio_aspirante = Column(DateTime, default=datetime.utcnow)
avaliacao_aspirante = Column(Text)
data_avaliacao_aspirante = Column(DateTime)
# Campos para estado do militante
estado = Column(Enum(EstadoMilitante), default=EstadoMilitante.ATIVO)
data_desligamento = Column(DateTime)
motivo_desligamento = Column(Text)
# Relacionamentos existentes
cotas_mensais = relationship("CotaMensal", back_populates="militante")
pagamentos = relationship("Pagamento", back_populates="militante")
materiais_vendidos = relationship("MaterialVendido", back_populates="militante")
vendas_jornais_avulsos = relationship("VendaJornalAvulso", back_populates="militante")
vendas_jornais = relationship("VendaJornal", back_populates="militante", foreign_keys="[VendaJornal.militante_id]")
assinaturas = relationship("AssinaturaJornal", back_populates="militante", foreign_keys="[AssinaturaJornal.militante_id]")
celula = relationship("Celula", back_populates="militantes", foreign_keys=[celula_id])
comprovantes = relationship("Comprovante", back_populates="militante")
# Constantes para responsabilidades
SECRETARIO = 1
TESOUREIRO = 2
IMPRENSA = 4
MNS = 8
MPS = 16
JUVENTUDE = 32
QUADRO_ORIENTADOR = 64
ASPIRANTE = 128
RESPONSAVEL_FINANCAS = 256
RESPONSAVEL_IMPRENSA = 512
@staticmethod
def get_responsabilidades_list():
return [
(Militante.SECRETARIO, "Secretário"),
(Militante.TESOUREIRO, "Tesoureiro"),
(Militante.IMPRENSA, "Imprensa"),
(Militante.MNS, "MNS"),
(Militante.MPS, "MPS"),
(Militante.JUVENTUDE, "Juventude"),
(Militante.QUADRO_ORIENTADOR, "Quadro-Orientador"),
(Militante.ASPIRANTE, "Aspirante"),
(Militante.RESPONSAVEL_FINANCAS, "Responsável de Finanças"),
(Militante.RESPONSAVEL_IMPRENSA, "Responsável de Imprensa")
]
def set_responsabilidades(self, resp_list):
"""
Define as responsabilidades do militante
resp_list: lista de inteiros representando as responsabilidades
"""
self.responsabilidades = sum(resp_list)
def get_responsabilidades(self):
"""
Retorna lista de responsabilidades ativas
"""
resp = []
for valor, nome in self.get_responsabilidades_list():
if self.responsabilidades & valor:
resp.append(nome)
return resp
def generate_temp_token(self):
"""
Gera um token temporário para acesso ao QR code
"""
self.temp_token = secrets.token_urlsafe(32)
self.temp_token_expiry = datetime.now() + timedelta(hours=48)
return self.temp_token
def generate_username(self):
"""Gera um nome de usuário único baseado no primeiro nome e um código"""
from sqlalchemy import func
from services.database_service import DatabaseService
db = DatabaseService.get_db_connection()
try:
# Pega o primeiro nome
primeiro_nome = self.nome.split()[0].lower()
# Importação local para evitar dependência circular
from models.entities.usuario import Usuario
# Conta quantos usuários já existem com esse prefixo
count = db.query(func.count(Usuario.id)).filter(
Usuario.username.like(f"{primeiro_nome}%")
).scalar()
# Gera o código (número sequencial)
codigo = str(count + 1).zfill(3)
return f"{primeiro_nome}{codigo}"
finally:
db.close()

View File

@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date
from sqlalchemy.orm import relationship
from models.entities.base import Base
class Pagamento(Base):
__tablename__ = 'pagamentos'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
tipo_pagamento = Column(String(50)) # Cota, Jornal, Assinatura, etc.
mes_referencia = Column(Date)
numero_jornal = Column(String(20))
numero_inicial_assinatura = Column(String(20))
numero_final_assinatura = Column(String(20))
campanha_financeira = Column(String(50))
valor = Column(Numeric(10, 2), nullable=False)
data_pagamento = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="pagamentos")
transacoes_pix = relationship("TransacaoPIX", back_populates="pagamento")

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from models.entities.base import Base
class RedeSocial(Base):
__tablename__ = 'redes_sociais'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
tipo = Column(String(20)) # Instagram, TikTok, Discord, etc.
identificador = Column(String(100))
# Relacionamentos
militante = relationship("Militante", back_populates="redes_sociais")

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from models.entities.base import Base
class TipoMaterial(Base):
__tablename__ = 'tipos_materiais'
id = Column(Integer, primary_key=True, autoincrement=True)
descricao = Column(String(100), nullable=False)
materiais_vendidos = relationship("MaterialVendido", back_populates="tipo_material")
assinaturas = relationship("AssinaturaJornal", back_populates="tipo_material")

126
models/entities/usuario.py Normal file
View File

@@ -0,0 +1,126 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship, backref
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime, timedelta
from flask_login import UserMixin
import pyotp
from models.entities.base import Base
from models.entities.militante import Militante
class TipoUsuario(enum.Enum):
ADMIN = "admin"
CR_RESPONSAVEL = "cr_responsavel"
SETOR_RESPONSAVEL = "setor_responsavel"
USUARIO = "usuario"
class Usuario(Base, UserMixin):
__tablename__ = 'usuarios'
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
email = Column(String(100), unique=True, nullable=False)
nome = Column(String(100)) # Nome completo do usuário
otp_secret = Column(String(32))
role_id = Column(Integer, ForeignKey('roles.id'))
setor_id = Column(Integer, ForeignKey('setores.id'))
ativo = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
ultimo_login = Column(DateTime)
ultimo_logout = Column(DateTime)
motivo_logout = Column(String(100))
cr_id = Column(Integer, ForeignKey('comites_regionais.id'))
celula_id = Column(Integer, ForeignKey('celulas.id'))
session_timeout = Column(Integer, default=30)
tipo = Column(String(17), nullable=False)
ultima_atividade = Column(DateTime, default=datetime.utcnow)
# Relacionamento com militante
militante_id = Column(Integer, ForeignKey('militantes.id'))
# Relacionamentos
roles = relationship("Role", secondary="user_roles", back_populates="users")
setor = relationship('Setor', back_populates='usuarios')
cr = relationship('ComiteRegional', back_populates='usuarios')
celula = relationship('Celula', back_populates='usuarios')
militante = relationship("Militante", backref=backref("usuario", uselist=False))
def __init__(self, username, email=None, is_admin=False, nome=None):
self.username = username
self.email = email
self.is_admin = is_admin
self.nome = nome
self.ativo = True
self.session_timeout = 30
self.tipo = "USUARIO"
self.ultima_atividade = datetime.utcnow()
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def update_last_activity(self):
self.ultima_atividade = datetime.utcnow()
def is_session_expired(self):
if not self.ultima_atividade:
return True
time_diff = datetime.utcnow() - self.ultima_atividade
return time_diff.total_seconds() > (self.session_timeout * 60)
def check_session_timeout(self):
"""Verifica se a sessão do usuário expirou"""
if not self.ultima_atividade:
return True
time_diff = datetime.utcnow() - self.ultima_atividade
return time_diff.total_seconds() > (self.session_timeout * 60)
def has_permission(self, permission_name):
"""Verifica se o usuário tem uma permissão específica"""
if self.is_admin: # Se for admin, tem todas as permissões
return True
# Verifica se o usuário tem a permissão através de suas roles
for role in self.roles:
for permission in role.permissions:
if permission.nome == permission_name:
return True
return False
def has_role(self, role_nivel):
"""Verifica se o usuário tem um determinado nível de role"""
for role in self.roles:
if role.nivel == role_nivel:
return True
return False
def get_otp_uri(self):
"""Gera a URI para autenticação em duas etapas"""
if not self.otp_secret:
self.otp_secret = pyotp.random_base32()
return pyotp.totp.TOTP(self.otp_secret).provisioning_uri(
self.username,
issuer_name="Sistema de Controles"
)
def verify_otp(self, code):
"""Verifica se um código OTP é válido"""
if not self.otp_secret:
print(f"Erro: OTP secret não configurado para o usuário {self.username}")
return False
totp = pyotp.totp.TOTP(self.otp_secret)
is_valid = totp.verify(code)
return is_valid
def logout(self):
"""Registra o logout do usuário"""
self.ultimo_logout = datetime.utcnow()
self.motivo_logout = "Logout manual"
self.ultima_atividade = None
def is_admin_user(self):
"""Verifica se o usuário é admin"""
return self.is_admin or any(role.nome == "admin" for role in self.roles)

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date
from sqlalchemy.orm import relationship
from models.entities.base import Base
class VendaJornal(Base):
__tablename__ = 'vendas_jornais'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
quantidade = Column(Integer, nullable=False)
valor_total = Column(Numeric(10, 2), nullable=False)
data_venda = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="vendas_jornais", foreign_keys=[militante_id])

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Numeric, Date
from sqlalchemy.orm import relationship
from models.entities.base import Base
class VendaJornalAvulso(Base):
__tablename__ = 'vendas_jornais_avulsos'
id = Column(Integer, primary_key=True, autoincrement=True)
militante_id = Column(Integer, ForeignKey('militantes.id'))
quantidade = Column(Integer, nullable=False)
valor_total = Column(Numeric(10, 2), nullable=False)
data_venda = Column(Date, nullable=False)
militante = relationship("Militante", back_populates="vendas_jornais_avulsos")

5
pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
pythonpath = .
testpaths = tests
python_files = test_*.py
addopts = -v --cov=. --cov-report=term-missing

View File

@@ -8,7 +8,7 @@ Werkzeug==3.0.1
python-dotenv==1.0.1 python-dotenv==1.0.1
pyotp==2.9.0 pyotp==2.9.0
qrcode==7.4.2 qrcode==7.4.2
Pillow==10.2.0 Pillow==9.5.0
email-validator==2.1.0.post1 email-validator==2.1.0.post1
cryptography==42.0.2 cryptography==42.0.2
bcrypt==4.1.2 bcrypt==4.1.2
@@ -17,6 +17,3 @@ flask-bootstrap5==0.1.dev1
PyJWT==2.8.0 PyJWT==2.8.0
gunicorn==21.2.0 gunicorn==21.2.0
Faker==19.13.0 Faker==19.13.0
pytest==8.0.0
pytest-cov==4.1.0
pyzbar==0.1.9

2
routes/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# Este arquivo está intencionalmente vazio
# Ele é usado para marcar o diretório como um pacote Python

135
routes/admin.py Normal file
View File

@@ -0,0 +1,135 @@
from flask import Blueprint, render_template, flash, redirect, url_for, request, jsonify
from functions.database import Usuario, get_db_connection
from functions.decorators import require_permission, require_role, require_minimum_role
from flask_login import login_required, current_user
from sqlalchemy.orm import joinedload
import pyotp
from werkzeug.security import generate_password_hash
import secrets
from functools import wraps
from sqlalchemy.exc import SQLAlchemyError
import logging
<<<<<<< HEAD
from datetime import datetime
=======
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
logger = logging.getLogger(__name__)
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_admin:
flash('Acesso não autorizado.', 'danger')
return redirect(url_for('main.index'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/')
@login_required
@admin_required
def dashboard():
"""Dashboard principal da área administrativa com lista de usuários"""
db = get_db_connection()
try:
now = datetime.now()
# Carregar estatísticas relevantes
total_users = db.query(Usuario).count()
active_users = db.query(Usuario).filter(Usuario.is_active == True).count()
inactive_users = total_users - active_users
# Carregar lista de usuários
users = db.query(Usuario).options(
joinedload(Usuario.roles),
joinedload(Usuario.militante)
).all()
return render_template(
'admin/dashboard.html',
total_users=total_users,
active_users=active_users,
inactive_users=inactive_users,
<<<<<<< HEAD
users=users,
now=now
=======
users=users
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
)
except SQLAlchemyError as e:
logger.error(f"Erro ao buscar dados do dashboard: {str(e)}")
flash('Erro ao carregar dados. Por favor, tente novamente.', 'danger')
return render_template('admin/dashboard.html',
total_users=0,
active_users=0,
inactive_users=0,
users=[])
finally:
db.close()
@admin_bp.route('/users/<int:user_id>/reset-otp', methods=['POST'])
@login_required
@require_role('ADMIN')
def reset_user_otp(user_id):
"""Reseta o OTP de um usuário"""
db = get_db_connection()
try:
user = db.query(Usuario).get(user_id)
if not user:
flash('Usuário não encontrado.', 'danger')
return redirect(url_for('admin.dashboard'))
# Gerar novo segredo OTP
user.otp_secret = pyotp.random_base32()
db.commit()
flash(f'OTP resetado com sucesso para {user.email}.', 'success')
return redirect(url_for('admin.dashboard'))
finally:
db.close()
@admin_bp.route('/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
@require_role('ADMIN')
def reset_user_password(user_id):
"""Reseta a senha de um usuário"""
db = get_db_connection()
try:
user = db.query(Usuario).get(user_id)
if not user:
flash('Usuário não encontrado.', 'danger')
return redirect(url_for('admin.dashboard'))
# Gerar nova senha aleatória
new_password = secrets.token_urlsafe(8)
user.password = generate_password_hash(new_password)
db.commit()
flash(f'Senha resetada com sucesso. Nova senha: {new_password}', 'success')
return redirect(url_for('admin.dashboard'))
finally:
db.close()
@admin_bp.route('/users/<int:user_id>/toggle-status', methods=['POST'])
@login_required
@require_role('ADMIN')
def toggle_user_status(user_id):
"""Ativa/desativa um usuário"""
db = get_db_connection()
try:
user = db.query(Usuario).get(user_id)
if not user:
flash('Usuário não encontrado.', 'danger')
return redirect(url_for('admin.dashboard'))
user.is_active = not user.is_active
db.commit()
status = 'ativado' if user.is_active else 'desativado'
flash(f'Usuário {status} com sucesso.', 'success')
return redirect(url_for('admin.dashboard'))
finally:
db.close()

61
routes/auth.py Normal file
View File

@@ -0,0 +1,61 @@
from flask import Blueprint, render_template, redirect, url_for, request, jsonify
from flask_login import login_required, current_user
from controllers.auth_controller import AuthController
from services.database_service import DatabaseService
from models.entities.usuario import Usuario
auth_bp = Blueprint('auth', __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Rota de login"""
if request.method == "POST":
# Processar o login através do controlador
if AuthController.login():
# Redirecionar para home em caso de sucesso
return redirect(url_for("home"))
# GET ou falha no login, renderizar template
return render_template("login.html")
@auth_bp.route("/logout")
@login_required
def logout():
"""Rota de logout"""
AuthController.logout()
return redirect(url_for('auth.login'))
@auth_bp.route("/alterar_senha", methods=["GET", "POST"])
@login_required
def alterar_senha():
"""Rota para alterar a senha do usuário"""
if request.method == "POST":
senha_atual = request.form.get("senha_atual")
nova_senha = request.form.get("nova_senha")
confirmar_senha = request.form.get("confirmar_senha")
if AuthController.alterar_senha(current_user.id, senha_atual, nova_senha, confirmar_senha):
return redirect(url_for("home"))
return render_template("alterar_senha.html")
@auth_bp.route("/qr/<token>")
def get_qr_code(token):
"""Rota para exibir QR code para configuração 2FA"""
db = DatabaseService.get_db_connection()
try:
user = db.query(Usuario).filter_by(username='admin').first()
if not user:
flash('Usuário não encontrado', 'error')
return redirect(url_for('auth.login'))
qr_uri = user.get_otp_uri()
return render_template('mostrar_qr_code.html', qr_uri=qr_uri)
finally:
db.close()
@auth_bp.route('/check_session')
def check_session():
"""Rota para verificar status da sessão via AJAX"""
return jsonify(AuthController.check_session())

123
routes/cota.py Normal file
View File

@@ -0,0 +1,123 @@
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, flash
from flask_login import login_required
from models.entities.cota_mensal import CotaMensal
from services.cota_service import CotaService
from services.militante_service import MilitanteService
from functions.decorators import require_permission
from utils.date_utils import validar_data, converter_data
cota_bp = Blueprint('cota', __name__, url_prefix='/cotas')
@cota_bp.route("/")
@login_required
@require_permission('gerenciar_cotas')
def listar():
"""Lista todas as cotas mensais"""
cotas = CotaService.listar_cotas()
# Calcular status de cada cota
for cota in cotas:
if cota.pago:
cota.status = "paga"
elif cota.data_vencimento < datetime.now().date():
cota.status = "atrasada"
else:
cota.status = "pendente"
militantes = MilitanteService.listar_militantes()
return render_template("listar_cotas.html", cotas=cotas, militantes=militantes)
@cota_bp.route("/novo", methods=["GET", "POST"])
@login_required
@require_permission('gerenciar_cotas')
def nova():
"""Cria uma nova cota"""
if request.method == "POST":
militante_id = request.form.get("militante_id")
valor_antigo = float(request.form.get("valor_antigo"))
valor_novo = float(request.form.get("valor_novo"))
data_alteracao = converter_data(request.form.get("data_alteracao"))
data_vencimento = converter_data(request.form.get("data_vencimento"))
# Validar datas
if not validar_data(data_alteracao) or not validar_data(data_vencimento):
flash('Datas inválidas', 'danger')
return redirect(url_for('cota.nova'))
result = CotaService.criar_cota(militante_id, valor_antigo, valor_novo,
data_alteracao, data_vencimento)
if result:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'status': 'success',
'message': 'Cota cadastrada com sucesso!'
})
flash('Cota cadastrada com sucesso!', 'success')
return redirect(url_for('cota.listar'))
else:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'status': 'error',
'message': 'Erro ao cadastrar cota. Verifique os dados e tente novamente.'
}), 400
flash('Erro ao cadastrar cota', 'danger')
return redirect(url_for('cota.nova'))
# GET
militantes = MilitanteService.listar_militantes()
return render_template("nova_cota.html", militantes=militantes)
@cota_bp.route('/editar/<int:id>', methods=['GET', 'POST'])
@login_required
@require_permission('gerenciar_cotas')
def editar(id):
"""Edita uma cota existente"""
cota = CotaService.buscar_cota(id)
if not cota:
flash('Cota não encontrada', 'danger')
return redirect(url_for('cota.listar'))
if request.method == 'POST':
militante_id = int(request.form['militante_id'])
valor_antigo = float(request.form['valor_antigo'])
valor_novo = float(request.form['valor_novo'])
data_alteracao = converter_data(request.form['data_alteracao'])
data_vencimento = converter_data(request.form['data_vencimento'])
pago = request.form.get('pago', '').lower() == 'true'
if CotaService.atualizar_cota(id, militante_id, valor_antigo, valor_novo,
data_alteracao, data_vencimento, pago):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'status': 'success',
'message': 'Cota atualizada com sucesso!'
})
flash('Cota atualizada com sucesso!', 'success')
return redirect(url_for('cota.listar'))
else:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'status': 'error',
'message': 'Erro ao atualizar cota'
}), 400
flash('Erro ao atualizar cota', 'danger')
return redirect(url_for('cota.editar', id=id))
return render_template('editar_cota.html', cota=cota)
@cota_bp.route('/excluir/<int:id>', methods=['POST'])
@login_required
@require_permission('gerenciar_cotas')
def excluir(id):
"""Exclui uma cota existente"""
if CotaService.excluir_cota(id):
flash('Cota excluída com sucesso!', 'success')
else:
flash('Erro ao excluir cota', 'danger')
return redirect(url_for('cota.listar'))

41
routes/main.py Normal file
View File

@@ -0,0 +1,41 @@
from flask import Blueprint, render_template, redirect, url_for
from flask_login import login_required
from functions.decorators import require_login
from controllers.home_controller import HomeController
main_bp = Blueprint('main', __name__)
@main_bp.route("/")
@require_login
def index():
"""Rota principal - redireciona para home se autenticado"""
return redirect(url_for('main.home'))
@main_bp.route("/home")
@require_login
def home():
"""Página inicial do sistema com dashboard"""
dashboard_data = HomeController.dashboard()
return render_template('home.html',
nome_usuario=dashboard_data['nome_usuario'],
data_atual=dashboard_data['data_atual'],
total_militantes=dashboard_data['total_militantes'],
total_cotas=dashboard_data['total_cotas'],
total_materiais=dashboard_data['total_materiais'],
total_assinaturas=dashboard_data['total_assinaturas'],
ultimos_militantes=dashboard_data['ultimos_militantes'],
ultimos_pagamentos=dashboard_data['ultimos_pagamentos'],
tipos_pagamento=dashboard_data['tipos_pagamento'],
Militante=None) # Militante class for constants
@main_bp.route("/api/setores/<int:cr_id>")
@require_login
def get_setores(cr_id):
"""API para listar setores por comitê regional"""
from services.setor_service import SetorService
setores = SetorService.listar_setores_por_cr(cr_id)
return jsonify({
'setores': [{'id': s.id, 'nome': s.nome} for s in setores]
})

50
routes/militante.py Normal file
View File

@@ -0,0 +1,50 @@
from flask import Blueprint, render_template, redirect, url_for, request, jsonify
from flask_login import login_required
from controllers.militante_controller import MilitanteController
from services.celula_service import CelulaService
from functions.decorators import require_permission, require_role
militante_bp = Blueprint('militante', __name__, url_prefix='/militantes')
@militante_bp.route("/")
@login_required
@require_permission('gerenciar_militantes')
def listar():
"""Lista todos os militantes"""
militantes = MilitanteController.listar_militantes()
celulas = CelulaService.listar_celulas()
return render_template('listar_militantes.html',
militantes=militantes,
celulas=celulas,
Militante=None) # Militante class for constants
@militante_bp.route("/criar", methods=["POST"])
@login_required
@require_permission('gerenciar_militantes')
def criar():
"""Cria um novo militante"""
return MilitanteController.criar_militante(request.form)
@militante_bp.route("/editar/<int:militante_id>", methods=["POST"])
@login_required
@require_permission('gerenciar_militantes')
def editar(militante_id):
"""Edita um militante existente"""
return MilitanteController.atualizar_militante(militante_id, request.form)
@militante_bp.route("/excluir/<int:militante_id>", methods=["POST"])
@login_required
@require_permission('gerenciar_militantes')
def excluir(militante_id):
"""Exclui um militante"""
if MilitanteController.excluir_militante(militante_id):
return redirect(url_for('militante.listar'))
return redirect(url_for('militante.listar'))
@militante_bp.route("/dados/<int:militante_id>")
@login_required
@require_permission('gerenciar_militantes')
def dados(militante_id):
"""Busca os dados de um militante específico"""
return MilitanteController.buscar_dados_militante(militante_id)

116
routes/pagamento.py Normal file
View File

@@ -0,0 +1,116 @@
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, flash
from flask_login import login_required
from services.pagamento_service import PagamentoService
from services.militante_service import MilitanteService
from services.tipo_pagamento_service import TipoPagamentoService
from functions.decorators import require_permission
from utils.date_utils import validar_data, converter_data
pagamento_bp = Blueprint('pagamento', __name__, url_prefix='/pagamentos')
@pagamento_bp.route("/")
@login_required
@require_permission('gerenciar_pagamentos')
def listar():
"""Lista todos os pagamentos"""
pagamentos = PagamentoService.listar_pagamentos()
militantes = MilitanteService.listar_militantes()
tipos_pagamento = TipoPagamentoService.listar_tipos_pagamento()
return render_template("listar_pagamentos.html",
pagamentos=pagamentos,
militantes=militantes,
tipos_pagamento=tipos_pagamento)
@pagamento_bp.route("/novo", methods=["GET", "POST"])
@login_required
@require_permission('gerenciar_pagamentos')
def novo():
"""Cria um novo pagamento"""
if request.method == "POST":
militante_id = request.form.get("militante_id")
tipo_pagamento_id = request.form.get("tipo_pagamento_id")
valor = float(request.form.get("valor"))
data_pagamento = converter_data(request.form.get("data_pagamento"))
if not validar_data(data_pagamento):
flash('Data de pagamento inválida ou futura', 'danger')
return redirect(url_for('pagamento.novo'))
if PagamentoService.criar_pagamento(militante_id, tipo_pagamento_id, valor, data_pagamento):
flash('Pagamento cadastrado com sucesso!', 'success')
return redirect(url_for('pagamento.listar'))
else:
flash('Erro ao cadastrar pagamento', 'danger')
return redirect(url_for('pagamento.novo'))
# GET
militantes = MilitanteService.listar_militantes()
tipos_pagamento = TipoPagamentoService.listar_tipos_pagamento()
return render_template("novo_pagamento.html",
militantes=militantes,
tipos_pagamento=tipos_pagamento)
@pagamento_bp.route("/adicionar", methods=["POST"])
@login_required
@require_permission('gerenciar_pagamentos')
def adicionar():
"""Adiciona um novo pagamento (via AJAX)"""
militante_id = request.form.get("militante_id")
tipo_pagamento = request.form.get("tipo_pagamento")
valor = float(request.form.get("valor"))
data_pagamento = converter_data(request.form.get("data_pagamento"))
if PagamentoService.criar_pagamento_simples(militante_id, tipo_pagamento, valor, data_pagamento):
return jsonify({
'status': 'success',
'message': 'Pagamento adicionado com sucesso!'
})
else:
return jsonify({
'status': 'error',
'message': 'Erro ao adicionar pagamento'
}), 400
@pagamento_bp.route('/editar/<int:id>', methods=['GET', 'POST'])
@login_required
@require_permission('gerenciar_pagamentos')
def editar(id):
"""Edita um pagamento existente"""
pagamento = PagamentoService.buscar_pagamento(id)
if not pagamento:
flash('Pagamento não encontrado', 'danger')
return redirect(url_for('pagamento.listar'))
if request.method == 'POST':
militante_id = int(request.form['militante_id'])
tipo_pagamento_id = int(request.form['tipo_pagamento_id'])
valor = float(request.form['valor'])
data_pagamento = converter_data(request.form['data_pagamento'])
if PagamentoService.atualizar_pagamento(id, militante_id, tipo_pagamento_id, valor, data_pagamento):
flash('Pagamento atualizado com sucesso!', 'success')
return redirect(url_for('pagamento.listar'))
else:
flash('Erro ao atualizar pagamento', 'danger')
return redirect(url_for('pagamento.editar', id=id))
militantes = MilitanteService.listar_militantes()
tipos_pagamento = TipoPagamentoService.listar_tipos_pagamento()
return render_template('editar_pagamento.html',
pagamento=pagamento,
militantes=militantes,
tipos_pagamento=tipos_pagamento)
@pagamento_bp.route('/excluir/<int:id>', methods=['POST'])
@login_required
@require_permission('gerenciar_pagamentos')
def excluir(id):
"""Exclui um pagamento existente"""
if PagamentoService.excluir_pagamento(id):
flash('Pagamento excluído com sucesso!', 'success')
else:
flash('Erro ao excluir pagamento', 'danger')
return redirect(url_for('pagamento.listar'))

149
routes/relatorio.py Normal file
View File

@@ -0,0 +1,149 @@
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, flash
from flask_login import login_required
from datetime import date
from services.relatorio_service import RelatorioService
from services.setor_service import SetorService
from services.comite_service import ComiteService
from functions.decorators import require_permission
from utils.date_utils import validar_data, converter_data
relatorio_bp = Blueprint('relatorio', __name__, url_prefix='/relatorios')
# Rotas para relatórios de cotas
@relatorio_bp.route("/cotas")
@login_required
@require_permission('visualizar_relatorios')
def listar_cotas():
"""Lista todos os relatórios de cotas"""
relatorios = RelatorioService.listar_relatorios_cotas()
return render_template("listar_relatorios_cotas.html", relatorios=relatorios)
@relatorio_bp.route("/cotas/novo", methods=["GET", "POST"])
@login_required
@require_permission('gerar_relatorios')
def novo_relatorio_cotas():
"""Cria um novo relatório de cotas"""
if request.method == "POST":
setor_id = request.form.get("setor_id")
comite_id = request.form.get("comite_id")
total_cotas = float(request.form.get("total_cotas"))
data_relatorio = request.form.get("data_relatorio")
# Validar data
if not validar_data(data_relatorio):
flash('Data do relatório inválida', 'danger')
return render_template("novo_relatorio_cotas.html")
# Converter data
data_relatorio = converter_data(data_relatorio)
# Validar data futura
if data_relatorio > date.today():
flash('A data do relatório não pode ser futura', 'danger')
return render_template("novo_relatorio_cotas.html")
if RelatorioService.criar_relatorio_cotas(setor_id, comite_id, total_cotas, data_relatorio):
flash('Relatório de cotas cadastrado com sucesso!', 'success')
return redirect(url_for('relatorio.listar_cotas'))
else:
flash('Erro ao cadastrar relatório de cotas', 'danger')
return render_template("novo_relatorio_cotas.html")
# GET
setores = SetorService.listar_setores()
comites = ComiteService.listar_comites()
return render_template("novo_relatorio_cotas.html",
setores=setores,
comites=comites,
hoje=date.today().strftime('%Y-%m-%d'))
@relatorio_bp.route('/cotas/editar/<int:id>', methods=['GET', 'POST'])
@login_required
@require_permission('gerar_relatorios')
def editar_relatorio_cotas(id):
"""Edita um relatório de cotas existente"""
relatorio = RelatorioService.buscar_relatorio_cotas(id)
if not relatorio:
flash('Relatório não encontrado', 'danger')
return redirect(url_for('relatorio.listar_cotas'))
if request.method == 'POST':
setor_id = int(request.form['setor_id']) if request.form['setor_id'] else None
comite_id = int(request.form['comite_id']) if request.form['comite_id'] else None
total_cotas = float(request.form['total_cotas'])
data_relatorio = converter_data(request.form['data_relatorio'])
if RelatorioService.atualizar_relatorio_cotas(id, setor_id, comite_id, total_cotas, data_relatorio):
flash('Relatório atualizado com sucesso!', 'success')
return redirect(url_for('relatorio.listar_cotas'))
else:
flash('Erro ao atualizar relatório', 'danger')
return redirect(url_for('relatorio.editar_relatorio_cotas', id=id))
setores = SetorService.listar_setores()
comites = ComiteService.listar_comites()
return render_template('editar_relatorio_cotas.html',
relatorio=relatorio,
setores=setores,
comites=comites)
@relatorio_bp.route('/cotas/excluir/<int:id>', methods=['POST'])
@login_required
@require_permission('gerar_relatorios')
def excluir_relatorio_cotas(id):
"""Exclui um relatório de cotas existente"""
if RelatorioService.excluir_relatorio_cotas(id):
flash('Relatório excluído com sucesso!', 'success')
else:
flash('Erro ao excluir relatório', 'danger')
return redirect(url_for('relatorio.listar_cotas'))
# Rotas para relatórios de vendas
@relatorio_bp.route("/vendas")
@login_required
@require_permission('visualizar_relatorios')
def listar_vendas():
"""Lista todos os relatórios de vendas"""
relatorios = RelatorioService.listar_relatorios_vendas()
return render_template("listar_relatorios_vendas.html", relatorios=relatorios)
@relatorio_bp.route("/vendas/novo", methods=["GET", "POST"])
@login_required
@require_permission('gerar_relatorios')
def novo_relatorio_vendas():
"""Cria um novo relatório de vendas"""
if request.method == "POST":
setor_id = request.form.get("setor_id")
comite_id = request.form.get("comite_id")
total_vendas = float(request.form.get("total_vendas"))
data_relatorio = request.form.get("data_relatorio")
# Validar data
if not validar_data(data_relatorio):
flash('Data do relatório inválida', 'danger')
return render_template("novo_relatorio_vendas.html")
# Converter data
data_relatorio = converter_data(data_relatorio)
# Validar data futura
if data_relatorio > date.today():
flash('A data do relatório não pode ser futura', 'danger')
return render_template("novo_relatorio_vendas.html")
if RelatorioService.criar_relatorio_vendas(setor_id, comite_id, total_vendas, data_relatorio):
flash('Relatório de vendas cadastrado com sucesso!', 'success')
return redirect(url_for('relatorio.listar_vendas'))
else:
flash('Erro ao cadastrar relatório de vendas', 'danger')
return render_template("novo_relatorio_vendas.html")
# GET
setores = SetorService.listar_setores()
comites = ComiteService.listar_comites()
return render_template("novo_relatorio_vendas.html",
setores=setores,
comites=comites,
hoje=date.today().strftime('%Y-%m-%d'))

17
run_tests.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Criar e ativar ambiente virtual
python -m venv venv
source venv/bin/activate
# Instalar dependências de teste
pip install -r tests/requirements-test.txt
# Instalar o projeto em modo de desenvolvimento
pip install -e .
# Executar testes
python -m pytest
# Desativar ambiente virtual
deactivate

44
scripts/prepare_mvc.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
# Script para preparar a estrutura MVC
echo "Preparando a estrutura MVC para o Sistema de Controles..."
# Criar estrutura de diretórios
echo "Criando estrutura de diretórios..."
mkdir -p models/entities controllers services
# Mover arquivos refatorados
echo "Movendo arquivos refatorados..."
cp app.py.new app.py
# Criar arquivo __init__.py nos diretórios Python
echo "Criando arquivos de inicialização..."
touch models/__init__.py
touch models/entities/__init__.py
touch controllers/__init__.py
touch services/__init__.py
# Criar arquivo __init__.py com importações para models/entities
cat > models/entities/__init__.py << EOF
from models.entities.base import Base
from models.entities.usuario import Usuario, TipoUsuario
from models.entities.militante import Militante, EstadoMilitante
from models.entities.endereco import Endereco
from models.entities.email_militante import EmailMilitante
from models.entities.rede_social import RedeSocial
from models.entities.cota_mensal import CotaMensal
from models.entities.pagamento import Pagamento
from models.entities.tipo_material import TipoMaterial
from models.entities.material_vendido import MaterialVendido
from models.entities.venda_jornal import VendaJornal
from models.entities.venda_jornal_avulso import VendaJornalAvulso
from models.entities.assinatura_jornal import AssinaturaJornal
from models.entities.comprovante import Comprovante
EOF
echo "Todos os arquivos criados com sucesso!"
echo "Para usar a nova estrutura MVC, execute:"
echo "1. chmod +x scripts/prepare_mvc.sh"
echo "2. ./scripts/prepare_mvc.sh"
echo "3. python app.py"

View File

@@ -5,7 +5,7 @@ from functions.database import (
RelatorioCotasMensais, RelatorioVendasMateriais, engine, SessionLocal, RelatorioCotasMensais, RelatorioVendasMateriais, engine, SessionLocal,
Setor, ComiteCentral, Usuario, Role, EmailMilitante, Endereco, Setor, ComiteCentral, Usuario, Role, EmailMilitante, Endereco,
ComiteRegional, Celula, EstadoMilitante, get_db_connection, ComiteRegional, Celula, EstadoMilitante, get_db_connection,
init_database, CentralizacaoComprovante init_database
) )
import random import random
from faker import Faker from faker import Faker
@@ -59,17 +59,17 @@ def criar_tipos_comprovante(session):
"""Cria tipos de comprovante padrão""" """Cria tipos de comprovante padrão"""
print("\nCriando tipos de comprovante...") print("\nCriando tipos de comprovante...")
tipos = [ tipos = [
("Comprovante Padrão", 50.00), "Comprovante Padrão",
("Comprovante Especial", 100.00), "Comprovante Especial",
("Comprovante Extraordinário", 200.00), "Comprovante Extraordinário",
("Jornal Avulso", 5.00), "Jornal Avulso",
("Assinatura de Jornal", 30.00), "Assinatura de Jornal",
("Campanha Financeira", 0.00) # Valor variável "Campanha Financeira"
] ]
for descricao, valor in tipos: for tipo in tipos:
if not session.query(TipoComprovante).filter_by(descricao=descricao).first(): if not session.query(TipoComprovante).filter_by(descricao=tipo).first():
session.add(TipoComprovante(descricao=descricao, valor=valor)) session.add(TipoComprovante(descricao=tipo))
try: try:
session.commit() session.commit()
@@ -229,25 +229,14 @@ def criar_comprovantes(session, militantes):
try: try:
# Criar entre 3 e 8 comprovantes por militante # Criar entre 3 e 8 comprovantes por militante
for _ in range(random.randint(3, 8)): for _ in range(random.randint(3, 8)):
# Criar o comprovante base tipo = random.choice(tipos_comprovante)
comprovante = Comprovante( comprovante = Comprovante(
militante_id=militante.id, militante_id=militante.id,
data_comprovante=fake.date_between(start_date='-1y', end_date='today'), tipo_comprovante=tipo.descricao, # Usando a descrição do tipo
forma_pagamento=random.choice(['PIX', 'transferência/DOC', 'depósito', 'maquininha']) valor=random.uniform(10, 1000),
data_comprovante=fake.date_between(start_date='-1y', end_date='today')
) )
session.add(comprovante) session.add(comprovante)
session.flush() # Para obter o ID do comprovante
# Criar a centralização para o comprovante
tipo = random.choice(tipos_comprovante)
valor = random.uniform(10, 1000)
centralizacao = CentralizacaoComprovante(
comprovante_id=comprovante.id,
tipo_comprovante=tipo.descricao,
valor=valor
)
session.add(centralizacao)
session.commit() session.commit()
except Exception as e: except Exception as e:
session.rollback() session.rollback()

View File

@@ -0,0 +1,35 @@
from sqlalchemy import text
from models.entities.base import engine, SessionLocal
class DatabaseService:
"""Serviço para gerenciar conexões com o banco de dados"""
@staticmethod
def get_db_connection():
"""Retorna uma nova sessão do banco de dados"""
db = SessionLocal()
try:
# Configurar SQLite para melhor tratamento de concorrência
db.execute(text("PRAGMA journal_mode=WAL"))
db.execute(text("PRAGMA busy_timeout=5000"))
return db
except:
db.close()
raise
@staticmethod
def execute_query(query, params=None):
"""
Executa uma query usando SQLAlchemy
"""
session = DatabaseService.get_db_connection()
try:
result = session.execute(query, params)
session.commit()
return result
except Exception as e:
session.rollback()
raise e
finally:
session.close()

View File

@@ -0,0 +1,161 @@
from sqlalchemy.orm import joinedload
from datetime import datetime
from models.entities.militante import Militante
from models.entities.email_militante import EmailMilitante
from models.entities.endereco import Endereco
from services.database_service import DatabaseService
class MilitanteService:
"""Serviço para operações com militantes"""
@staticmethod
def listar_militantes():
"""Lista todos os militantes"""
db = DatabaseService.get_db_connection()
try:
militantes = db.query(Militante)\
.options(
joinedload(Militante.celula),
joinedload(Militante.emails)
)\
.order_by(Militante.nome)\
.all()
return militantes
finally:
db.close()
@staticmethod
def buscar_militante(militante_id):
"""Busca um militante pelo ID"""
db = DatabaseService.get_db_connection()
try:
militante = db.query(Militante)\
.options(
joinedload(Militante.celula),
joinedload(Militante.emails),
joinedload(Militante.endereco),
joinedload(Militante.redes_sociais)
)\
.get(militante_id)
return militante
finally:
db.close()
@staticmethod
def buscar_por_cpf(cpf):
"""Busca um militante pelo CPF"""
db = DatabaseService.get_db_connection()
try:
militante = db.query(Militante).filter(Militante.cpf == cpf).first()
return militante
finally:
db.close()
@staticmethod
def salvar_militante(militante):
"""Salva um militante no banco de dados"""
db = DatabaseService.get_db_connection()
try:
if militante.id is None: # Novo militante
db.add(militante)
db.flush() # Para obter o ID gerado
militante_id = militante.id
else: # Militante existente
db.merge(militante)
militante_id = militante.id
db.commit()
return militante_id
except Exception as e:
db.rollback()
print(f"Erro ao salvar militante: {e}")
raise
finally:
db.close()
@staticmethod
def salvar_endereco(endereco):
"""Salva um endereço no banco de dados"""
db = DatabaseService.get_db_connection()
try:
db.add(endereco)
db.flush() # Para obter o ID gerado
endereco_id = endereco.id
db.commit()
return endereco_id
except Exception as e:
db.rollback()
print(f"Erro ao salvar endereço: {e}")
raise
finally:
db.close()
@staticmethod
def salvar_email_militante(email_militante):
"""Salva um email de militante no banco de dados"""
db = DatabaseService.get_db_connection()
try:
db.add(email_militante)
db.commit()
return email_militante.id
except Exception as e:
db.rollback()
print(f"Erro ao salvar email: {e}")
raise
finally:
db.close()
@staticmethod
def atualizar_email_militante(militante_id, email):
"""Atualiza ou cria o email principal de um militante"""
db = DatabaseService.get_db_connection()
try:
# Verificar se já existe email
email_existente = db.query(EmailMilitante)\
.filter_by(militante_id=militante_id)\
.first()
if email_existente:
email_existente.endereco_email = email
db.commit()
else:
novo_email = EmailMilitante(
endereco_email=email,
militante_id=militante_id
)
db.add(novo_email)
db.commit()
return True
except Exception as e:
db.rollback()
print(f"Erro ao atualizar email: {e}")
raise
finally:
db.close()
@staticmethod
def excluir_militante(militante_id):
"""Exclui um militante pelo ID"""
db = DatabaseService.get_db_connection()
try:
militante = db.query(Militante).get(militante_id)
if not militante:
return False
# Excluir emails associados
db.query(EmailMilitante)\
.filter_by(militante_id=militante_id)\
.delete()
# Excluir o militante
db.delete(militante)
db.commit()
return True
except Exception as e:
db.rollback()
print(f"Erro ao excluir militante: {e}")
raise
finally:
db.close()

View File

@@ -0,0 +1,95 @@
from sqlalchemy.orm import joinedload
from models.entities.usuario import Usuario
from services.database_service import DatabaseService
class UsuarioService:
"""Serviço para operações com usuários"""
@staticmethod
def listar_usuarios():
"""Lista todos os usuários"""
db = DatabaseService.get_db_connection()
try:
usuarios = db.query(Usuario).options(
joinedload(Usuario.roles),
joinedload(Usuario.militante),
joinedload(Usuario.setor),
joinedload(Usuario.cr),
joinedload(Usuario.celula)
).all()
return usuarios
finally:
db.close()
@staticmethod
def buscar_usuario(user_id):
"""Busca um usuário pelo ID"""
db = DatabaseService.get_db_connection()
try:
usuario = db.query(Usuario).options(
joinedload(Usuario.roles),
joinedload(Usuario.militante),
joinedload(Usuario.setor),
joinedload(Usuario.cr),
joinedload(Usuario.celula)
).get(user_id)
return usuario
finally:
db.close()
@staticmethod
def buscar_por_username(username):
"""Busca um usuário pelo nome de usuário"""
db = DatabaseService.get_db_connection()
try:
usuario = db.query(Usuario).filter(Usuario.username == username).first()
return usuario
finally:
db.close()
@staticmethod
def buscar_por_email(email):
"""Busca um usuário pelo email"""
db = DatabaseService.get_db_connection()
try:
usuario = db.query(Usuario).filter(Usuario.email == email).first()
return usuario
finally:
db.close()
@staticmethod
def salvar_usuario(usuario):
"""Salva um usuário no banco de dados"""
db = DatabaseService.get_db_connection()
try:
if usuario.id is None: # Novo usuário
db.add(usuario)
else: # Usuário existente
db.merge(usuario)
db.commit()
return True
except Exception as e:
db.rollback()
print(f"Erro ao salvar usuário: {e}")
return False
finally:
db.close()
@staticmethod
def excluir_usuario(user_id):
"""Exclui um usuário pelo ID"""
db = DatabaseService.get_db_connection()
try:
usuario = db.query(Usuario).get(user_id)
if usuario:
db.delete(usuario)
db.commit()
return True
return False
except Exception as e:
db.rollback()
print(f"Erro ao excluir usuário: {e}")
return False
finally:
db.close()

18
setup.py Normal file
View File

@@ -0,0 +1,18 @@
from setuptools import setup, find_packages
setup(
name="controles",
version="0.1",
packages=find_packages(),
include_package_data=True,
install_requires=[
'flask',
'flask-login',
'flask-sqlalchemy',
'flask-wtf',
'flask-mail',
'python-dotenv',
'pyotp',
'qrcode',
],
)

View File

@@ -20,6 +20,10 @@
--bs-success-dark: #157347; --bs-success-dark: #157347;
--bs-secondary: #6c757d; --bs-secondary: #6c757d;
--bs-secondary-dark: #565e64; --bs-secondary-dark: #565e64;
/* Variáveis para status */
--status-active: #28a745;
--status-inactive: #dc3545;
} }
/* Tabelas */ /* Tabelas */
@@ -608,4 +612,15 @@ input.btn-secondary:hover,
color: #055160; color: #055160;
background-color: #cff4fc; background-color: #cff4fc;
border-color: #b6effb; border-color: #b6effb;
}
/* Status styles */
.status-active {
color: var(--status-active);
font-weight: 500;
}
.status-inactive {
color: var(--status-inactive);
font-weight: 500;
} }

View File

@@ -1,15 +1,3 @@
// Configuração do token CSRF para requisições AJAX
document.addEventListener('DOMContentLoaded', function() {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrfToken);
}
}
});
});
// Máscaras para campos de formulário // Máscaras para campos de formulário
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Máscara para CPF // Máscara para CPF

102
templates/admin/base.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Área Administrativa{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav id="sidebar" class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
<div class="position-sticky pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.dashboard' %}active{% endif %}"
href="{{ url_for('admin.dashboard') }}">
<i class="fas fa-tachometer-alt me-2"></i>
Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.list_users' %}active{% endif %}"
href="{{ url_for('admin.list_users') }}">
<i class="fas fa-users me-2"></i>
Usuários
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('home') }}">
<i class="fas fa-arrow-left me-2"></i>
Voltar ao Sistema
</a>
</li>
</ul>
</div>
</nav>
<!-- Main content -->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{% block admin_title %}{% endblock %}</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block admin_content %}{% endblock %}
</main>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100;
padding: 48px 0 0;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
main {
padding-top: 48px;
}
@media (max-width: 767.98px) {
.sidebar {
position: static;
padding-top: 0;
}
main {
padding-top: 0;
}
}
</style>
{% endblock %}
{% block extra_scripts %}{% endblock %}

View File

@@ -0,0 +1,329 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard Administrativo{% endblock %}
<<<<<<< HEAD
{% block extra_css %}
<style>
.card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
transition: all 0.3s ease;
overflow: hidden;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
}
.bg-primary {
background: linear-gradient(135deg, #0d6efd, #0a58ca) !important;
}
.bg-success {
background: linear-gradient(135deg, #198754, #146c43) !important;
}
.bg-danger {
background: linear-gradient(135deg, #dc3545, #b02a37) !important;
}
.card .opacity-50 {
opacity: 0.2 !important;
transition: all 0.3s ease;
}
.card:hover .opacity-50 {
opacity: 0.3 !important;
transform: scale(1.1);
}
.card-title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 1rem;
text-transform: uppercase;
color: rgba(255,255,255,0.8);
}
.display-4 {
font-size: 2.5rem;
font-weight: 600;
margin: 0.5rem 0;
}
.btn-group {
gap: 0.25rem;
}
/* Estilo da lista de usuários */
.card.lista-usuarios {
border-radius: 0;
box-shadow: none;
transition: none;
border: 1px solid #dee2e6;
}
.card.lista-usuarios:hover {
transform: none;
box-shadow: none;
}
.card.lista-usuarios .card-header {
background: linear-gradient(to right, var(--secondary-dark), var(--secondary-color));
color: white;
border: none;
padding: 1rem 1.5rem;
}
.card.lista-usuarios .card-header h5 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.card.lista-usuarios .table {
margin-bottom: 0;
}
.card.lista-usuarios .table th {
border-top: none;
font-weight: 600;
padding: 1rem;
background-color: #f8f9fa;
}
.card.lista-usuarios .table td {
padding: 1rem;
vertical-align: middle;
}
.card.lista-usuarios .badge {
padding: 0.5em 0.8em;
font-weight: 500;
}
.btn-group .btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
</style>
{% endblock %}
{% block content %}
<h2 class="mb-4">
<i class="fas fa-users-cog"></i>
Administração de Usuários
</h2>
<div class="row mb-4">
<div class="col-md-4">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title text-uppercase">Total de Usuários</h5>
<div class="d-flex justify-content-between align-items-center">
<h2 class="display-4 mb-0">{{ total_users }}</h2>
<i class="fas fa-users fa-3x opacity-50"></i>
</div>
=======
{% block content %}
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total de Usuários</h5>
<p class="card-text display-4">{{ total_users }}</p>
<i class="fas fa-users fa-2x text-primary"></i>
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
</div>
</div>
</div>
<div class="col-md-4">
<<<<<<< HEAD
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title text-uppercase">Usuários Ativos</h5>
<div class="d-flex justify-content-between align-items-center">
<h2 class="display-4 mb-0">{{ active_users }}</h2>
<i class="fas fa-user-check fa-3x opacity-50"></i>
</div>
=======
<div class="card">
<div class="card-body">
<h5 class="card-title">Usuários Ativos</h5>
<p class="card-text display-4">{{ active_users }}</p>
<i class="fas fa-user-check fa-2x text-success"></i>
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
</div>
</div>
</div>
<div class="col-md-4">
<<<<<<< HEAD
<div class="card bg-danger text-white">
<div class="card-body">
<h5 class="card-title text-uppercase">Usuários Inativos</h5>
<div class="d-flex justify-content-between align-items-center">
<h2 class="display-4 mb-0">{{ inactive_users }}</h2>
<i class="fas fa-user-times fa-3x opacity-50"></i>
</div>
=======
<div class="card">
<div class="card-body">
<h5 class="card-title">Usuários Inativos</h5>
<p class="card-text display-4">{{ inactive_users }}</p>
<i class="fas fa-user-times fa-2x text-danger"></i>
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
</div>
</div>
</div>
</div>
<<<<<<< HEAD
<div class="card lista-usuarios">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-users me-2"></i>
Lista de Usuários
</h5>
</div>
<div class="card-body p-0">
<table id="users-table" class="table table-striped table-hover">
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Status</th>
<th>Último Login</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge {% if user.is_active %}bg-success{% else %}bg-danger{% endif %}">
{{ "Ativo" if user.is_active else "Inativo" }}
</span>
</td>
<td>{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else 'Nunca' }}</td>
<td>
<div class="btn-group">
<form action="{{ url_for('admin.reset_user_otp', user_id=user.id) }}" method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-warning btn-sm" title="Reset OTP" onclick="return confirm('Confirma o reset do OTP deste usuário?')">
<i class="fas fa-key"></i>
=======
<div class="card">
<div class="card-header">
<h5 class="mb-0">Gerenciamento de Usuários</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="users-table" class="table table-striped">
<thead>
<tr>
<th>Email</th>
<th>Nome</th>
<th>Status</th>
<th>Último Login</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.email }}</td>
<td>{{ user.name }}</td>
<td>
<span class="badge {% if user.is_active %}bg-success{% else %}bg-danger{% endif %}">
{{ "Ativo" if user.is_active else "Inativo" }}
</span>
</td>
<td>{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else 'Nunca' }}</td>
<td>
<form action="{{ url_for('admin.reset_user_otp', user_id=user.id) }}" method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-warning btn-sm" onclick="return confirm('Confirma o reset do OTP deste usuário?')">
<i class="fas fa-key"></i> Reset OTP
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
</button>
</form>
<form action="{{ url_for('admin.reset_user_password', user_id=user.id) }}" method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<<<<<<< HEAD
<button type="submit" class="btn btn-info btn-sm" title="Reset Senha" onclick="return confirm('Confirma o reset da senha deste usuário?')">
<i class="fas fa-lock"></i>
</button>
</form>
<form action="{{ url_for('admin.toggle_user_status', user_id=user.id) }}" method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-{{ 'danger' if user.is_active else 'success' }} btn-sm" title="{{ 'Desativar' if user.is_active else 'Ativar' }} Usuário">
<i class="fas fa-{{ 'user-times' if user.is_active else 'user-check' }}"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
=======
<button type="submit" class="btn btn-info btn-sm" onclick="return confirm('Confirma o reset da senha deste usuário?')">
<i class="fas fa-lock"></i> Reset Senha
</button>
</form>
<button onclick="toggleUserStatus({{ user.id }})" class="btn btn-{% if user.is_active %}danger{% else %}success{% endif %} btn-sm">
<i class="fas fa-{% if user.is_active %}user-times{% else %}user-check{% endif %}"></i>
{{ "Desativar" if user.is_active else "Ativar" }}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
<<<<<<< HEAD
=======
function toggleUserStatus(userId) {
if (confirm('Deseja alterar o status deste usuário?')) {
fetch(`/admin/users/${userId}/toggle-status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
}).then(response => {
if (response.ok) {
window.location.reload();
}
});
}
}
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
$(document).ready(function() {
$('#users-table').DataTable({
language: {
url: '//cdn.datatables.net/plug-ins/1.13.7/i18n/pt-BR.json'
},
<<<<<<< HEAD
order: [[0, 'asc']],
pageLength: 25
=======
order: [[1, 'asc']]
>>>>>>> a22b0e4 (refactor(#11): Integra listagem de usuários no dashboard)
});
});
</script>
{% endblock %}

View File

@@ -10,9 +10,9 @@
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css?v=1" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css?v=1" rel="stylesheet">
<!-- Font Awesome 6 --> <!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css?v=1"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Componentes CSS --> <!-- Componentes CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}?v={{ range(1, 10000) | random }}">
<style> <style>
:root { :root {
@@ -541,8 +541,8 @@
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{{ url_for('listar_comprovantes') }}"> <a class="dropdown-item" href="{{ url_for('listar_pagamentos') }}">
<i class="fas fa-receipt"></i>Comprovantes <i class="fas fa-receipt"></i>Pagamentos
</a> </a>
</li> </li>
</ul> </ul>
@@ -563,8 +563,8 @@
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{{ url_for('listar_vendas_jornal') }}"> <a class="dropdown-item" href="{{ url_for('listar_assinaturas') }}">
<i class="fas fa-file-signature"></i>Assinaturas de Jornal <i class="fas fa-file-signature"></i>Assinaturas
</a> </a>
</li> </li>
</ul> </ul>
@@ -599,6 +599,11 @@
<i class="fas fa-user-plus"></i>Novo Usuário <i class="fas fa-user-plus"></i>Novo Usuário
</a> </a>
</li> </li>
<li>
<a class="dropdown-item" href="{{ url_for('admin.dashboard') }}">
<i class="fas fa-cog fa fa-cog fa-solid fa-cog" style="display: inline-block !important; visibility: visible !important;"></i>Administração
</a>
</li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% endif %} {% endif %}
<li> <li>
@@ -625,4 +630,4 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -1,63 +1,77 @@
{% extends 'base.html' %} {% extends "base.html" %}
{% block title %}Dashboard Administrativo{% endblock %} {% block title %}Dashboard Administrativo{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container mt-4">
<h1 class="mb-4">Dashboard Administrativo</h1> <h2 class="mb-4"><i class="fas fa-users-cog"></i> Administração de Usuários</h2>
<div class="card">
{% with messages = get_flashed_messages(with_categories=true) %} <div class="card-header bg-dark text-white">
{% if messages %} <h3 class="mb-0"><i class="fas fa-users-cog"></i> Administração de Usuários</h3>
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Gerenciamento de Usuários</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i> Aqui você pode gerenciar todos os usuários do sistema. Use os controles abaixo para ativar/desativar contas ou alterar níveis de acesso.
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-hover">
<thead> <thead class="thead-light">
<tr> <tr>
<th>ID</th>
<th>Usuário</th> <th>Usuário</th>
<th>Email</th> <th>Email</th>
<th>Admin</th> <th>Nome</th>
<th>OTP Configurado</th> <th>Último Acesso</th>
<th>Status</th>
<th>Nível</th>
<th>Ações</th> <th>Ações</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for usuario in usuarios %} {% for usuario in usuarios %}
<tr> <tr>
<td>{{ usuario.id }}</td>
<td>{{ usuario.username }}</td> <td>{{ usuario.username }}</td>
<td>{{ usuario.email }}</td> <td>{{ usuario.email }}</td>
<td>{{ usuario.nome }}</td>
<td>{{ usuario.last_login }}</td>
<td>
<span class="badge {% if usuario.ativo %}bg-success{% else %}bg-danger{% endif %}">
<span class="badge {% if usuario.ativo %}badge-success{% else %}badge-danger{% endif %}">
{{ "Ativo" if usuario.ativo else "Inativo" }}
</span>
</td>
<td> <td>
{% if usuario.is_admin %} {% if usuario.is_admin %}
<span class="badge bg-success">Sim</span> Administrador
{% else %} {% else %}
<span class="badge bg-secondary">Não</span> {{ usuario.nivel }}
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if usuario.otp_secret %} <div class="btn-group" role="group">
<span class="badge bg-success">Sim</span> <button class="btn btn-sm btn-outline-primary"
{% else %} onclick="toggleStatus('{{ usuario.id }}')"
<span class="badge bg-danger">Não</span> data-toggle="tooltip"
{% endif %} title="{{ 'Desativar' if usuario.ativo else 'Ativar' }} usuário">
</td> <i class="fas {% if usuario.ativo %}fa-user-times{% else %}fa-user-check{% endif %}"></i>
<td>
<form action="{{ url_for('reset_otp', user_id=usuario.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-warning btn-sm"
onclick="return confirm('Tem certeza que deseja resetar o OTP deste usuário?')">
Resetar OTP
</button> </button>
</form>
<button class="btn btn-sm btn-outline-warning"
onclick="resetarSenha('{{ usuario.id }}')"
data-toggle="tooltip"
title="Resetar senha">
<i class="fas fa-key"></i>
</button>
{% if not usuario.is_admin %}
<button class="btn btn-sm btn-outline-info"
onclick="alterarNivel('{{ usuario.id }}')"
data-toggle="tooltip"
title="Alterar nível">
<i class="fas fa-level-up-alt"></i>
</button>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -66,18 +80,127 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="card">
<div class="card-header"> <!-- Modal de Feedback -->
<h5 class="card-title mb-0">Ações Rápidas</h5> <div class="modal fade" id="feedbackModal" tabindex="-1" role="dialog">
</div> <div class="modal-dialog" role="document">
<div class="card-body"> <div class="modal-content">
<div class="d-grid gap-2"> <div class="modal-header">
<a href="{{ url_for('novo_usuario') }}" class="btn btn-primary"> <h5 class="modal-title">Aviso</h5>
Criar Novo Usuário <button type="button" class="close" data-dismiss="modal">
</a> <span>&times;</span>
</button>
</div>
<div class="modal-body">
<p id="feedbackMessage"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Fechar</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% endblock %}
{% block scripts %}
<script>
function showFeedback(message, type = 'info') {
const modal = document.getElementById('feedbackModal');
const messageElement = document.getElementById('feedbackMessage');
messageElement.textContent = message;
messageElement.className = `alert alert-${type}`;
$(modal).modal('show');
}
function handleResponse(response) {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
function toggleStatus(userId) {
if (!confirm('Tem certeza que deseja alterar o status deste usuário?')) {
return;
}
fetch(`/usuarios/${userId}/toggle_status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(handleResponse)
.then(data => {
showFeedback(data.message || 'Status alterado com sucesso!', data.success ? 'success' : 'danger');
if (data.success) {
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => {
console.error('Error:', error);
showFeedback('Erro ao alterar status do usuário. Por favor, tente novamente.', 'danger');
});
}
function resetarSenha(userId) {
if (!confirm('Tem certeza que deseja resetar a senha deste usuário?')) {
return;
}
fetch(`/reset_password/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(handleResponse)
.then(data => {
showFeedback(data.message || 'Senha resetada com sucesso!', data.success ? 'success' : 'danger');
})
.catch(error => {
console.error('Error:', error);
showFeedback('Erro ao resetar senha. Por favor, tente novamente.', 'danger');
});
}
function alterarNivel(userId) {
const novoNivel = prompt('Digite o novo nível do usuário (1-5):');
if (!novoNivel) return;
if (!/^[1-5]$/.test(novoNivel)) {
showFeedback('Por favor, insira um nível válido entre 1 e 5.', 'warning');
return;
}
fetch(`/usuarios/${userId}/alterar_nivel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ nivel: parseInt(novoNivel) })
})
.then(handleResponse)
.then(data => {
showFeedback(data.message || 'Nível alterado com sucesso!', data.success ? 'success' : 'danger');
if (data.success) {
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => {
console.error('Error:', error);
showFeedback('Erro ao alterar nível. Por favor, tente novamente.', 'danger');
});
}
// Inicializa os tooltips do Bootstrap
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});
</script>
{% endblock %}

View File

@@ -57,7 +57,7 @@
<div class="stats-card yellow"> <div class="stats-card yellow">
<div class="title">Assinaturas Ativas</div> <div class="title">Assinaturas Ativas</div>
<div class="value">{{ total_assinaturas }}</div> <div class="value">{{ total_assinaturas }}</div>
<a href="{{ url_for('listar_vendas_jornal') }}" class="link"> <a href="{{ url_for('listar_assinaturas') }}" class="link">
Ver detalhes <i class="fas fa-arrow-right"></i> Ver detalhes <i class="fas fa-arrow-right"></i>
</a> </a>
<div class="icon"> <div class="icon">

View File

@@ -7,7 +7,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-money-bill-wave"></i> Comprovantes</h2> <h2><i class="fas fa-money-bill-wave"></i> Comprovantes</h2>
<div> <div>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#novoComprovanteModal"> <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalNovoComprovante">
<i class="fas fa-plus"></i> Novo Comprovante <i class="fas fa-plus"></i> Novo Comprovante
</button> </button>
<button type="button" class="btn btn-outline-primary" id="btnExportar"> <button type="button" class="btn btn-outline-primary" id="btnExportar">
@@ -22,30 +22,40 @@
<table class="table table-striped table-hover" id="tabelaComprovantes"> <table class="table table-striped table-hover" id="tabelaComprovantes">
<thead> <thead>
<tr> <tr>
<th>ID</th>
<th>Militante</th> <th>Militante</th>
<th>Data</th> <th>Tipo de Comprovante</th>
<th>Forma de Pagamento</th> <th>Valor</th>
<th>Campanha</th> <th>Data do Comprovante</th>
<th>Centralizações</th>
<th>Ações</th> <th>Ações</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for comprovante in comprovantes %} {% for comprovante in comprovantes %}
<tr> <tr>
<td>{{ comprovante.id }}</td> <td data-militante="{{ comprovante.militante_id }}">{{ comprovante.militante.nome if comprovante.militante else 'N/A' }}</td>
<td>{{ comprovante.militante.nome }}</td> <td data-tipo="{{ comprovante.tipo_comprovante }}">
<td>{{ comprovante.data_comprovante.strftime('%d/%m/%Y') }}</td> {% if comprovante.tipo_comprovante == 1 %}
<td>{{ comprovante.forma_pagamento }}</td> Cota
<td>{{ comprovante.campanha.nome if comprovante.campanha else '-' }}</td> {% elif comprovante.tipo_comprovante == 2 %}
<td> Contribuição Extra
<ul class="list-unstyled"> {% elif comprovante.tipo_comprovante == 3 %}
{% for centralizacao in comprovante.centralizacoes %} Doação
<li>{{ centralizacao.tipo_comprovante }}: R$ {{ "%.2f"|format(centralizacao.valor) }}</li> {% elif comprovante.tipo_comprovante == 4 %}
{% endfor %} Taxa de Evento
</ul> {% elif comprovante.tipo_comprovante == 5 %}
Jornal Avulso
{% elif comprovante.tipo_comprovante == 6 %}
Assinatura de Jornal
{% elif comprovante.tipo_comprovante == 7 %}
Campanha Financeira
{% elif comprovante.tipo_comprovante == 8 %}
Outros
{% else %}
Não Definido
{% endif %}
</td> </td>
<td data-valor="{{ comprovante.valor }}">R$ {{ "%.2f"|format(comprovante.valor) }}</td>
<td data-data="{{ comprovante.data_comprovante }}">{{ comprovante.data_comprovante.strftime('%d/%m/%Y') }}</td>
<td> <td>
<button type="button" <button type="button"
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
@@ -53,7 +63,8 @@
data-bs-target="#modalEditarComprovante" data-bs-target="#modalEditarComprovante"
data-comprovante-id="{{ comprovante.id }}" data-comprovante-id="{{ comprovante.id }}"
data-militante-id="{{ comprovante.militante_id }}" data-militante-id="{{ comprovante.militante_id }}"
data-militante-nome="{{ comprovante.militante.nome }}" data-tipo-comprovante="{{ comprovante.tipo_comprovante }}"
data-valor="{{ comprovante.valor }}"
data-data-comprovante="{{ comprovante.data_comprovante.strftime('%Y-%m-%d') }}" data-data-comprovante="{{ comprovante.data_comprovante.strftime('%Y-%m-%d') }}"
title="Editar"> title="Editar">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
@@ -63,7 +74,7 @@
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#modalExcluirComprovante" data-bs-target="#modalExcluirComprovante"
data-comprovante-id="{{ comprovante.id }}" data-comprovante-id="{{ comprovante.id }}"
data-comprovante-info="Comprovante de {{ comprovante.militante.nome }} - Total: R$ {{ "%.2f"|format(comprovante.centralizacoes|sum(attribute='valor')) }}" data-comprovante-info="Comprovante de {{ comprovante.militante.nome if comprovante.militante else 'N/A' }} - R$ {{ "%.2f"|format(comprovante.valor) }}"
title="Excluir"> title="Excluir">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
@@ -78,159 +89,58 @@
</div> </div>
<!-- Modal Novo Comprovante --> <!-- Modal Novo Comprovante -->
<div class="modal fade" id="novoComprovanteModal" tabindex="-1" aria-labelledby="novoComprovanteModalLabel" aria-hidden="true"> <div class="modal fade" id="modalNovoComprovante" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="novoComprovanteModalLabel">Novo Comprovante</h5> <h5 class="modal-title"><i class="fas fa-plus"></i> Novo Comprovante</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="novoComprovanteForm"> <form id="formNovoComprovante" method="post" action="{{ url_for('adicionar_comprovante') }}">
<!-- Dados únicos do comprovante --> <div class="mb-3">
<div class="row mb-3"> <label for="militante" class="form-label">Militante:</label>
<div class="col-md-6"> <select class="form-select" id="militante" name="militante_id" required>
<label for="militante_id" class="form-label">Militante</label> <option value="">Selecione um militante</option>
<select class="form-select" id="militante_id" name="militante_id" required> {% for militante in militantes %}
<option value="">Selecione o militante</option> <option value="{{ militante.id }}">{{ militante.nome }}</option>
{% for militante in militantes %} {% endfor %}
<option value="{{ militante.id }}">{{ militante.nome }}</option> </select>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="data_comprovante" class="form-label">Data do Comprovante</label>
<input type="date" class="form-control" id="data_comprovante" name="data_comprovante" required>
</div>
</div> </div>
<div class="row mb-3"> <div class="mb-3">
<div class="col-md-6"> <label for="tipoComprovante" class="form-label">Tipo de Comprovante:</label>
<label for="forma_pagamento" class="form-label">Forma de Pagamento</label> <select class="form-select" id="tipoComprovante" name="tipo_comprovante" required>
<select class="form-select" id="forma_pagamento" name="forma_pagamento" required> <option value="">Selecione o tipo</option>
<option value="">Selecione a forma de pagamento</option> <option value="1">Cota</option>
<option value="PIX">PIX</option> {% if current_user.has_permission('gerenciar_tipos_comprovante') %}
<option value="TRANSFERENCIA">Transferência/DOC</option> <option value="2">Contribuição Extra</option>
<option value="DEPOSITO">Depósito</option> <option value="3">Doação</option>
<option value="MAQUININHA">Maquininha</option> <option value="4">Taxa de Evento</option>
</select> <option value="5">Jornal Avulso</option>
</div> <option value="6">Assinatura de Jornal</option>
<div class="col-md-6"> <option value="7">Campanha Financeira</option>
<label for="campanha_id" class="form-label">Campanha</label> <option value="8">Outros</option>
<select class="form-select" id="campanha_id" name="campanha_id"> {% endif %}
<option value="">Selecione a campanha</option> </select>
{% for campanha in campanhas %}
<option value="{{ campanha.id }}">{{ campanha.nome }}</option>
{% endfor %}
</select>
</div>
</div> </div>
<div class="mb-3">
<!-- Centralizações --> <label for="valor" class="form-label">Valor:</label>
<div class="centralizacoes-container"> <input type="number" step="0.01" class="form-control" id="valor" name="valor" required>
<h6 class="mb-3">Centralizações</h6> </div>
<div class="centralizacao-item mb-3"> <div class="mb-3">
<div class="row"> <label for="dataComprovante" class="form-label">Data do Comprovante:</label>
<div class="col-md-6"> <input type="date" class="form-control" id="dataComprovante" name="data_comprovante" required>
<label class="form-label">Tipo de Comprovante</label> </div>
<select class="form-select tipo-comprovante" name="tipo_comprovante[]" required> <div class="modal-footer">
<option value="">Selecione o tipo</option> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<option value="COTA">Cota</option> <button type="submit" class="btn btn-primary">Salvar</button>
<option value="JORNAL">Jornal</option>
<option value="ASSINATURA">Assinatura</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label">Valor</label>
<input type="number" class="form-control valor" name="valor[]" step="0.01" required>
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="button" class="btn btn-danger btn-sm remover-centralizacao">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div> </div>
<button type="button" class="btn btn-secondary btn-sm mb-3" id="adicionar-centralizacao">
<i class="bi bi-plus"></i> Adicionar Centralização
</button>
</form> </form>
</div> </div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary" id="salvarComprovante">Salvar</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<style>
.centralizacao-item {
background-color: #f8f9fa;
padding: 15px;
border-radius: 5px;
border: 1px solid #dee2e6;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Adicionar nova centralização
document.getElementById('adicionar-centralizacao').addEventListener('click', function() {
const container = document.querySelector('.centralizacoes-container');
const newItem = document.querySelector('.centralizacao-item').cloneNode(true);
newItem.querySelector('.valor').value = '';
newItem.querySelector('.tipo-comprovante').value = '';
container.appendChild(newItem);
});
// Remover centralização
document.addEventListener('click', function(e) {
if (e.target.closest('.remover-centralizacao')) {
const centralizacoes = document.querySelectorAll('.centralizacao-item');
if (centralizacoes.length > 1) {
e.target.closest('.centralizacao-item').remove();
}
}
});
// Salvar comprovante
document.getElementById('salvarComprovante').addEventListener('click', function() {
const form = document.getElementById('novoComprovanteForm');
const formData = new FormData(form);
// Coletar dados das centralizações
const centralizacoes = [];
document.querySelectorAll('.centralizacao-item').forEach(item => {
centralizacoes.push({
tipo_comprovante: item.querySelector('.tipo-comprovante').value,
valor: item.querySelector('.valor').value
});
});
// Adicionar centralizações ao formData
formData.append('centralizacoes', JSON.stringify(centralizacoes));
fetch('/comprovantes/novo', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Erro ao salvar comprovante');
}
})
.catch(error => {
console.error('Error:', error);
alert('Erro ao salvar comprovante');
});
});
});
</script>
<!-- Modal Editar Comprovante --> <!-- Modal Editar Comprovante -->
<div class="modal fade" id="modalEditarComprovante" tabindex="-1"> <div class="modal fade" id="modalEditarComprovante" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">

View File

@@ -47,7 +47,7 @@
</div> </div>
<div class="form-floating mb-4"> <div class="form-floating mb-4">
<input type="text" class="form-control" id="otp" name="otp" placeholder="Código OTP"> <input type="text" class="form-control" id="otp" name="otp" placeholder="Código OTP" required>
<label for="otp">Código OTP</label> <label for="otp">Código OTP</label>
<div class="invalid-feedback"> <div class="invalid-feedback">
Por favor, informe o código OTP. Por favor, informe o código OTP.

View File

@@ -31,7 +31,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Registrar</button> <button type="submit" class="btn btn-primary">Registrar</button>
<a href="{{ url_for('listar_vendas_jornal') }}" class="btn btn-secondary">Voltar</a> <a href="{{ url_for('listar_assinaturas') }}" class="btn btn-secondary">Voltar</a>
<a href="{{ url_for('home') }}" class="btn btn-outline-primary">Início</a> <a href="{{ url_for('home') }}" class="btn btn-outline-primary">Início</a>
</div> </div>
</form> </form>

33
tests/conftest.py Normal file
View File

@@ -0,0 +1,33 @@
import pytest
from app import create_app
from functions.database import init_database, get_db_connection
@pytest.fixture
def app():
"""Cria uma instância do app para testes"""
app = create_app()
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
# Inicializar banco de dados de teste
init_database()
yield app
# Limpar banco após os testes
db = get_db_connection()
try:
db.execute('DROP TABLE IF EXISTS usuarios CASCADE')
db.commit()
finally:
db.close()
@pytest.fixture
def client(app):
"""Cria um cliente de teste"""
return app.test_client()
@pytest.fixture
def runner(app):
"""Cria um runner de CLI para testes"""
return app.test_cli_runner()

View File

@@ -0,0 +1,4 @@
pytest==7.4.3
pytest-cov==4.1.0
pytest-flask==1.3.0
coverage==7.3.2

100
tests/test_admin_routes.py Normal file
View File

@@ -0,0 +1,100 @@
import pytest
from flask import url_for
from functions.database import Usuario, get_db_connection
from werkzeug.security import generate_password_hash
import json
@pytest.fixture
def admin_user(client):
"""Fixture que cria um usuário admin para testes"""
db = get_db_connection()
try:
admin = Usuario(
username='admin_test',
email='admin@test.com',
password_hash=generate_password_hash('admin123'),
is_admin=True,
is_active=True
)
db.add(admin)
db.commit()
return admin
finally:
db.close()
@pytest.fixture
def auth_admin_client(client, admin_user):
"""Fixture que retorna um cliente autenticado como admin"""
client.post('/login', data={
'email': 'admin@test.com',
'password': 'admin123'
})
return client
def test_dashboard_access_sem_login(client):
"""Testa acesso ao dashboard sem login"""
response = client.get('/admin/')
assert response.status_code == 302
assert '/login' in response.headers['Location']
def test_dashboard_access_com_login(auth_admin_client):
"""Testa acesso ao dashboard com login de admin"""
response = auth_admin_client.get('/admin/')
assert response.status_code == 200
assert b'Dashboard Administrativo' in response.data
def test_lista_usuarios(auth_admin_client):
"""Testa listagem de usuários"""
response = auth_admin_client.get('/admin/users')
assert response.status_code == 200
assert b'Lista de' in response.data
assert b'admin_test' in response.data
def test_reset_otp(auth_admin_client, admin_user):
"""Testa reset de OTP"""
response = auth_admin_client.post(f'/admin/users/{admin_user.id}/reset-otp')
assert response.status_code == 302
assert 'success' in response.headers['Location']
def test_reset_password(auth_admin_client, admin_user):
"""Testa reset de senha"""
response = auth_admin_client.post(f'/admin/users/{admin_user.id}/reset-password')
assert response.status_code == 302
assert 'success' in response.headers['Location']
def test_toggle_status(auth_admin_client, admin_user):
"""Testa alteração de status do usuário"""
response = auth_admin_client.post(
f'/admin/users/{admin_user.id}/toggle-status',
headers={'Content-Type': 'application/json'}
)
data = json.loads(response.data)
assert response.status_code == 200
assert data['success'] is True
def test_acesso_nao_admin(client):
"""Testa acesso de usuário não admin"""
db = get_db_connection()
try:
# Criar usuário normal
user = Usuario(
username='normal_user',
email='user@test.com',
password_hash=generate_password_hash('user123'),
is_admin=False,
is_active=True
)
db.add(user)
db.commit()
# Login
client.post('/login', data={
'email': 'user@test.com',
'password': 'user123'
})
# Tentar acessar área admin
response = client.get('/admin/')
assert response.status_code == 403
finally:
db.close()

View File

@@ -1,89 +0,0 @@
import pytest
from flask import url_for
from app import create_app
from functions.database import get_db_connection, init_database
import os
@pytest.fixture
def app():
app = create_app()
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
# Criar banco de dados temporário para testes
with app.app_context():
init_database()
yield app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()
def test_home_page(client):
response = client.get('/')
assert response.status_code == 302 # Redireciona para login
def test_login_page(client):
response = client.get('/login')
assert response.status_code == 200
assert b'Login' in response.data
def test_listar_assinaturas_jornal(client):
# Primeiro fazer login
client.post('/login', data={
'username': 'admin',
'password': 'admin123'
})
response = client.get('/assinaturas/jornal')
assert response.status_code == 200
assert b'Assinaturas de Jornal' in response.data
def test_nova_assinatura_jornal(client):
# Primeiro fazer login
client.post('/login', data={
'username': 'admin',
'password': 'admin123'
})
response = client.get('/assinaturas/jornal/novo')
assert response.status_code == 200
assert b'Registrar Nova Assinatura Anual' in response.data
def test_listar_militantes(client):
# Primeiro fazer login
client.post('/login', data={
'username': 'admin',
'password': 'admin123'
})
response = client.get('/militantes')
assert response.status_code == 200
assert b'Militantes' in response.data
def test_listar_cotas(client):
# Primeiro fazer login
client.post('/login', data={
'username': 'admin',
'password': 'admin123'
})
response = client.get('/cotas')
assert response.status_code == 200
assert b'Cotas' in response.data
def test_listar_materiais(client):
# Primeiro fazer login
client.post('/login', data={
'username': 'admin',
'password': 'admin123'
})
response = client.get('/materiais')
assert response.status_code == 200
assert b'Materiais' in response.data