diff --git a/Makefile b/Makefile index a9a5333..9292d78 100644 --- a/Makefile +++ b/Makefile @@ -11,10 +11,13 @@ init-db: clean seed: init-db python seed.py +init: + python app.py --init + run: python app.py -run-with-seed: seed run +run-with-seed: seed init run reset-admin: clean python create_admin.py diff --git a/app.py b/app.py index 531bb7a..e50bb1f 100644 --- a/app.py +++ b/app.py @@ -51,6 +51,8 @@ from sqlalchemy.sql import func from flask_wtf.csrf import CSRFProtect import json from utils.date_utils import validar_data, converter_data, validar_sequencia_datas, calcular_idade +from routes.admin import admin_bp # Importar o blueprint administrativo +import sys load_dotenv() @@ -59,6 +61,9 @@ def create_app(): app.secret_key = os.getenv('SECRET_KEY', secrets.token_hex(16)) bootstrap = Bootstrap5(app) + # Registrar o blueprint administrativo + app.register_blueprint(admin_bp) + # Configurar CSRF Protection csrf = CSRFProtect() csrf.init_app(app) @@ -128,7 +133,30 @@ def create_app(): if 'user_id' not in session: flash('Por favor, faça login para acessar esta página.', 'warning') return redirect(url_for('login')) - return f(*args, **kwargs) + + from sqlalchemy.orm import Session + db = get_db_connection() + try: + # Carregar o usuário com suas roles e permissões + user = db.query(Usuario).options( + joinedload(Usuario.roles).joinedload(Role.permissions), + joinedload(Usuario.militante), + joinedload(Usuario.cr), + joinedload(Usuario.setor), + joinedload(Usuario.celula) + ).get(session['user_id']) + + if not user: + flash('Usuário não encontrado.', 'danger') + return redirect(url_for('login')) + + # Atualiza timestamp da última atividade + user.update_last_activity() + db.commit() + + return f(*args, **kwargs) + finally: + db.close() return decorated_function # Decorator para verificar se a sessão expirou @@ -222,6 +250,7 @@ def create_app(): session['user_id'] = user.id session['username'] = user.username session['is_admin'] = user.is_admin + print(f"Login realizado: user_id={user.id}, username={user.username}, is_admin={user.is_admin}") # Redirecionar para home return redirect(url_for("home")) @@ -1361,34 +1390,36 @@ def create_app(): @app.route('/usuarios//toggle_status', methods=['POST']) @require_login def toggle_user_status(user_id): - user = db_session.query(Usuario).get_or_404(user_id) - - # Verificar permissões baseado na hierarquia - if not current_user.has_permission('system_config'): - if current_user.has_permission('manage_cr_sectors'): - # Secretário de CR só pode gerenciar membros do seu CR - if user.cr_id != current_user.cr_id: - flash('Você não tem permissão para gerenciar este usuário.', 'danger') - return redirect(url_for('dashboard_admin')) - elif current_user.has_permission('manage_sector_cells'): - # Secretário de Setor só pode gerenciar membros do seu setor - if user.setor_id != current_user.setor_id: - flash('Você não tem permissão para gerenciar este usuário.', 'danger') - return redirect(url_for('dashboard_admin')) - elif current_user.has_permission('manage_cell_members'): - # Secretário de Célula só pode gerenciar membros da sua célula - if user.celula_id != current_user.celula_id: - flash('Você não tem permissão para gerenciar este usuário.', 'danger') - return redirect(url_for('dashboard_admin')) - else: - # Militante básico não pode gerenciar ninguém - flash('Você não tem permissão para gerenciar usuários.', 'danger') - return redirect(url_for('dashboard_admin')) - - user.ativo = not user.ativo - db_session.commit() - - return jsonify({'success': True, 'message': 'Status do usuário alterado com sucesso!'}) + 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 + + db = get_db_connection() + try: + usuario = db.query(Usuario).get(user_id) + if not usuario: + return jsonify({ + 'success': False, + 'error': 'Usuário não encontrado.' + }), 404 + + usuario.ativo = not usuario.ativo + db.commit() + + return jsonify({ + 'success': True, + 'message': f'Usuário {"ativado" if usuario.ativo else "desativado"} com sucesso!' + }) + except Exception as e: + db.rollback() + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + finally: + db.close() @app.route('/usuarios//alterar_nivel', methods=['POST']) @require_login @@ -1683,18 +1714,18 @@ def init_system(): def main(): # Criar a aplicação app = create_app() - - # Inicializar o sistema - init_system() - return app # Criar a aplicação usando a função main app = main() if __name__ == '__main__': - app.run( - host='0.0.0.0', - port=5000, - debug=os.getenv('FLASK_ENV') == 'development' - ) + # Verificar se é para inicializar o sistema + if '--init' in sys.argv: + init_system() + else: + app.run( + host='0.0.0.0', + port=5000, + debug=os.getenv('FLASK_ENV') == 'development' + ) diff --git a/create_admin.py b/create_admin.py index 9d70e6d..b53fa93 100644 --- a/create_admin.py +++ b/create_admin.py @@ -70,6 +70,15 @@ def create_admin_user(): 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() diff --git a/create_test_users.py b/create_test_users.py new file mode 100644 index 0000000..5774f8c --- /dev/null +++ b/create_test_users.py @@ -0,0 +1,56 @@ +from functions.database import get_db_connection, Usuario, Role +from werkzeug.security import generate_password_hash + +def create_test_users(): + """Cria usuários de teste""" + db = get_db_connection() + try: + # Lista de usuários de teste + test_users = [ + { + 'username': 'aligner', + 'email': 'aligner@test.com', + 'password': 'Test123!@#', + 'is_admin': False + }, + { + 'username': 'tester', + 'email': 'tester@test.com', + 'password': 'Test123!@#', + 'is_admin': False + }, + { + 'username': 'deployer', + 'email': 'deployer@test.com', + 'password': 'Test123!@#', + 'is_admin': False + } + ] + + # Criar cada usuário + for user_data in test_users: + user = db.query(Usuario).filter_by(username=user_data['username']).first() + + if not user: + user = Usuario( + username=user_data['username'], + email=user_data['email'], + is_admin=user_data['is_admin'] + ) + user.set_password(user_data['password']) + db.add(user) + print(f"Usuário {user_data['username']} criado") + else: + print(f"Usuário {user_data['username']} já existe") + + db.commit() + print("Usuários de teste criados com sucesso") + + except Exception as e: + print(f"Erro ao criar usuários de teste: {str(e)}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + create_test_users() \ No newline at end of file diff --git a/functions/database.py b/functions/database.py index 54e0d1c..4f09e71 100644 --- a/functions/database.py +++ b/functions/database.py @@ -441,6 +441,7 @@ class Usuario(Base, UserMixin): 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')) @@ -464,11 +465,11 @@ class Usuario(Base, UserMixin): cr = relationship('ComiteRegional', 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.email = email self.is_admin = is_admin - self.email = email + self.nome = nome self.ativo = True self.session_timeout = 30 self.tipo = "USUARIO" @@ -549,6 +550,10 @@ class Usuario(Base, UserMixin): 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) + class PagamentoCelula(Base): __tablename__ = 'pagamentos_celula' diff --git a/functions/decorators.py b/functions/decorators.py index b2ec0dc..1483835 100644 --- a/functions/decorators.py +++ b/functions/decorators.py @@ -2,7 +2,7 @@ from functools import wraps from flask import session, redirect, url_for, flash from flask_login import current_user, login_required from sqlalchemy.orm import joinedload -from .database import get_db_connection, Usuario +from .database import get_db_connection, Usuario, Role from .rbac import Permission def require_login(f): @@ -15,9 +15,13 @@ def require_login(f): db = get_db_connection() try: - # Carregar o usuário com suas roles + # Carregar o usuário com suas roles e permissões 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) if not user: @@ -28,7 +32,15 @@ def require_login(f): user.update_last_activity() 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) + except Exception as e: + db.rollback() + flash('Erro ao carregar dados do usuário.', 'danger') + return redirect(url_for('login')) finally: db.close() return decorated_function @@ -39,14 +51,38 @@ def require_permission(permission_name): @wraps(f) def decorated_function(*args, **kwargs): 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')) - if not current_user.has_permission(permission_name): - flash('Você não tem permissão para acessar esta página.', 'danger') - return redirect(url_for('home')) - - return f(*args, **kwargs) + db = get_db_connection() + try: + # Carregar o usuário com suas roles e permissões + user = db.query(Usuario).options( + 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 decorator diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..69c7848 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +pythonpath = . +testpaths = tests +python_files = test_*.py +addopts = -v --cov=. --cov-report=term-missing \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..5d588a4 --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,2 @@ +# Este arquivo está intencionalmente vazio +# Ele é usado para marcar o diretório como um pacote Python \ No newline at end of file diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..232e307 --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,128 @@ +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 +from datetime import datetime + +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, + users=users, + now=now + ) + 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//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//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//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() \ No newline at end of file diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..ec4df49 --- /dev/null +++ b/run_tests.sh @@ -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 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..546c895 --- /dev/null +++ b/setup.py @@ -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', + ], +) \ No newline at end of file diff --git a/static/css/components.css b/static/css/components.css index 31c632a..bcec453 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -20,6 +20,10 @@ --bs-success-dark: #157347; --bs-secondary: #6c757d; --bs-secondary-dark: #565e64; + + /* Variáveis para status */ + --status-active: #28a745; + --status-inactive: #dc3545; } /* Tabelas */ @@ -608,4 +612,15 @@ input.btn-secondary:hover, color: #055160; background-color: #cff4fc; border-color: #b6effb; +} + +/* Status styles */ +.status-active { + color: var(--status-active); + font-weight: 500; +} + +.status-inactive { + color: var(--status-inactive); + font-weight: 500; } \ No newline at end of file diff --git a/templates/admin/base.html b/templates/admin/base.html new file mode 100644 index 0000000..5d4e3d5 --- /dev/null +++ b/templates/admin/base.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} + +{% block title %}Área Administrativa{% endblock %} + +{% block content %} +
+
+ + + + +
+
+

{% block admin_title %}{% endblock %}

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block admin_content %}{% endblock %} +
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_scripts %}{% endblock %} \ No newline at end of file diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..1ce3ae5 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,227 @@ +{% extends "admin/base.html" %} + +{% block title %}Dashboard Administrativo{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +

+ + Administração de Usuários +

+ +
+
+
+
+
Total de Usuários
+
+

{{ total_users }}

+ +
+
+
+
+
+
+
+
Usuários Ativos
+
+

{{ active_users }}

+ +
+
+
+
+
+
+
+
Usuários Inativos
+
+

{{ inactive_users }}

+ +
+
+
+
+
+ +
+
+
+ + Lista de Usuários +
+
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
NomeEmailStatusÚltimo LoginAções
{{ user.name }}{{ user.email }} + + {{ "Ativo" if user.is_active else "Inativo" }} + + {{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else 'Nunca' }} +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 9327f54..778505d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,9 +10,9 @@ - + - +