Version base funcionando pre-test
This commit is contained in:
parent
1a931474b0
commit
79194e0f61
128
app.py
128
app.py
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
from flask import Flask
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template
|
||||
from flask_login import LoginManager
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_session import Session
|
||||
|
@ -10,108 +11,149 @@ from config import config
|
|||
|
||||
# Inicialización de extensiones
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Por favor inicie sesión para acceder a esta página.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
login_manager.login_view = "auth.login"
|
||||
login_manager.login_message = "Por favor inicie sesión para acceder a esta página."
|
||||
login_manager.login_message_category = "warning"
|
||||
|
||||
bcrypt = Bcrypt()
|
||||
csrf = CSRFProtect()
|
||||
session = Session()
|
||||
cache = Cache()
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""Fábrica de aplicación Flask."""
|
||||
if config_name is None:
|
||||
config_name = os.environ.get('FLASK_ENV', 'default')
|
||||
|
||||
config_name = os.environ.get("FLASK_ENV", "default")
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
config[config_name].init_app(app)
|
||||
|
||||
|
||||
# Inicializar extensiones
|
||||
login_manager.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
csrf.init_app(app)
|
||||
session.init_app(app)
|
||||
cache.init_app(app)
|
||||
|
||||
|
||||
# Importar y registrar blueprints
|
||||
from routes.main_routes import main_bp
|
||||
from routes.auth_routes import auth_bp
|
||||
from routes.user_routes import users_bp
|
||||
from routes.project_routes import projects_bp
|
||||
from routes.document_routes import documents_bp
|
||||
from routes.schema_routes import schemas_bp
|
||||
from routes.admin_routes import admin_bp
|
||||
|
||||
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(projects_bp)
|
||||
app.register_blueprint(documents_bp)
|
||||
app.register_blueprint(schemas_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
|
||||
# Configurar cargador de usuario para Flask-Login
|
||||
from services.user_service import get_user_by_id
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return get_user_by_id(user_id)
|
||||
|
||||
|
||||
# Registrar handlers para errores
|
||||
register_error_handlers(app)
|
||||
|
||||
|
||||
# Context processor para variables globales en templates
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {"now": datetime.now()}
|
||||
|
||||
# Asegurar que existen los directorios de almacenamiento
|
||||
initialize_storage_structure(app)
|
||||
|
||||
|
||||
# Configurar sistema de logging
|
||||
from utils.logger import setup_logger
|
||||
|
||||
setup_logger(app)
|
||||
|
||||
# Inicializar datos por defecto
|
||||
with app.app_context():
|
||||
# Inicializar usuario administrador si no existe
|
||||
from services.auth_service import initialize_admin_user
|
||||
|
||||
initialize_admin_user()
|
||||
|
||||
# Inicializar esquemas predeterminados si no existen
|
||||
from services.schema_service import initialize_default_schemas
|
||||
|
||||
initialize_default_schemas()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
"""Registrar manejadores de errores para la aplicación."""
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('error.html',
|
||||
error_code=404,
|
||||
error_message="Página no encontrada"), 404
|
||||
|
||||
return (
|
||||
render_template(
|
||||
"error.html", error_code=404, error_message="Página no encontrada"
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(e):
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="Acceso denegado"), 403
|
||||
|
||||
return (
|
||||
render_template(
|
||||
"error.html", error_code=403, error_message="Acceso denegado"
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
return render_template('error.html',
|
||||
error_code=500,
|
||||
error_message="Error interno del servidor"), 500
|
||||
return (
|
||||
render_template(
|
||||
"error.html", error_code=500, error_message="Error interno del servidor"
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
|
||||
def initialize_storage_structure(app):
|
||||
"""Crear estructura de almacenamiento si no existe."""
|
||||
storage_path = app.config['STORAGE_PATH']
|
||||
|
||||
storage_path = app.config["STORAGE_PATH"]
|
||||
|
||||
# Crear directorios principales
|
||||
os.makedirs(os.path.join(storage_path, 'logs'), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, 'schemas'), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, 'users'), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, 'filetypes'), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, 'projects'), exist_ok=True)
|
||||
|
||||
os.makedirs(os.path.join(storage_path, "logs"), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, "schemas"), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, "users"), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, "filetypes"), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, "projects"), exist_ok=True)
|
||||
os.makedirs(os.path.join(storage_path, "exports"), exist_ok=True)
|
||||
|
||||
# Inicializar archivos JSON si no existen
|
||||
init_json_file(os.path.join(storage_path, 'schemas', 'schema.json'), {})
|
||||
init_json_file(os.path.join(storage_path, 'users', 'users.json'), {})
|
||||
init_json_file(os.path.join(storage_path, 'filetypes', 'filetypes.json'), {})
|
||||
init_json_file(os.path.join(storage_path, 'indices.json'),
|
||||
{"max_project_id": 0, "max_document_id": 0})
|
||||
init_json_file(os.path.join(storage_path, "schemas", "schema.json"), {})
|
||||
init_json_file(os.path.join(storage_path, "users", "users.json"), {})
|
||||
init_json_file(os.path.join(storage_path, "filetypes", "filetypes.json"), {})
|
||||
init_json_file(
|
||||
os.path.join(storage_path, "indices.json"),
|
||||
{"max_project_id": 0, "max_document_id": 0},
|
||||
)
|
||||
|
||||
|
||||
def init_json_file(file_path, default_content):
|
||||
"""Inicializar un archivo JSON si no existe."""
|
||||
if not os.path.exists(file_path):
|
||||
import json
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(default_content, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if __name__ == '__main__':
|
||||
from flask import render_template
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
app.run(debug=True)
|
||||
app.run(debug=True)
|
||||
|
|
123
config.py
123
config.py
|
@ -5,82 +5,91 @@ from dotenv import load_dotenv
|
|||
# Cargar variables de entorno desde archivo .env
|
||||
load_dotenv()
|
||||
|
||||
# Directorio base donde se ejecuta la aplicación
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuración base para la aplicación."""
|
||||
# Configuración de Flask
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'clave-secreta-predeterminada'
|
||||
STORAGE_PATH = os.environ.get('STORAGE_PATH') or 'storage'
|
||||
|
||||
# Configuración de sesión
|
||||
SESSION_TYPE = 'filesystem'
|
||||
SESSION_FILE_DIR = os.path.join(STORAGE_PATH, 'sessions')
|
||||
"""Configuración base."""
|
||||
|
||||
# Seguridad
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY") or "clave-secreta-por-defecto"
|
||||
BCRYPT_LOG_ROUNDS = 12
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Sesión
|
||||
SESSION_TYPE = "filesystem"
|
||||
SESSION_FILE_DIR = os.path.join(basedir, "flask_session")
|
||||
SESSION_PERMANENT = True
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
|
||||
|
||||
# Configuración de carga de archivos
|
||||
MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB límite global
|
||||
UPLOAD_FOLDER = os.path.join(STORAGE_PATH, 'projects')
|
||||
|
||||
# Configuración de caché
|
||||
CACHE_TYPE = 'SimpleCache'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
|
||||
SESSION_USE_SIGNER = True
|
||||
|
||||
# Caché
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
|
||||
# Configuración de logging
|
||||
LOG_DIR = os.path.join(STORAGE_PATH, 'logs')
|
||||
|
||||
|
||||
# Almacenamiento
|
||||
STORAGE_PATH = os.environ.get("STORAGE_PATH") or os.path.join(basedir, "storage")
|
||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# Configuración de la aplicación
|
||||
APP_NAME = "Arch"
|
||||
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL") or "admin@example.com"
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") or "admin"
|
||||
|
||||
@staticmethod
|
||||
def init_app(app):
|
||||
"""Inicialización adicional de la aplicación."""
|
||||
# Asegurar que existen directorios necesarios
|
||||
os.makedirs(Config.SESSION_FILE_DIR, exist_ok=True)
|
||||
os.makedirs(Config.LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Configuración para entorno de desarrollo."""
|
||||
"""Configuración para desarrollo."""
|
||||
|
||||
DEBUG = True
|
||||
|
||||
TESTING = False
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Configuración para entorno de pruebas."""
|
||||
"""Configuración para pruebas."""
|
||||
|
||||
TESTING = True
|
||||
STORAGE_PATH = 'test_storage'
|
||||
WTF_CSRF_ENABLED = False # Deshabilitar CSRF para pruebas
|
||||
|
||||
DEBUG = True
|
||||
WTF_CSRF_ENABLED = False
|
||||
BCRYPT_LOG_ROUNDS = 4 # más rápido para pruebas
|
||||
STORAGE_PATH = os.path.join(basedir, "test_storage")
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Configuración para entorno de producción."""
|
||||
"""Configuración para producción."""
|
||||
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
@classmethod
|
||||
def init_app(cls, app):
|
||||
|
||||
# Override secret key in production
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
# Opciones de seguridad más estrictas
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
REMEMBER_COOKIE_SECURE = True
|
||||
REMEMBER_COOKIE_HTTPONLY = True
|
||||
|
||||
@staticmethod
|
||||
def init_app(app):
|
||||
Config.init_app(app)
|
||||
|
||||
# Configuración adicional para producción
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# Configurar handler para errores
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(cls.LOG_DIR, 'arch.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=10
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||
))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.info('ARCH inicializado')
|
||||
|
||||
# Verificar SECRET_KEY
|
||||
if app.config["SECRET_KEY"] == "clave-secreta-por-defecto":
|
||||
import warnings
|
||||
|
||||
warnings.warn("SECRET_KEY no configurada para producción!")
|
||||
|
||||
|
||||
# Diccionario con las configuraciones disponibles
|
||||
# Mapeo de configuraciones
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'testing': TestingConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
"development": DevelopmentConfig,
|
||||
"testing": TestingConfig,
|
||||
"production": ProductionConfig,
|
||||
"default": DevelopmentConfig,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Paquete de middleware para la aplicación ARCH.
|
||||
|
||||
Este paquete contiene los módulos que implementan funcionalidades
|
||||
intermedias entre las solicitudes HTTP y las respuestas, como
|
||||
autenticación, verificación de permisos y registro de actividad.
|
||||
"""
|
|
@ -0,0 +1,121 @@
|
|||
from functools import wraps
|
||||
from flask import request, redirect, url_for, flash, current_app
|
||||
from flask_login import current_user
|
||||
|
||||
def login_required_api(f):
|
||||
"""
|
||||
Decorador para verificar autenticación en endpoints de API.
|
||||
A diferencia del decorador estándar de Flask-Login, este devuelve
|
||||
una respuesta JSON en lugar de redirigir.
|
||||
|
||||
Args:
|
||||
f: Función a decorar
|
||||
|
||||
Returns:
|
||||
function: Función decorada
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Autenticación requerida',
|
||||
'code': 401
|
||||
}, 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def check_permission(required_level):
|
||||
"""
|
||||
Decorador para verificar nivel de permisos en endpoints de API.
|
||||
|
||||
Args:
|
||||
required_level (int): Nivel mínimo requerido
|
||||
|
||||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Autenticación requerida',
|
||||
'code': 401
|
||||
}, 401
|
||||
|
||||
if not current_user.has_permission(required_level):
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'No tiene permisos suficientes para esta acción',
|
||||
'code': 403
|
||||
}, 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def check_active_user(f):
|
||||
"""
|
||||
Decorador para verificar que el usuario está activo y no ha expirado.
|
||||
|
||||
Args:
|
||||
f: Función a decorar
|
||||
|
||||
Returns:
|
||||
function: Función decorada
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if not current_user.is_active():
|
||||
flash('Su cuenta está desactivada. Contacte al administrador.', 'danger')
|
||||
return redirect(url_for('auth.logout'))
|
||||
|
||||
if current_user.is_expired():
|
||||
flash('Su cuenta ha expirado. Contacte al administrador.', 'danger')
|
||||
return redirect(url_for('auth.logout'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def log_activity(activity_type):
|
||||
"""
|
||||
Decorador para registrar actividad del usuario.
|
||||
|
||||
Args:
|
||||
activity_type (str): Tipo de actividad a registrar
|
||||
|
||||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Ejecutar la función original
|
||||
result = f(*args, **kwargs)
|
||||
|
||||
# Registrar actividad si el usuario está autenticado
|
||||
if current_user.is_authenticated:
|
||||
from utils.logger import log_user_activity
|
||||
|
||||
# Extraer información relevante
|
||||
user_id = current_user.id
|
||||
ip_address = request.remote_addr
|
||||
user_agent = request.user_agent.string
|
||||
|
||||
# Registrar actividad
|
||||
log_user_activity(
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details=kwargs
|
||||
)
|
||||
|
||||
return result
|
||||
return decorated_function
|
||||
return decorator
|
|
@ -0,0 +1,229 @@
|
|||
from functools import wraps
|
||||
from flask import render_template, abort, current_app
|
||||
from flask_login import current_user
|
||||
|
||||
def permission_required(min_level):
|
||||
"""
|
||||
Decorador para verificar nivel de permisos en rutas web.
|
||||
|
||||
Args:
|
||||
min_level (int): Nivel mínimo requerido
|
||||
|
||||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
|
||||
if not current_user.has_permission(min_level):
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="No tiene permisos suficientes para acceder a esta página."), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def project_access_required(access_type='view'):
|
||||
"""
|
||||
Decorador para verificar acceso a un proyecto específico.
|
||||
|
||||
Args:
|
||||
access_type (str): Tipo de acceso ('view' o 'edit')
|
||||
|
||||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
|
||||
# Obtener ID del proyecto de los argumentos
|
||||
project_id = kwargs.get('project_id')
|
||||
|
||||
if not project_id:
|
||||
abort(400, description="ID de proyecto no proporcionado")
|
||||
|
||||
# Verificar permisos específicos del proyecto
|
||||
from services.project_service import get_project
|
||||
|
||||
project = get_project(project_id)
|
||||
|
||||
if not project:
|
||||
abort(404, description="Proyecto no encontrado")
|
||||
|
||||
# Verificar si el proyecto está activo
|
||||
if project.get('estado') != 'activo' and not current_user.has_permission(9000):
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="El proyecto no está activo."), 403
|
||||
|
||||
# Verificar permisos específicos
|
||||
# Administradores siempre tienen acceso
|
||||
if current_user.has_permission(9000):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Verificar permisos en el archivo de permisos del proyecto
|
||||
from utils.file_utils import load_json_file
|
||||
import os
|
||||
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
project_dir = os.path.join(storage_path, 'projects', project.get('directory', ''))
|
||||
permissions_file = os.path.join(project_dir, 'permissions.json')
|
||||
|
||||
permissions = load_json_file(permissions_file, {})
|
||||
|
||||
# Verificar permisos específicos del usuario
|
||||
user_permissions = permissions.get(current_user.id, {})
|
||||
|
||||
if access_type == 'edit' and user_permissions.get('can_edit', False):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if access_type == 'view' and user_permissions.get('can_view', False):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Verificar nivel general requerido según el esquema
|
||||
schema_code = project.get('esquema')
|
||||
|
||||
if not schema_code:
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="No tiene permisos para acceder a este proyecto."), 403
|
||||
|
||||
from services.schema_service import get_schema
|
||||
|
||||
schema = get_schema(schema_code)
|
||||
|
||||
if not schema:
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="Esquema no encontrado."), 403
|
||||
|
||||
# Nivel mínimo para ver/editar según el esquema
|
||||
min_level_view = 0 # Por defecto, cualquier usuario autenticado puede ver
|
||||
min_level_edit = 5000 # Por defecto, se requiere nivel de gestor para editar
|
||||
|
||||
# Si hay configuración específica en el esquema, usarla
|
||||
if 'nivel_ver' in schema:
|
||||
min_level_view = schema.get('nivel_ver', 0)
|
||||
|
||||
if 'nivel_editar' in schema:
|
||||
min_level_edit = schema.get('nivel_editar', 5000)
|
||||
|
||||
# Verificar nivel según tipo de acceso
|
||||
if access_type == 'edit' and current_user.has_permission(min_level_edit):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if access_type == 'view' and current_user.has_permission(min_level_view):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Si llega aquí, no tiene permisos
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="No tiene permisos para acceder a este proyecto."), 403
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def document_access_required(access_type='view'):
|
||||
"""
|
||||
Decorador para verificar acceso a un documento específico.
|
||||
|
||||
Args:
|
||||
access_type (str): Tipo de acceso ('view' o 'edit')
|
||||
|
||||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
|
||||
# Obtener IDs de proyecto y documento
|
||||
project_id = kwargs.get('project_id')
|
||||
document_id = kwargs.get('document_id')
|
||||
|
||||
if not project_id or not document_id:
|
||||
abort(400, description="ID de proyecto o documento no proporcionado")
|
||||
|
||||
# Verificar permisos del proyecto primero
|
||||
from services.project_service import get_project
|
||||
|
||||
project = get_project(project_id)
|
||||
|
||||
if not project:
|
||||
abort(404, description="Proyecto no encontrado")
|
||||
|
||||
# Verificar si el proyecto está activo
|
||||
if project.get('estado') != 'activo' and not current_user.has_permission(9000):
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="El proyecto no está activo."), 403
|
||||
|
||||
# Administradores siempre tienen acceso
|
||||
if current_user.has_permission(9000):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Obtener información del documento
|
||||
from services.document_service import get_document
|
||||
|
||||
document = get_document(project_id, document_id)
|
||||
|
||||
if not document:
|
||||
abort(404, description="Documento no encontrado")
|
||||
|
||||
# Obtener el esquema del proyecto
|
||||
schema_code = project.get('esquema')
|
||||
|
||||
if not schema_code:
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="No tiene permisos para acceder a este documento."), 403
|
||||
|
||||
# Cargar el esquema específico del proyecto
|
||||
from utils.file_utils import load_json_file
|
||||
import os
|
||||
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
project_dir = os.path.join(storage_path, 'projects', project.get('directory', ''))
|
||||
project_schema_file = os.path.join(project_dir, 'schema.json')
|
||||
|
||||
schema = load_json_file(project_schema_file)
|
||||
|
||||
if not schema:
|
||||
# Si no hay esquema específico, usar el general
|
||||
from services.schema_service import get_schema
|
||||
schema = get_schema(schema_code)
|
||||
|
||||
if not schema:
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="Esquema no encontrado."), 403
|
||||
|
||||
# Obtener el tipo de documento
|
||||
document_name = document.get('document_id', '').split('_', 1)[1] if '_' in document.get('document_id', '') else ''
|
||||
|
||||
# Buscar configuración de permisos para este tipo de documento
|
||||
for doc_type in schema.get('documentos', []):
|
||||
if doc_type.get('nombre', '').lower().replace(' ', '_') == document_name:
|
||||
# Verificar nivel según tipo de acceso
|
||||
if access_type == 'edit' and current_user.has_permission(doc_type.get('nivel_editar', 5000)):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if access_type == 'view' and current_user.has_permission(doc_type.get('nivel_ver', 0)):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Si no se encontró configuración específica o no tiene permisos
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="No tiene permisos para acceder a este documento."), 403
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
|
@ -27,7 +27,8 @@ Flask-Session==0.5.0
|
|||
requests==2.31.0
|
||||
|
||||
# Validación de archivos
|
||||
python-magic==0.4.27
|
||||
python-magic-bin; platform_system == "Windows"
|
||||
python-magic; platform_system != "Windows"
|
||||
|
||||
# Mejoras de desarrollo y mantenimiento
|
||||
loguru==0.7.0
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
Paquete de rutas para la aplicación ARCH.
|
||||
|
||||
Este paquete contiene los blueprints de Flask que definen las rutas
|
||||
y controladores para las diferentes secciones de la aplicación.
|
||||
"""
|
|
@ -0,0 +1,288 @@
|
|||
import os
|
||||
import json
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
request,
|
||||
jsonify,
|
||||
current_app,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange
|
||||
|
||||
from services.index_service import build_search_index, get_index_stats
|
||||
from services.user_service import get_user_stats
|
||||
from utils.security import permission_required
|
||||
from utils.logger import get_audit_logs
|
||||
from utils.file_utils import get_directory_size
|
||||
|
||||
# Definir Blueprint
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
# Formularios
|
||||
class FileTypeForm(FlaskForm):
|
||||
"""Formulario para tipo de archivo."""
|
||||
|
||||
extension = StringField("Extensión", validators=[DataRequired(), Length(1, 10)])
|
||||
descripcion = StringField(
|
||||
"Descripción", validators=[DataRequired(), Length(1, 100)]
|
||||
)
|
||||
mime_type = StringField("MIME Type", validators=[DataRequired(), Length(1, 100)])
|
||||
tamano_maximo = IntegerField(
|
||||
"Tamaño máximo (bytes)", validators=[NumberRange(min=1)], default=10485760
|
||||
)
|
||||
submit = SubmitField("Guardar")
|
||||
|
||||
|
||||
# Rutas
|
||||
@admin_bp.route("/")
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def dashboard():
|
||||
"""Panel de administración."""
|
||||
# Obtener estadísticas
|
||||
user_stats = get_user_stats()
|
||||
index_stats = get_index_stats()
|
||||
|
||||
# Obtener tamaño del almacenamiento
|
||||
storage_path = os.path.join(os.getcwd(), "storage")
|
||||
storage_size = get_directory_size(storage_path) // (1024 * 1024) # Convertir a MB
|
||||
|
||||
return render_template(
|
||||
"admin/dashboard.html",
|
||||
user_stats=user_stats,
|
||||
index_stats=index_stats,
|
||||
storage_size=storage_size,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/filetypes")
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def filetypes():
|
||||
"""Gestión de tipos de archivo."""
|
||||
# Cargar tipos de archivo
|
||||
storage_path = os.path.join(os.getcwd(), "storage")
|
||||
filetypes_file = os.path.join(storage_path, "filetypes", "filetypes.json")
|
||||
|
||||
with open(filetypes_file, "r", encoding="utf-8") as f:
|
||||
filetypes = json.load(f)
|
||||
|
||||
form = FileTypeForm()
|
||||
|
||||
return render_template("admin/filetypes.html", filetypes=filetypes, form=form)
|
||||
|
||||
|
||||
@admin_bp.route("/filetypes/add", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def add_filetype():
|
||||
"""Añadir nuevo tipo de archivo."""
|
||||
form = FileTypeForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Cargar tipos de archivo actuales
|
||||
storage_path = os.path.join(os.getcwd(), "storage")
|
||||
filetypes_file = os.path.join(storage_path, "filetypes", "filetypes.json")
|
||||
|
||||
with open(filetypes_file, "r", encoding="utf-8") as f:
|
||||
filetypes = json.load(f)
|
||||
|
||||
# Verificar si ya existe
|
||||
extension = form.extension.data.lower()
|
||||
if extension in filetypes:
|
||||
flash(f"El tipo de archivo '{extension}' ya existe.", "danger")
|
||||
return redirect(url_for("admin.filetypes"))
|
||||
|
||||
# Añadir nuevo tipo
|
||||
filetypes[extension] = {
|
||||
"extension": extension,
|
||||
"descripcion": form.descripcion.data,
|
||||
"mime_type": form.mime_type.data,
|
||||
"tamano_maximo": form.tamano_maximo.data,
|
||||
}
|
||||
|
||||
# Guardar cambios
|
||||
with open(filetypes_file, "w", encoding="utf-8") as f:
|
||||
json.dump(filetypes, f, ensure_ascii=False, indent=2)
|
||||
|
||||
flash(f"Tipo de archivo '{extension}' añadido correctamente.", "success")
|
||||
else:
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
flash(f"{getattr(form, field).label.text}: {error}", "danger")
|
||||
|
||||
return redirect(url_for("admin.filetypes"))
|
||||
|
||||
|
||||
@admin_bp.route("/filetypes/delete/<extension>", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def delete_filetype(extension):
|
||||
"""Eliminar tipo de archivo."""
|
||||
# Cargar tipos de archivo actuales
|
||||
storage_path = os.path.join(os.getcwd(), "storage")
|
||||
filetypes_file = os.path.join(storage_path, "filetypes", "filetypes.json")
|
||||
|
||||
with open(filetypes_file, "r", encoding="utf-8") as f:
|
||||
filetypes = json.load(f)
|
||||
|
||||
# Verificar si existe
|
||||
if extension not in filetypes:
|
||||
flash(f"El tipo de archivo '{extension}' no existe.", "danger")
|
||||
return redirect(url_for("admin.filetypes"))
|
||||
|
||||
# Eliminar tipo
|
||||
del filetypes[extension]
|
||||
|
||||
# Guardar cambios
|
||||
with open(filetypes_file, "w", encoding="utf-8") as f:
|
||||
json.dump(filetypes, f, ensure_ascii=False, indent=2)
|
||||
|
||||
flash(f"Tipo de archivo '{extension}' eliminado correctamente.", "success")
|
||||
return redirect(url_for("admin.filetypes"))
|
||||
|
||||
|
||||
@admin_bp.route("/system")
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def system():
|
||||
"""Estado del sistema."""
|
||||
import sys
|
||||
|
||||
storage_path = current_app.config["STORAGE_PATH"]
|
||||
|
||||
# Define a helper function to get directory size
|
||||
def get_directory_size(path):
|
||||
total_size = 0
|
||||
if os.path.exists(path):
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
# skip if it is symbolic link
|
||||
if not os.path.islink(fp):
|
||||
total_size += os.path.getsize(fp)
|
||||
return total_size
|
||||
|
||||
# Recopilar estadísticas del sistema
|
||||
stats = {
|
||||
"storage_size": get_directory_size(storage_path),
|
||||
"projects_count": (
|
||||
len(os.listdir(os.path.join(storage_path, "projects")))
|
||||
if os.path.exists(os.path.join(storage_path, "projects"))
|
||||
else 0
|
||||
),
|
||||
"log_size": get_directory_size(os.path.join(storage_path, "logs")),
|
||||
"python_version": sys.version,
|
||||
"platform": sys.platform,
|
||||
}
|
||||
|
||||
return render_template("admin/system.html", stats=stats)
|
||||
|
||||
|
||||
@admin_bp.route("/system/clear_logs", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def clear_logs():
|
||||
"""Limpiar logs del sistema."""
|
||||
storage_path = current_app.config["STORAGE_PATH"]
|
||||
logs_path = os.path.join(storage_path, "logs")
|
||||
|
||||
try:
|
||||
for filename in os.listdir(logs_path):
|
||||
file_path = os.path.join(logs_path, filename)
|
||||
if os.path.isfile(file_path) and not filename.endswith(".log"):
|
||||
os.unlink(file_path)
|
||||
|
||||
flash("Archivos de log rotados eliminados correctamente.", "success")
|
||||
except Exception as e:
|
||||
flash(f"Error al limpiar logs: {str(e)}", "danger")
|
||||
|
||||
return redirect(url_for("admin.system"))
|
||||
|
||||
|
||||
@admin_bp.route("/initialize")
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def initialize():
|
||||
"""Inicializar o reiniciar el sistema."""
|
||||
from services.filetype_service import initialize_default_filetypes
|
||||
from services.schema_service import initialize_default_schemas
|
||||
|
||||
try:
|
||||
initialize_default_filetypes()
|
||||
initialize_default_schemas()
|
||||
|
||||
flash("Sistema inicializado correctamente.", "success")
|
||||
except Exception as e:
|
||||
flash(f"Error al inicializar el sistema: {str(e)}", "danger")
|
||||
|
||||
return redirect(url_for("admin.system"))
|
||||
|
||||
|
||||
@admin_bp.route("/system/rebuild-index", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def rebuild_index():
|
||||
"""Reconstruir índice de búsqueda."""
|
||||
success, message = build_search_index()
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return redirect(url_for("admin.system"))
|
||||
|
||||
|
||||
@admin_bp.route("/audit-logs")
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def audit_logs():
|
||||
"""Ver logs de auditoría."""
|
||||
# Obtener parámetros de filtrado
|
||||
filters = {
|
||||
"user_id": request.args.get("user_id"),
|
||||
"activity_type": request.args.get("activity_type"),
|
||||
"start_date": request.args.get("start_date"),
|
||||
"end_date": request.args.get("end_date"),
|
||||
}
|
||||
|
||||
# Filtrar logs vacíos
|
||||
filters = {k: v for k, v in filters.items() if v}
|
||||
|
||||
# Obtener logs
|
||||
logs = get_audit_logs(filters, limit=100)
|
||||
|
||||
return render_template("admin/audit_logs.html", logs=logs, filters=filters)
|
||||
|
||||
|
||||
@admin_bp.route("/api/system-info")
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def api_system_info():
|
||||
"""API para obtener información del sistema."""
|
||||
# Obtener información del sistema
|
||||
storage_path = os.path.join(os.getcwd(), "storage")
|
||||
|
||||
# Tamaños de directorios
|
||||
storage_info = {
|
||||
"total": get_directory_size(storage_path) // (1024 * 1024), # MB
|
||||
"logs": get_directory_size(os.path.join(storage_path, "logs")) // (1024 * 1024),
|
||||
"projects": get_directory_size(os.path.join(storage_path, "projects"))
|
||||
// (1024 * 1024),
|
||||
}
|
||||
|
||||
# Estadísticas de usuarios
|
||||
user_stats = get_user_stats()
|
||||
|
||||
# Estadísticas de índice
|
||||
index_stats = get_index_stats()
|
||||
|
||||
return jsonify({"storage": storage_info, "users": user_stats, "index": index_stats})
|
|
@ -0,0 +1,22 @@
|
|||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
# Create a blueprint for main routes
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
def index():
|
||||
"""Render the homepage."""
|
||||
if current_user.is_authenticated:
|
||||
# If user is logged in, we could redirect to a dashboard or just show the main page
|
||||
return render_template("index.html")
|
||||
else:
|
||||
# For non-authenticated users, show the public homepage
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@main_bp.route("/about")
|
||||
def about():
|
||||
"""About page."""
|
||||
return render_template("about.html")
|
|
@ -0,0 +1,185 @@
|
|||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
TextAreaField,
|
||||
FieldList,
|
||||
FormField,
|
||||
SelectField,
|
||||
IntegerField,
|
||||
SubmitField,
|
||||
)
|
||||
from wtforms.validators import DataRequired, Length, NumberRange
|
||||
|
||||
from services.schema_service import (
|
||||
get_all_schemas,
|
||||
get_schema_by_id,
|
||||
create_schema,
|
||||
update_schema,
|
||||
delete_schema,
|
||||
)
|
||||
from services.document_service import get_allowed_filetypes
|
||||
from utils.security import permission_required
|
||||
from utils.validators import validate_schema_data
|
||||
|
||||
# Definir Blueprint
|
||||
schemas_bp = Blueprint("schemas", __name__, url_prefix="/schemas")
|
||||
|
||||
|
||||
# Formularios
|
||||
class DocumentTypeForm(FlaskForm):
|
||||
"""Formulario para tipo de documento en esquema."""
|
||||
|
||||
tipo = StringField("Tipo de Documento", validators=[DataRequired()])
|
||||
nombre = StringField("Nombre", validators=[DataRequired()])
|
||||
nivel_ver = IntegerField("Nivel para Ver", default=0)
|
||||
nivel_editar = IntegerField("Nivel para Editar", default=5000)
|
||||
|
||||
|
||||
class SchemaForm(FlaskForm):
|
||||
"""Formulario para esquema."""
|
||||
|
||||
codigo = StringField("Código", validators=[DataRequired(), Length(1, 20)])
|
||||
descripcion = TextAreaField("Descripción", validators=[DataRequired()])
|
||||
documentos = FieldList(FormField(DocumentTypeForm), min_entries=1)
|
||||
submit = SubmitField("Guardar")
|
||||
|
||||
|
||||
# Rutas
|
||||
@schemas_bp.route("/")
|
||||
@login_required
|
||||
@permission_required(5000) # Nivel mínimo para ver esquemas
|
||||
def list():
|
||||
"""Lista todos los esquemas disponibles."""
|
||||
schemas = get_all_schemas()
|
||||
return render_template("schemas/list.html", schemas=schemas)
|
||||
|
||||
|
||||
@schemas_bp.route("/view/<schema_id>")
|
||||
@login_required
|
||||
@permission_required(5000)
|
||||
def view(schema_id):
|
||||
"""Ver detalle de un esquema."""
|
||||
schema = get_schema_by_id(schema_id)
|
||||
if not schema:
|
||||
flash("Esquema no encontrado.", "danger")
|
||||
return redirect(url_for("schemas.list"))
|
||||
|
||||
return render_template("schemas/view.html", schema=schema)
|
||||
|
||||
|
||||
@schemas_bp.route("/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def create():
|
||||
"""Crear nuevo esquema."""
|
||||
form = SchemaForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
data = {
|
||||
"codigo": form.codigo.data,
|
||||
"descripcion": form.descripcion.data,
|
||||
"documentos": [],
|
||||
}
|
||||
|
||||
for doc_form in form.documentos:
|
||||
data["documentos"].append(
|
||||
{
|
||||
"tipo": doc_form.tipo.data,
|
||||
"nombre": doc_form.nombre.data,
|
||||
"nivel_ver": doc_form.nivel_ver.data,
|
||||
"nivel_editar": doc_form.nivel_editar.data,
|
||||
}
|
||||
)
|
||||
|
||||
success, message = create_schema(data, current_user.id)
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
return redirect(url_for("schemas.list"))
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return render_template("schemas/create.html", form=form)
|
||||
|
||||
|
||||
@schemas_bp.route("/edit/<schema_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def edit(schema_id):
|
||||
"""Editar esquema existente."""
|
||||
schema = get_schema_by_id(schema_id)
|
||||
if not schema:
|
||||
flash("Esquema no encontrado.", "danger")
|
||||
return redirect(url_for("schemas.list"))
|
||||
|
||||
# Prepopulate form
|
||||
form = SchemaForm(obj=schema)
|
||||
|
||||
if form.validate_on_submit():
|
||||
data = {
|
||||
"codigo": form.codigo.data,
|
||||
"descripcion": form.descripcion.data,
|
||||
"documentos": [],
|
||||
}
|
||||
|
||||
for doc_form in form.documentos:
|
||||
data["documentos"].append(
|
||||
{
|
||||
"tipo": doc_form.tipo.data,
|
||||
"nombre": doc_form.nombre.data,
|
||||
"nivel_ver": doc_form.nivel_ver.data,
|
||||
"nivel_editar": doc_form.nivel_editar.data,
|
||||
}
|
||||
)
|
||||
|
||||
success, message = update_schema(schema_id, data)
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
return redirect(url_for("schemas.list"))
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return render_template("schemas/edit.html", form=form, schema=schema)
|
||||
|
||||
|
||||
@schemas_bp.route("/delete/<schema_id>", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def delete(schema_id):
|
||||
"""Eliminar esquema."""
|
||||
success, message = delete_schema(schema_id)
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return redirect(url_for("schemas.list"))
|
||||
|
||||
|
||||
@schemas_bp.route("/api/list")
|
||||
@login_required
|
||||
def api_list():
|
||||
"""API para listar esquemas."""
|
||||
schemas = get_all_schemas()
|
||||
return jsonify(
|
||||
[
|
||||
{"code": code, "description": schema["descripcion"]}
|
||||
for code, schema in schemas.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@schemas_bp.route("/api/get/<schema_code>")
|
||||
@login_required
|
||||
def api_get(schema_code):
|
||||
"""API para obtener un esquema específico."""
|
||||
schema = get_schema(schema_code)
|
||||
|
||||
if not schema:
|
||||
return jsonify({"error": "Esquema no encontrado"}), 404
|
||||
|
||||
return jsonify(schema)
|
|
@ -0,0 +1,259 @@
|
|||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
request,
|
||||
jsonify,
|
||||
current_app,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
IntegerField,
|
||||
EmailField,
|
||||
BooleanField,
|
||||
SubmitField,
|
||||
)
|
||||
from wtforms.validators import DataRequired, Email, Length, Optional, EqualTo
|
||||
|
||||
from services.auth_service import (
|
||||
get_all_users,
|
||||
get_user_by_username,
|
||||
create_user,
|
||||
update_user,
|
||||
delete_user,
|
||||
)
|
||||
from services.user_service import (
|
||||
filter_users,
|
||||
get_user_stats,
|
||||
check_username_availability,
|
||||
)
|
||||
from utils.validators import validate_user_data
|
||||
from utils.security import permission_required
|
||||
from utils.logger import log_user_management
|
||||
|
||||
# Definir Blueprint
|
||||
users_bp = Blueprint("users", __name__, url_prefix="/users")
|
||||
|
||||
|
||||
# Formularios
|
||||
class UserForm(FlaskForm):
|
||||
"""Formulario para crear/editar usuarios."""
|
||||
|
||||
nombre = StringField("Nombre completo", validators=[DataRequired(), Length(1, 100)])
|
||||
username = StringField(
|
||||
"Nombre de usuario", validators=[DataRequired(), Length(3, 20)]
|
||||
)
|
||||
email = EmailField("Email", validators=[DataRequired(), Email()])
|
||||
password = PasswordField("Contraseña", validators=[Optional(), Length(8, 64)])
|
||||
password_confirm = PasswordField(
|
||||
"Confirmar contraseña",
|
||||
validators=[
|
||||
Optional(),
|
||||
EqualTo("password", message="Las contraseñas deben coincidir"),
|
||||
],
|
||||
)
|
||||
nivel = IntegerField("Nivel de acceso", validators=[DataRequired()])
|
||||
idioma = SelectField("Idioma", choices=[("es", "Español"), ("en", "Inglés")])
|
||||
empresa = StringField("Empresa", validators=[Optional(), Length(0, 100)])
|
||||
estado = SelectField(
|
||||
"Estado", choices=[("activo", "Activo"), ("inactivo", "Inactivo")]
|
||||
)
|
||||
fecha_caducidad = StringField(
|
||||
"Fecha de caducidad (YYYY-MM-DD)", validators=[Optional()]
|
||||
)
|
||||
submit = SubmitField("Guardar")
|
||||
|
||||
|
||||
class UserFilterForm(FlaskForm):
|
||||
"""Formulario para filtrar usuarios."""
|
||||
|
||||
empresa = StringField("Empresa", validators=[Optional()])
|
||||
estado = SelectField(
|
||||
"Estado",
|
||||
choices=[("", "Todos"), ("activo", "Activo"), ("inactivo", "Inactivo")],
|
||||
validators=[Optional()],
|
||||
)
|
||||
nivel_min = IntegerField("Nivel mínimo", validators=[Optional()])
|
||||
nivel_max = IntegerField("Nivel máximo", validators=[Optional()])
|
||||
submit = SubmitField("Filtrar")
|
||||
|
||||
|
||||
# Rutas
|
||||
@users_bp.route("/")
|
||||
@login_required
|
||||
@permission_required(5000) # Assuming 5000+ is admin level permission
|
||||
def list():
|
||||
"""List all users."""
|
||||
# Import here to avoid circular imports
|
||||
from services.user_service import get_all_users
|
||||
|
||||
users = get_all_users()
|
||||
|
||||
return render_template("users/list.html", users=users)
|
||||
|
||||
|
||||
@users_bp.route("/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def create():
|
||||
"""Crear nuevo usuario."""
|
||||
form = UserForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Validar datos
|
||||
data = {
|
||||
"nombre": form.nombre.data,
|
||||
"username": form.username.data,
|
||||
"email": form.email.data,
|
||||
"password": form.password.data,
|
||||
"nivel": form.nivel.data,
|
||||
"idioma": form.idioma.data,
|
||||
"empresa": form.empresa.data,
|
||||
"estado": form.estado.data,
|
||||
"fecha_caducidad": (
|
||||
form.fecha_caducidad.data if form.fecha_caducidad.data else None
|
||||
),
|
||||
}
|
||||
|
||||
is_valid, errors = validate_user_data(data)
|
||||
|
||||
if not is_valid:
|
||||
for field, error in errors.items():
|
||||
flash(f"Error en {field}: {error}", "danger")
|
||||
return render_template("users/create.html", form=form)
|
||||
|
||||
# Crear usuario
|
||||
success, message = create_user(
|
||||
username=data["username"],
|
||||
nombre=data["nombre"],
|
||||
email=data["email"],
|
||||
password=data["password"],
|
||||
nivel=data["nivel"],
|
||||
idioma=data["idioma"],
|
||||
fecha_caducidad=data["fecha_caducidad"],
|
||||
empresa=data["empresa"],
|
||||
estado=data["estado"],
|
||||
)
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
|
||||
# Registrar actividad
|
||||
log_user_management(
|
||||
admin_id=current_user.id,
|
||||
target_user_id=data["username"],
|
||||
action="create",
|
||||
)
|
||||
|
||||
return redirect(url_for("users.list_users"))
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return render_template("users/create.html", form=form)
|
||||
|
||||
|
||||
@users_bp.route("/edit/<username>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def edit(username):
|
||||
"""Editar usuario existente."""
|
||||
user = get_user_by_username(username)
|
||||
|
||||
if not user:
|
||||
flash(f"Usuario {username} no encontrado.", "danger")
|
||||
return redirect(url_for("users.list_users"))
|
||||
|
||||
form = UserForm(obj=user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Validar datos
|
||||
data = {
|
||||
"nombre": form.nombre.data,
|
||||
"email": form.email.data,
|
||||
"password": form.password.data,
|
||||
"nivel": form.nivel.data,
|
||||
"idioma": form.idioma.data,
|
||||
"empresa": form.empresa.data,
|
||||
"estado": form.estado.data,
|
||||
"fecha_caducidad": (
|
||||
form.fecha_caducidad.data if form.fecha_caducidad.data else None
|
||||
),
|
||||
}
|
||||
|
||||
is_valid, errors = validate_user_data(data, is_new_user=False)
|
||||
|
||||
if not is_valid:
|
||||
for field, error in errors.items():
|
||||
flash(f"Error en {field}: {error}", "danger")
|
||||
return render_template("users/edit.html", form=form, username=username)
|
||||
|
||||
# Actualizar usuario
|
||||
success, message = update_user(username, data)
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
|
||||
# Registrar actividad
|
||||
log_user_management(
|
||||
admin_id=current_user.id,
|
||||
target_user_id=username,
|
||||
action="update",
|
||||
details={"fields_updated": list(data.keys())},
|
||||
)
|
||||
|
||||
return redirect(url_for("users.list_users"))
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return render_template("users/edit.html", form=form, username=username)
|
||||
|
||||
|
||||
@users_bp.route("/delete/<username>", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def delete(username):
|
||||
"""Eliminar usuario."""
|
||||
if username == current_user.id:
|
||||
flash("No puede eliminar su propio usuario.", "danger")
|
||||
return redirect(url_for("users.list_users"))
|
||||
|
||||
if username == "admin":
|
||||
flash("No se puede eliminar el usuario administrador.", "danger")
|
||||
return redirect(url_for("users.list_users"))
|
||||
|
||||
success, message = delete_user(username)
|
||||
|
||||
if success:
|
||||
flash(message, "success")
|
||||
|
||||
# Registrar actividad
|
||||
log_user_management(
|
||||
admin_id=current_user.id, target_user_id=username, action="delete"
|
||||
)
|
||||
else:
|
||||
flash(message, "danger")
|
||||
|
||||
return redirect(url_for("users.list_users"))
|
||||
|
||||
|
||||
# API para verificar disponibilidad de nombre de usuario
|
||||
@users_bp.route("/api/check_username", methods=["POST"])
|
||||
@login_required
|
||||
@permission_required(9000) # Solo administradores
|
||||
def api_check_username():
|
||||
"""Verificar disponibilidad de nombre de usuario."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or "username" not in data:
|
||||
return jsonify({"error": "Se requiere nombre de usuario"}), 400
|
||||
|
||||
username = data["username"]
|
||||
available = check_username_availability(username)
|
||||
|
||||
return jsonify({"available": available})
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
Script to run the test suite for ARCH application.
|
||||
Executes all tests and generates JSON and HTML reports.
|
||||
"""
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
def run_tests(args):
|
||||
"""Run pytest with specified arguments and generate reports."""
|
||||
# Create test reports directory if needed
|
||||
os.makedirs('test_reports', exist_ok=True)
|
||||
|
||||
# Generate timestamp for report files
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
# Base pytest arguments
|
||||
pytest_args = [
|
||||
'-v', # Verbose output
|
||||
'--no-header', # No header in the output
|
||||
'--tb=short', # Short traceback
|
||||
f'--junitxml=test_reports/junit_{timestamp}.xml', # JUnit XML report
|
||||
'--cov=app', # Coverage for app module
|
||||
'--cov=routes', # Coverage for routes
|
||||
'--cov=services', # Coverage for services
|
||||
'--cov=utils', # Coverage for utils
|
||||
'--cov-report=html:test_reports/coverage', # HTML coverage report
|
||||
]
|
||||
|
||||
# Add test files/directories
|
||||
if args.tests:
|
||||
pytest_args.extend(args.tests)
|
||||
else:
|
||||
pytest_args.append('tests/')
|
||||
|
||||
# Execute tests
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Running tests at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"{'='*80}")
|
||||
|
||||
result = pytest.main(pytest_args)
|
||||
|
||||
# Generate JSON report
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Test report available at: test_reports/test_results_{timestamp}.json")
|
||||
print(f"Coverage report available at: test_reports/coverage/index.html")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run tests for ARCH application")
|
||||
parser.add_argument('tests', nargs='*', help='Specific test files or directories to run')
|
||||
parser.add_argument('--clean', action='store_true', help='Clean test storage and results before running')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.clean:
|
||||
# Clean temporary test storage
|
||||
if os.path.exists('test_storage'):
|
||||
shutil.rmtree('test_storage')
|
||||
# Clean previous test reports
|
||||
if os.path.exists('test_reports'):
|
||||
shutil.rmtree('test_reports')
|
||||
print("Cleaned test storage and reports.")
|
||||
|
||||
# Run tests and exit with the pytest result code
|
||||
sys.exit(run_tests(args))
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
Paquete de servicios para la aplicación ARCH.
|
||||
|
||||
Este paquete contiene los módulos que implementan la lógica de negocio
|
||||
de la aplicación, separando las responsabilidades de las rutas y vistas.
|
||||
"""
|
|
@ -0,0 +1,317 @@
|
|||
import os
|
||||
import json
|
||||
import zipfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from flask import current_app
|
||||
|
||||
from services.project_service import get_project, find_project_directory
|
||||
from services.document_service import get_project_documents, get_latest_version
|
||||
from utils.file_utils import ensure_dir_exists
|
||||
|
||||
def export_project(project_id, document_ids=None, include_metadata=True):
|
||||
"""
|
||||
Exportar un proyecto completo o documentos seleccionados a un archivo ZIP.
|
||||
|
||||
Args:
|
||||
project_id (int): ID del proyecto a exportar
|
||||
document_ids (list, optional): Lista de IDs de documentos a incluir.
|
||||
Si es None, se incluyen todos los documentos.
|
||||
include_metadata (bool, optional): Incluir metadatos del proyecto y documentos.
|
||||
Por defecto es True.
|
||||
|
||||
Returns:
|
||||
tuple: (success, message, zip_path)
|
||||
- success (bool): True si la exportación fue exitosa
|
||||
- message (str): Mensaje descriptivo
|
||||
- zip_path (str): Ruta al archivo ZIP generado
|
||||
"""
|
||||
# Verificar que el proyecto existe
|
||||
project = get_project(project_id)
|
||||
|
||||
if not project:
|
||||
return False, f"No se encontró el proyecto con ID {project_id}.", None
|
||||
|
||||
# Obtener directorio del proyecto
|
||||
project_dir = find_project_directory(project_id)
|
||||
|
||||
if not project_dir:
|
||||
return False, f"No se encontró el directorio del proyecto con ID {project_id}.", None
|
||||
|
||||
# Crear directorio temporal para la exportación
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
||||
try:
|
||||
# Obtener documentos del proyecto
|
||||
all_documents = get_project_documents(project_id)
|
||||
|
||||
# Filtrar documentos si se especificaron IDs
|
||||
if document_ids:
|
||||
documents = [doc for doc in all_documents if doc.get('id') in document_ids]
|
||||
else:
|
||||
documents = all_documents
|
||||
|
||||
if not documents:
|
||||
return False, "No hay documentos para exportar.", None
|
||||
|
||||
# Crear nombre para el archivo ZIP
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
project_code = project.get('codigo', f'PROJ{project_id}')
|
||||
zip_filename = f"{project_code}_export_{timestamp}.zip"
|
||||
|
||||
# Ruta completa al archivo ZIP
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
exports_dir = os.path.join(storage_path, 'exports')
|
||||
ensure_dir_exists(exports_dir)
|
||||
zip_path = os.path.join(exports_dir, zip_filename)
|
||||
|
||||
# Crear archivo ZIP
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Incluir metadatos del proyecto si se solicita
|
||||
if include_metadata:
|
||||
# Crear archivo JSON con metadatos del proyecto
|
||||
project_meta = {
|
||||
'codigo': project.get('codigo'),
|
||||
'descripcion': project.get('descripcion'),
|
||||
'cliente': project.get('cliente'),
|
||||
'destinacion': project.get('destinacion'),
|
||||
'ano_creacion': project.get('ano_creacion'),
|
||||
'fecha_creacion': project.get('fecha_creacion'),
|
||||
'creado_por': project.get('creado_por'),
|
||||
'fecha_exportacion': datetime.now(pytz.UTC).isoformat(),
|
||||
'documentos_incluidos': len(documents)
|
||||
}
|
||||
|
||||
# Guardar metadatos en archivo temporal
|
||||
meta_file = os.path.join(temp_dir, 'project_info.json')
|
||||
with open(meta_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(project_meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Añadir al ZIP
|
||||
zipf.write(meta_file, arcname='project_info.json')
|
||||
|
||||
# Añadir documentos (última versión de cada uno)
|
||||
for document in documents:
|
||||
doc_id = document.get('id')
|
||||
doc_name = document.get('document_id', '').split('_', 1)[1] if '_' in document.get('document_id', '') else f'doc_{doc_id}'
|
||||
|
||||
# Obtener última versión
|
||||
version_meta, file_path = get_latest_version(project_id, doc_id)
|
||||
|
||||
if not version_meta or not file_path or not os.path.exists(file_path):
|
||||
continue
|
||||
|
||||
# Nombre para el archivo en el ZIP
|
||||
version_num = version_meta.get('version', 1)
|
||||
original_filename = document.get('original_filename', os.path.basename(file_path))
|
||||
|
||||
# Usar nombre original o generar uno basado en metadatos
|
||||
if '.' in original_filename:
|
||||
base_name, extension = original_filename.rsplit('.', 1)
|
||||
zip_filename = f"{doc_name}_v{version_num}.{extension}"
|
||||
else:
|
||||
zip_filename = f"{doc_name}_v{version_num}"
|
||||
|
||||
# Añadir archivo al ZIP
|
||||
zipf.write(file_path, arcname=f'documents/{zip_filename}')
|
||||
|
||||
# Incluir metadatos del documento si se solicita
|
||||
if include_metadata:
|
||||
# Crear metadatos simplificados
|
||||
doc_meta = {
|
||||
'nombre': doc_name,
|
||||
'version': version_num,
|
||||
'fecha_creacion': version_meta.get('created_at'),
|
||||
'creado_por': version_meta.get('created_by'),
|
||||
'descripcion': version_meta.get('description', ''),
|
||||
'tamano': version_meta.get('file_size', 0)
|
||||
}
|
||||
|
||||
# Guardar metadatos en archivo temporal
|
||||
doc_meta_file = os.path.join(temp_dir, f'{doc_name}_meta.json')
|
||||
with open(doc_meta_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(doc_meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Añadir al ZIP
|
||||
zipf.write(doc_meta_file, arcname=f'documents/{doc_name}_meta.json')
|
||||
|
||||
return True, f"Proyecto exportado correctamente. {len(documents)} documentos incluidos.", zip_path
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Error al exportar proyecto: {str(e)}", None
|
||||
|
||||
finally:
|
||||
# Limpiar directorio temporal
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def export_document_versions(project_id, document_id, include_all_versions=False):
|
||||
"""
|
||||
Exportar todas las versiones de un documento específico.
|
||||
|
||||
Args:
|
||||
project_id (int): ID del proyecto
|
||||
document_id (int): ID del documento
|
||||
include_all_versions (bool, optional): Incluir todas las versiones.
|
||||
Si es False, solo se incluye la última versión.
|
||||
|
||||
Returns:
|
||||
tuple: (success, message, zip_path)
|
||||
"""
|
||||
from services.document_service import get_document, find_document_directory
|
||||
|
||||
# Verificar que el proyecto existe
|
||||
project = get_project(project_id)
|
||||
|
||||
if not project:
|
||||
return False, f"No se encontró el proyecto con ID {project_id}.", None
|
||||
|
||||
# Obtener directorio del proyecto
|
||||
project_dir = find_project_directory(project_id)
|
||||
|
||||
if not project_dir:
|
||||
return False, f"No se encontró el directorio del proyecto con ID {project_id}.", None
|
||||
|
||||
# Obtener documento
|
||||
document = get_document(project_id, document_id)
|
||||
|
||||
if not document:
|
||||
return False, f"No se encontró el documento con ID {document_id}.", None
|
||||
|
||||
# Obtener directorio del documento
|
||||
document_dir = find_document_directory(project_dir, document_id)
|
||||
|
||||
if not document_dir:
|
||||
return False, f"No se encontró el directorio del documento con ID {document_id}.", None
|
||||
|
||||
# Crear nombre para el archivo ZIP
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
doc_name = document.get('document_id', '').split('_', 1)[1] if '_' in document.get('document_id', '') else f'doc_{document_id}'
|
||||
zip_filename = f"{doc_name}_export_{timestamp}.zip"
|
||||
|
||||
# Ruta completa al archivo ZIP
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
exports_dir = os.path.join(storage_path, 'exports')
|
||||
ensure_dir_exists(exports_dir)
|
||||
zip_path = os.path.join(exports_dir, zip_filename)
|
||||
|
||||
try:
|
||||
# Crear archivo ZIP
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Obtener versiones
|
||||
versions = document.get('versions', [])
|
||||
|
||||
if not versions:
|
||||
return False, "El documento no tiene versiones.", None
|
||||
|
||||
# Si solo se quiere la última versión
|
||||
if not include_all_versions:
|
||||
latest_version = max(versions, key=lambda v: v['version'])
|
||||
versions = [latest_version]
|
||||
|
||||
# Añadir cada versión al ZIP
|
||||
for version in versions:
|
||||
version_num = version.get('version', 1)
|
||||
version_filename = version.get('filename')
|
||||
|
||||
if not version_filename:
|
||||
continue
|
||||
|
||||
file_path = os.path.join(document_dir, version_filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
continue
|
||||
|
||||
# Añadir archivo al ZIP
|
||||
zipf.write(file_path, arcname=f'v{version_num}_{version_filename}')
|
||||
|
||||
# Añadir metadatos
|
||||
meta_data = {
|
||||
'document_id': document.get('document_id'),
|
||||
'original_filename': document.get('original_filename'),
|
||||
'versions_included': len(versions),
|
||||
'export_date': datetime.now(pytz.UTC).isoformat()
|
||||
}
|
||||
|
||||
# Crear archivo temporal para metadatos
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as temp:
|
||||
json.dump(meta_data, temp, ensure_ascii=False, indent=2)
|
||||
temp_path = temp.name
|
||||
|
||||
# Añadir metadatos al ZIP
|
||||
zipf.write(temp_path, arcname='document_info.json')
|
||||
|
||||
# Eliminar archivo temporal
|
||||
os.unlink(temp_path)
|
||||
|
||||
return True, f"Documento exportado correctamente. {len(versions)} versiones incluidas.", zip_path
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Error al exportar documento: {str(e)}", None
|
||||
|
||||
def create_project_report(project_id, report_type='summary'):
|
||||
"""
|
||||
Crear un informe del proyecto en formato JSON.
|
||||
|
||||
Args:
|
||||
project_id (int): ID del proyecto
|
||||
report_type (str, optional): Tipo de informe ('summary', 'detailed').
|
||||
Por defecto es 'summary'.
|
||||
|
||||
Returns:
|
||||
tuple: (success, message, report_data)
|
||||
"""
|
||||
# Verificar que el proyecto existe
|
||||
project = get_project(project_id)
|
||||
|
||||
if not project:
|
||||
return False, f"No se encontró el proyecto con ID {project_id}.", None
|
||||
|
||||
# Obtener documentos del proyecto
|
||||
documents = get_project_documents(project_id)
|
||||
|
||||
# Crear informe básico
|
||||
report = {
|
||||
'project_code': project.get('codigo'),
|
||||
'description': project.get('descripcion'),
|
||||
'client': project.get('cliente'),
|
||||
'destination': project.get('destinacion'),
|
||||
'creation_year': project.get('ano_creacion'),
|
||||
'creation_date': project.get('fecha_creacion'),
|
||||
'created_by': project.get('creado_por'),
|
||||
'status': project.get('estado'),
|
||||
'last_modified': project.get('ultima_modificacion'),
|
||||
'modified_by': project.get('modificado_por'),
|
||||
'document_count': len(documents),
|
||||
'report_generated': datetime.now(pytz.UTC).isoformat(),
|
||||
'report_type': report_type
|
||||
}
|
||||
|
||||
# Si es un informe detallado, incluir información de documentos
|
||||
if report_type == 'detailed':
|
||||
report['documents'] = []
|
||||
|
||||
for doc in documents:
|
||||
doc_info = {
|
||||
'id': doc.get('id'),
|
||||
'name': doc.get('document_id', '').split('_', 1)[1] if '_' in doc.get('document_id', '') else '',
|
||||
'original_filename': doc.get('original_filename'),
|
||||
'version_count': len(doc.get('versions', [])),
|
||||
}
|
||||
|
||||
# Incluir información de la última versión
|
||||
if doc.get('versions'):
|
||||
latest_version = max(doc.get('versions', []), key=lambda v: v['version'])
|
||||
doc_info['latest_version'] = {
|
||||
'version': latest_version.get('version'),
|
||||
'created_at': latest_version.get('created_at'),
|
||||
'created_by': latest_version.get('created_by'),
|
||||
'description': latest_version.get('description'),
|
||||
'file_size': latest_version.get('file_size'),
|
||||
'download_count': len(latest_version.get('downloads', []))
|
||||
}
|
||||
|
||||
report['documents'].append(doc_info)
|
||||
|
||||
return True, "Informe generado correctamente.", report
|
|
@ -0,0 +1,276 @@
|
|||
import os
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from flask import current_app
|
||||
|
||||
from utils.file_utils import load_json_file, save_json_file, ensure_dir_exists
|
||||
from services.project_service import get_all_projects
|
||||
from services.document_service import get_project_documents
|
||||
|
||||
def build_search_index():
|
||||
"""
|
||||
Construir un índice de búsqueda para proyectos y documentos.
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
"""
|
||||
try:
|
||||
# Obtener todos los proyectos (incluyendo inactivos)
|
||||
all_projects = get_all_projects(include_inactive=True)
|
||||
|
||||
# Inicializar índice
|
||||
index = {
|
||||
'projects': {},
|
||||
'documents': {},
|
||||
'clients': {},
|
||||
'years': {},
|
||||
'users': {},
|
||||
'last_updated': datetime.now(pytz.UTC).isoformat()
|
||||
}
|
||||
|
||||
# Indexar proyectos
|
||||
for project in all_projects:
|
||||
project_id = int(project.get('codigo', '').replace('PROJ', ''))
|
||||
|
||||
if not project_id:
|
||||
continue
|
||||
|
||||
# Datos básicos del proyecto
|
||||
project_data = {
|
||||
'id': project_id,
|
||||
'code': project.get('codigo'),
|
||||
'description': project.get('descripcion', ''),
|
||||
'client': project.get('cliente', ''),
|
||||
'destination': project.get('destinacion', ''),
|
||||
'year': project.get('ano_creacion'),
|
||||
'status': project.get('estado', 'inactivo'),
|
||||
'created_by': project.get('creado_por', ''),
|
||||
'modified_by': project.get('modificado_por', ''),
|
||||
'directory': project.get('directory', '')
|
||||
}
|
||||
|
||||
# Añadir al índice de proyectos
|
||||
index['projects'][str(project_id)] = project_data
|
||||
|
||||
# Añadir a índice de clientes
|
||||
client = project.get('cliente', 'Sin cliente')
|
||||
if client not in index['clients']:
|
||||
index['clients'][client] = []
|
||||
index['clients'][client].append(project_id)
|
||||
|
||||
# Añadir a índice de años
|
||||
year = str(project.get('ano_creacion', 'Sin año'))
|
||||
if year not in index['years']:
|
||||
index['years'][year] = []
|
||||
index['years'][year].append(project_id)
|
||||
|
||||
# Añadir a índice de usuarios
|
||||
creator = project.get('creado_por', 'desconocido')
|
||||
if creator not in index['users']:
|
||||
index['users'][creator] = {'created': [], 'modified': []}
|
||||
index['users'][creator]['created'].append(project_id)
|
||||
|
||||
modifier = project.get('modificado_por')
|
||||
if modifier and modifier not in index['users']:
|
||||
index['users'][modifier] = {'created': [], 'modified': []}
|
||||
if modifier:
|
||||
index['users'][modifier]['modified'].append(project_id)
|
||||
|
||||
# Indexar documentos del proyecto
|
||||
documents = get_project_documents(project_id)
|
||||
|
||||
for doc in documents:
|
||||
doc_id = doc.get('id')
|
||||
|
||||
if not doc_id:
|
||||
continue
|
||||
|
||||
# Extraer nombre del documento
|
||||
doc_name = doc.get('document_id', '').split('_', 1)[1] if '_' in doc.get('document_id', '') else f'doc_{doc_id}'
|
||||
|
||||
# Datos básicos del documento
|
||||
doc_data = {
|
||||
'id': doc_id,
|
||||
'name': doc_name,
|
||||
'project_id': project_id,
|
||||
'original_filename': doc.get('original_filename', ''),
|
||||
'versions': len(doc.get('versions', [])),
|
||||
'latest_version': max([v.get('version', 0) for v in doc.get('versions', [])]) if doc.get('versions') else 0,
|
||||
'directory': doc.get('directory', '')
|
||||
}
|
||||
|
||||
# Añadir al índice de documentos
|
||||
index['documents'][f"{project_id}_{doc_id}"] = doc_data
|
||||
|
||||
# Guardar índice
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
index_file = os.path.join(storage_path, 'indices.json')
|
||||
save_json_file(index_file, index)
|
||||
|
||||
return True, "Índice construido correctamente."
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error al construir índice: {str(e)}")
|
||||
return False, f"Error al construir índice: {str(e)}"
|
||||
|
||||
def search_projects(query, filters=None):
|
||||
"""
|
||||
Buscar proyectos en el índice.
|
||||
|
||||
Args:
|
||||
query (str): Término de búsqueda
|
||||
filters (dict, optional): Filtros adicionales
|
||||
- client: Cliente específico
|
||||
- year: Año de creación
|
||||
- status: Estado del proyecto
|
||||
- created_by: Usuario creador
|
||||
|
||||
Returns:
|
||||
list: Lista de proyectos que coinciden con la búsqueda
|
||||
"""
|
||||
# Cargar índice
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
index_file = os.path.join(storage_path, 'indices.json')
|
||||
index = load_json_file(index_file, {})
|
||||
|
||||
if not index or 'projects' not in index:
|
||||
return []
|
||||
|
||||
# Preparar resultados
|
||||
results = []
|
||||
|
||||
# Convertir query a minúsculas para búsqueda insensible a mayúsculas
|
||||
query = query.lower()
|
||||
|
||||
# Buscar en proyectos
|
||||
for project_id, project_data in index.get('projects', {}).items():
|
||||
# Aplicar filtros si existen
|
||||
if filters:
|
||||
# Filtrar por cliente
|
||||
if 'client' in filters and filters['client'] and project_data.get('client') != filters['client']:
|
||||
continue
|
||||
|
||||
# Filtrar por año
|
||||
if 'year' in filters and filters['year'] and str(project_data.get('year')) != str(filters['year']):
|
||||
continue
|
||||
|
||||
# Filtrar por estado
|
||||
if 'status' in filters and filters['status'] and project_data.get('status') != filters['status']:
|
||||
continue
|
||||
|
||||
# Filtrar por creador
|
||||
if 'created_by' in filters and filters['created_by'] and project_data.get('created_by') != filters['created_by']:
|
||||
continue
|
||||
|
||||
# Si no hay query, incluir todos los proyectos que pasen los filtros
|
||||
if not query:
|
||||
results.append(project_data)
|
||||
continue
|
||||
|
||||
# Buscar coincidencias en campos relevantes
|
||||
if (query in project_data.get('code', '').lower() or
|
||||
query in project_data.get('description', '').lower() or
|
||||
query in project_data.get('client', '').lower() or
|
||||
query in project_data.get('destination', '').lower()):
|
||||
results.append(project_data)
|
||||
|
||||
return results
|
||||
|
||||
def search_documents(query, project_id=None):
|
||||
"""
|
||||
Buscar documentos en el índice.
|
||||
|
||||
Args:
|
||||
query (str): Término de búsqueda
|
||||
project_id (int, optional): ID del proyecto para limitar la búsqueda
|
||||
|
||||
Returns:
|
||||
list: Lista de documentos que coinciden con la búsqueda
|
||||
"""
|
||||
# Cargar índice
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
index_file = os.path.join(storage_path, 'indices.json')
|
||||
index = load_json_file(index_file, {})
|
||||
|
||||
if not index or 'documents' not in index:
|
||||
return []
|
||||
|
||||
# Preparar resultados
|
||||
results = []
|
||||
|
||||
# Convertir query a minúsculas para búsqueda insensible a mayúsculas
|
||||
query = query.lower()
|
||||
|
||||
# Buscar en documentos
|
||||
for doc_key, doc_data in index.get('documents', {}).items():
|
||||
# Si se especifica proyecto, filtrar por él
|
||||
if project_id is not None and doc_data.get('project_id') != int(project_id):
|
||||
continue
|
||||
|
||||
# Si no hay query, incluir todos los documentos del proyecto
|
||||
if not query:
|
||||
results.append(doc_data)
|
||||
continue
|
||||
|
||||
# Buscar coincidencias en campos relevantes
|
||||
if (query in doc_data.get('name', '').lower() or
|
||||
query in doc_data.get('original_filename', '').lower()):
|
||||
results.append(doc_data)
|
||||
|
||||
return results
|
||||
|
||||
def get_index_stats():
|
||||
"""
|
||||
Obtener estadísticas del índice.
|
||||
|
||||
Returns:
|
||||
dict: Estadísticas del índice
|
||||
"""
|
||||
# Cargar índice
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
index_file = os.path.join(storage_path, 'indices.json')
|
||||
index = load_json_file(index_file, {})
|
||||
|
||||
if not index:
|
||||
return {
|
||||
'projects': 0,
|
||||
'documents': 0,
|
||||
'clients': 0,
|
||||
'years': 0,
|
||||
'users': 0,
|
||||
'last_updated': None
|
||||
}
|
||||
|
||||
# Calcular estadísticas
|
||||
stats = {
|
||||
'projects': len(index.get('projects', {})),
|
||||
'documents': len(index.get('documents', {})),
|
||||
'clients': len(index.get('clients', {})),
|
||||
'years': len(index.get('years', {})),
|
||||
'users': len(index.get('users', {})),
|
||||
'last_updated': index.get('last_updated')
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def schedule_index_update():
|
||||
"""
|
||||
Programar actualización periódica del índice.
|
||||
"""
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Programar actualización diaria a las 3:00 AM
|
||||
scheduler.add_job(
|
||||
build_search_index,
|
||||
'cron',
|
||||
hour=3,
|
||||
minute=0,
|
||||
id='index_update'
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
current_app.logger.info("Actualización de índice programada.")
|
|
@ -1,212 +1,176 @@
|
|||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from flask import current_app
|
||||
from utils.file_utils import load_json_file, save_json_file
|
||||
|
||||
def get_all_schemas():
|
||||
"""
|
||||
Obtener todos los esquemas disponibles.
|
||||
|
||||
Returns:
|
||||
dict: Diccionario de esquemas
|
||||
"""
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
|
||||
|
||||
return load_json_file(schema_file, {})
|
||||
|
||||
def get_schema(schema_code):
|
||||
def get_schemas_file_path():
|
||||
"""Obtener ruta al archivo de esquemas."""
|
||||
storage_path = current_app.config["STORAGE_PATH"]
|
||||
return os.path.join(storage_path, "schemas", "schema.json")
|
||||
|
||||
|
||||
def get_all_schemas():
|
||||
"""Obtener todos los esquemas disponibles."""
|
||||
return load_json_file(get_schemas_file_path(), {})
|
||||
|
||||
|
||||
def get_schema_by_id(schema_id):
|
||||
"""
|
||||
Obtener un esquema específico.
|
||||
|
||||
Obtener un esquema por su ID.
|
||||
|
||||
Args:
|
||||
schema_code (str): Código del esquema
|
||||
|
||||
schema_id (str): ID del esquema a buscar
|
||||
|
||||
Returns:
|
||||
dict: Datos del esquema o None si no existe
|
||||
"""
|
||||
schemas = get_all_schemas()
|
||||
|
||||
return schemas.get(schema_code)
|
||||
return schemas.get(schema_id)
|
||||
|
||||
def create_schema(schema_data, creator_username):
|
||||
|
||||
def create_schema(schema_data, user_id):
|
||||
"""
|
||||
Crear un nuevo esquema.
|
||||
|
||||
|
||||
Args:
|
||||
schema_data (dict): Datos del esquema
|
||||
creator_username (str): Usuario que crea el esquema
|
||||
|
||||
Returns:
|
||||
tuple: (success, message, schema_code)
|
||||
"""
|
||||
# Validar datos obligatorios
|
||||
if 'descripcion' not in schema_data or not schema_data['descripcion']:
|
||||
return False, "La descripción del esquema es obligatoria.", None
|
||||
|
||||
if 'documentos' not in schema_data or not schema_data['documentos']:
|
||||
return False, "Se requiere al menos un tipo de documento en el esquema.", None
|
||||
|
||||
# Generar código de esquema
|
||||
schemas = get_all_schemas()
|
||||
|
||||
# Encontrar el último código numérico
|
||||
last_code = 0
|
||||
for code in schemas.keys():
|
||||
if code.startswith('ESQ'):
|
||||
try:
|
||||
num = int(code[3:])
|
||||
if num > last_code:
|
||||
last_code = num
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Crear nuevo código
|
||||
schema_code = f"ESQ{last_code + 1:03d}"
|
||||
|
||||
# Crear esquema
|
||||
schemas[schema_code] = {
|
||||
'codigo': schema_code,
|
||||
'descripcion': schema_data['descripcion'],
|
||||
'fecha_creacion': datetime.now(pytz.UTC).isoformat(),
|
||||
'creado_por': creator_username,
|
||||
'documentos': schema_data['documentos']
|
||||
}
|
||||
|
||||
# Guardar esquemas
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
|
||||
save_json_file(schema_file, schemas)
|
||||
|
||||
return True, "Esquema creado correctamente.", schema_code
|
||||
user_id (str): ID del usuario que crea el esquema
|
||||
|
||||
def update_schema(schema_code, schema_data):
|
||||
Returns:
|
||||
tuple: (éxito, mensaje)
|
||||
"""
|
||||
schemas = get_all_schemas()
|
||||
|
||||
# Verificar si ya existe un esquema con ese código
|
||||
schema_id = schema_data["codigo"]
|
||||
if schema_id in schemas:
|
||||
return False, f"Ya existe un esquema con el código {schema_id}."
|
||||
|
||||
# Añadir metadatos
|
||||
schema_data["fecha_creacion"] = datetime.now(pytz.UTC).isoformat()
|
||||
schema_data["creado_por"] = user_id
|
||||
|
||||
# Guardar esquema
|
||||
schemas[schema_id] = schema_data
|
||||
save_json_file(get_schemas_file_path(), schemas)
|
||||
|
||||
return True, f"Esquema '{schema_data['descripcion']}' creado correctamente."
|
||||
|
||||
|
||||
def update_schema(schema_id, schema_data):
|
||||
"""
|
||||
Actualizar un esquema existente.
|
||||
|
||||
|
||||
Args:
|
||||
schema_code (str): Código del esquema
|
||||
schema_data (dict): Datos actualizados
|
||||
|
||||
schema_id (str): ID del esquema a actualizar
|
||||
schema_data (dict): Nuevos datos del esquema
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
tuple: (éxito, mensaje)
|
||||
"""
|
||||
schemas = get_all_schemas()
|
||||
|
||||
# Verificar si existe el esquema
|
||||
if schema_code not in schemas:
|
||||
return False, f"No se encontró el esquema con código {schema_code}."
|
||||
|
||||
# Actualizar campos permitidos
|
||||
if 'descripcion' in schema_data:
|
||||
schemas[schema_code]['descripcion'] = schema_data['descripcion']
|
||||
|
||||
if 'documentos' in schema_data:
|
||||
schemas[schema_code]['documentos'] = schema_data['documentos']
|
||||
|
||||
# Guardar esquemas
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
|
||||
save_json_file(schema_file, schemas)
|
||||
|
||||
return True, "Esquema actualizado correctamente."
|
||||
|
||||
def delete_schema(schema_code):
|
||||
# Verificar si existe el esquema
|
||||
if schema_id not in schemas:
|
||||
return False, f"No existe un esquema con el código {schema_id}."
|
||||
|
||||
# Preservar metadatos originales
|
||||
schema_data["fecha_creacion"] = schemas[schema_id].get("fecha_creacion")
|
||||
schema_data["creado_por"] = schemas[schema_id].get("creado_por")
|
||||
schema_data["ultima_modificacion"] = datetime.now(pytz.UTC).isoformat()
|
||||
|
||||
# Actualizar esquema
|
||||
schemas[schema_id] = schema_data
|
||||
save_json_file(get_schemas_file_path(), schemas)
|
||||
|
||||
return True, f"Esquema '{schema_data['descripcion']}' actualizado correctamente."
|
||||
|
||||
|
||||
def delete_schema(schema_id):
|
||||
"""
|
||||
Eliminar un esquema.
|
||||
|
||||
|
||||
Args:
|
||||
schema_code (str): Código del esquema
|
||||
|
||||
schema_id (str): ID del esquema a eliminar
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
tuple: (éxito, mensaje)
|
||||
"""
|
||||
schemas = get_all_schemas()
|
||||
|
||||
|
||||
# Verificar si existe el esquema
|
||||
if schema_code not in schemas:
|
||||
return False, f"No se encontró el esquema con código {schema_code}."
|
||||
|
||||
if schema_id not in schemas:
|
||||
return False, f"No existe un esquema con el código {schema_id}."
|
||||
|
||||
# Verificar si el esquema está en uso
|
||||
# [Implementar verificación si el esquema está asociado a proyectos]
|
||||
|
||||
# Eliminar esquema
|
||||
del schemas[schema_code]
|
||||
|
||||
# Guardar esquemas
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
|
||||
save_json_file(schema_file, schemas)
|
||||
|
||||
return True, "Esquema eliminado correctamente."
|
||||
schema_desc = schemas[schema_id].get("descripcion", schema_id)
|
||||
del schemas[schema_id]
|
||||
save_json_file(get_schemas_file_path(), schemas)
|
||||
|
||||
def get_schema_document_types(schema_code):
|
||||
"""
|
||||
Obtener los tipos de documento definidos en un esquema.
|
||||
|
||||
Args:
|
||||
schema_code (str): Código del esquema
|
||||
|
||||
Returns:
|
||||
list: Lista de tipos de documento
|
||||
"""
|
||||
schema = get_schema(schema_code)
|
||||
|
||||
if not schema:
|
||||
return []
|
||||
|
||||
return schema.get('documentos', [])
|
||||
return True, f"Esquema '{schema_desc}' eliminado correctamente."
|
||||
|
||||
def validate_document_for_schema(schema_code, document_type):
|
||||
"""
|
||||
Validar si un tipo de documento está permitido en un esquema.
|
||||
|
||||
Args:
|
||||
schema_code (str): Código del esquema
|
||||
document_type (str): Tipo de documento
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, required_level) - Validez y nivel requerido
|
||||
"""
|
||||
schema = get_schema(schema_code)
|
||||
|
||||
if not schema:
|
||||
return False, None
|
||||
|
||||
for doc in schema.get('documentos', []):
|
||||
if doc.get('tipo') == document_type:
|
||||
return True, doc.get('nivel_editar', 0)
|
||||
|
||||
return False, None
|
||||
|
||||
def initialize_default_schemas():
|
||||
"""
|
||||
Inicializar esquemas predeterminados si no existen.
|
||||
"""
|
||||
"""Inicializar esquemas predeterminados si no existen."""
|
||||
schemas = get_all_schemas()
|
||||
|
||||
if not schemas:
|
||||
# Crear esquema estándar
|
||||
create_schema({
|
||||
'descripcion': 'Proyecto estándar',
|
||||
'documentos': [
|
||||
|
||||
# Si ya hay esquemas, no hacer nada
|
||||
if schemas:
|
||||
return
|
||||
|
||||
# Esquema predeterminado para proyecto estándar
|
||||
default_schema = {
|
||||
"ESQ001": {
|
||||
"codigo": "ESQ001",
|
||||
"descripcion": "Proyecto estándar",
|
||||
"fecha_creacion": datetime.now(pytz.UTC).isoformat(),
|
||||
"creado_por": "admin",
|
||||
"documentos": [
|
||||
{
|
||||
'tipo': 'pdf',
|
||||
'nombre': 'Manual de Usuario',
|
||||
'nivel_ver': 0,
|
||||
'nivel_editar': 5000
|
||||
"tipo": "pdf",
|
||||
"nombre": "Manual de Usuario",
|
||||
"nivel_ver": 0,
|
||||
"nivel_editar": 5000,
|
||||
},
|
||||
{
|
||||
'tipo': 'txt',
|
||||
'nombre': 'Notas del Proyecto',
|
||||
'nivel_ver': 0,
|
||||
'nivel_editar': 1000
|
||||
"tipo": "dwg",
|
||||
"nombre": "Planos Técnicos",
|
||||
"nivel_ver": 0,
|
||||
"nivel_editar": 5000,
|
||||
},
|
||||
{
|
||||
'tipo': 'zip',
|
||||
'nombre': 'Archivos Fuente',
|
||||
'nivel_ver': 1000,
|
||||
'nivel_editar': 5000
|
||||
}
|
||||
]
|
||||
}, 'admin')
|
||||
|
||||
current_app.logger.info('Esquema predeterminado creado.')
|
||||
"tipo": "zip",
|
||||
"nombre": "Archivos Fuente",
|
||||
"nivel_ver": 1000,
|
||||
"nivel_editar": 5000,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
save_json_file(get_schemas_file_path(), default_schema)
|
||||
current_app.logger.info("Esquemas predeterminados inicializados.")
|
||||
|
||||
|
||||
def get_schema_document_types(schema_id):
|
||||
"""
|
||||
Obtener los tipos de documentos definidos en un esquema.
|
||||
|
||||
Args:
|
||||
schema_id (str): ID del esquema
|
||||
|
||||
Returns:
|
||||
list: Lista de tipos de documentos en el esquema, o lista vacía si no existe
|
||||
"""
|
||||
schema = get_schema_by_id(schema_id)
|
||||
|
||||
if not schema or "documentos" not in schema:
|
||||
return []
|
||||
|
||||
return schema["documentos"]
|
||||
|
|
|
@ -1,116 +1,191 @@
|
|||
import os
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
from flask_login import UserMixin
|
||||
from app import bcrypt
|
||||
from utils.file_utils import load_json_file, save_json_file
|
||||
from services.auth_service import (
|
||||
get_user_by_username, get_user_by_id, get_all_users,
|
||||
create_user, update_user, delete_user
|
||||
get_user_by_username,
|
||||
get_user_by_id,
|
||||
get_all_users,
|
||||
create_user,
|
||||
update_user,
|
||||
delete_user,
|
||||
)
|
||||
|
||||
|
||||
class User(UserMixin):
|
||||
"""Clase de usuario para Flask-Login."""
|
||||
|
||||
def __init__(self, user_id, user_data):
|
||||
self.id = user_id
|
||||
self.username = user_data.get("username", "")
|
||||
self.nombre = user_data.get("nombre", "")
|
||||
self.email = user_data.get("email", "")
|
||||
self.nivel = user_data.get("nivel", 0)
|
||||
self.idioma = user_data.get("idioma", "es")
|
||||
self.fecha_caducidad = user_data.get("fecha_caducidad")
|
||||
self.empresa = user_data.get("empresa", "")
|
||||
self.estado = user_data.get("estado", "activo")
|
||||
self.ultimo_acceso = user_data.get("ultimo_acceso")
|
||||
|
||||
def has_permission(self, required_level):
|
||||
"""Verificar si el usuario tiene el nivel de permiso requerido."""
|
||||
return self.nivel >= required_level and self.estado == "activo"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convertir el usuario a un diccionario para almacenamiento."""
|
||||
return {
|
||||
"username": self.username,
|
||||
"nombre": self.nombre,
|
||||
"email": self.email,
|
||||
"nivel": self.nivel,
|
||||
"idioma": self.idioma,
|
||||
"fecha_caducidad": self.fecha_caducidad,
|
||||
"empresa": self.empresa,
|
||||
"estado": self.estado,
|
||||
"ultimo_acceso": self.ultimo_acceso,
|
||||
}
|
||||
|
||||
|
||||
def get_users_file_path():
|
||||
"""Obtener la ruta al archivo de usuarios."""
|
||||
storage_path = current_app.config["STORAGE_PATH"]
|
||||
return os.path.join(storage_path, "users", "users.json")
|
||||
|
||||
|
||||
def get_all_users():
|
||||
"""Obtener todos los usuarios del sistema."""
|
||||
users_data = load_json_file(get_users_file_path(), {})
|
||||
return users_data
|
||||
|
||||
|
||||
def get_user_by_id(user_id):
|
||||
"""Obtener un usuario por su ID."""
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
users_data = load_json_file(get_users_file_path(), {})
|
||||
|
||||
if user_id in users_data:
|
||||
return User(user_id, users_data[user_id])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Este archivo importa funcionalidades de auth_service.py para mantener
|
||||
# consistencia en la estructura del proyecto, y agrega funcionalidades específicas
|
||||
# de gestión de usuarios que no están relacionadas con la autenticación.
|
||||
|
||||
|
||||
def filter_users(filter_params):
|
||||
"""
|
||||
Filtrar usuarios según los parámetros proporcionados.
|
||||
|
||||
|
||||
Args:
|
||||
filter_params (dict): Diccionario con parámetros de filtrado
|
||||
- empresa: Nombre de empresa
|
||||
- estado: Estado del usuario ('activo', 'inactivo')
|
||||
- nivel_min: Nivel mínimo de permiso
|
||||
- nivel_max: Nivel máximo de permiso
|
||||
|
||||
|
||||
Returns:
|
||||
list: Lista de objetos User que cumplen los criterios
|
||||
"""
|
||||
users = get_all_users()
|
||||
filtered_users = []
|
||||
|
||||
|
||||
for user in users:
|
||||
# Filtrar por empresa
|
||||
if 'empresa' in filter_params and filter_params['empresa']:
|
||||
if user.empresa != filter_params['empresa']:
|
||||
if "empresa" in filter_params and filter_params["empresa"]:
|
||||
if user.empresa != filter_params["empresa"]:
|
||||
continue
|
||||
|
||||
|
||||
# Filtrar por estado
|
||||
if 'estado' in filter_params and filter_params['estado']:
|
||||
if user.estado != filter_params['estado']:
|
||||
if "estado" in filter_params and filter_params["estado"]:
|
||||
if user.estado != filter_params["estado"]:
|
||||
continue
|
||||
|
||||
|
||||
# Filtrar por nivel mínimo
|
||||
if 'nivel_min' in filter_params and filter_params['nivel_min'] is not None:
|
||||
if user.nivel < int(filter_params['nivel_min']):
|
||||
if "nivel_min" in filter_params and filter_params["nivel_min"] is not None:
|
||||
if user.nivel < int(filter_params["nivel_min"]):
|
||||
continue
|
||||
|
||||
|
||||
# Filtrar por nivel máximo
|
||||
if 'nivel_max' in filter_params and filter_params['nivel_max'] is not None:
|
||||
if user.nivel > int(filter_params['nivel_max']):
|
||||
if "nivel_max" in filter_params and filter_params["nivel_max"] is not None:
|
||||
if user.nivel > int(filter_params["nivel_max"]):
|
||||
continue
|
||||
|
||||
|
||||
# Si pasó todos los filtros, agregar a la lista
|
||||
filtered_users.append(user)
|
||||
|
||||
|
||||
return filtered_users
|
||||
|
||||
|
||||
def get_user_stats():
|
||||
"""
|
||||
Obtener estadísticas sobre los usuarios.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Diccionario con estadísticas
|
||||
"""
|
||||
users = get_all_users()
|
||||
|
||||
|
||||
# Inicializar estadísticas
|
||||
stats = {
|
||||
'total': len(users),
|
||||
'activos': 0,
|
||||
'inactivos': 0,
|
||||
'expirados': 0,
|
||||
'por_empresa': {},
|
||||
'por_nivel': {
|
||||
'admin': 0, # Nivel 9000+
|
||||
'gestor': 0, # Nivel 5000-8999
|
||||
'editor': 0, # Nivel 1000-4999
|
||||
'lector': 0 # Nivel 0-999
|
||||
}
|
||||
"total": len(users),
|
||||
"activos": 0,
|
||||
"inactivos": 0,
|
||||
"expirados": 0,
|
||||
"por_empresa": {},
|
||||
"por_nivel": {
|
||||
"admin": 0, # Nivel 9000+
|
||||
"gestor": 0, # Nivel 5000-8999
|
||||
"editor": 0, # Nivel 1000-4999
|
||||
"lector": 0, # Nivel 0-999
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Calcular estadísticas
|
||||
for user in users:
|
||||
# Estado
|
||||
if user.estado == 'activo':
|
||||
stats['activos'] += 1
|
||||
if user.estado == "activo":
|
||||
stats["activos"] += 1
|
||||
else:
|
||||
stats['inactivos'] += 1
|
||||
|
||||
stats["inactivos"] += 1
|
||||
|
||||
# Expirados
|
||||
if user.is_expired():
|
||||
stats['expirados'] += 1
|
||||
|
||||
stats["expirados"] += 1
|
||||
|
||||
# Por empresa
|
||||
empresa = user.empresa or 'Sin empresa'
|
||||
stats['por_empresa'][empresa] = stats['por_empresa'].get(empresa, 0) + 1
|
||||
|
||||
empresa = user.empresa or "Sin empresa"
|
||||
stats["por_empresa"][empresa] = stats["por_empresa"].get(empresa, 0) + 1
|
||||
|
||||
# Por nivel
|
||||
if user.nivel >= 9000:
|
||||
stats['por_nivel']['admin'] += 1
|
||||
stats["por_nivel"]["admin"] += 1
|
||||
elif user.nivel >= 5000:
|
||||
stats['por_nivel']['gestor'] += 1
|
||||
stats["por_nivel"]["gestor"] += 1
|
||||
elif user.nivel >= 1000:
|
||||
stats['por_nivel']['editor'] += 1
|
||||
stats["por_nivel"]["editor"] += 1
|
||||
else:
|
||||
stats['por_nivel']['lector'] += 1
|
||||
|
||||
stats["por_nivel"]["lector"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def check_username_availability(username):
|
||||
"""
|
||||
Verificar si un nombre de usuario está disponible.
|
||||
|
||||
|
||||
Args:
|
||||
username (str): Nombre de usuario a verificar
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True si está disponible, False si ya existe
|
||||
"""
|
||||
user = get_user_by_username(username)
|
||||
return user is None
|
||||
return user is None
|
||||
|
|
|
@ -21,6 +21,14 @@ body {
|
|||
margin-top: auto;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Encabezados */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
|
@ -35,7 +43,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
@ -55,12 +63,20 @@ h1, h2, h3, h4, h5, h6 {
|
|||
background-color: rgba(0, 123, 255, 0.05);
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Botones */
|
||||
.btn {
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Navegación */
|
||||
.navbar {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
|
@ -93,6 +109,11 @@ h1, h2, h3, h4, h5, h6 {
|
|||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.required-field::after {
|
||||
content: " *";
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Alertas */
|
||||
.alert {
|
||||
border-radius: 0.25rem;
|
||||
|
@ -174,4 +195,52 @@ h1, h2, h3, h4, h5, h6 {
|
|||
|
||||
.text-primary-dark {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
/* Login form styling */
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
/* Project card styling */
|
||||
.project-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Document version styling */
|
||||
.version-timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.version-timeline:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.version-item:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -25px;
|
||||
top: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #0d6efd;
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.4 MiB |
|
@ -0,0 +1,41 @@
|
|||
// Main JavaScript for ARCH
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Enable tooltips
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
// Enable popovers
|
||||
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
|
||||
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
|
||||
return new bootstrap.Popover(popoverTriggerEl)
|
||||
});
|
||||
|
||||
// Auto-hide alerts after 5 seconds
|
||||
setTimeout(function() {
|
||||
$('.alert').fadeOut('slow');
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Function to confirm deletions
|
||||
function confirmDelete(message, formId) {
|
||||
if (confirm(message || '¿Está seguro que desea eliminar este elemento?')) {
|
||||
document.getElementById(formId).submit();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to preview images when uploading
|
||||
function previewImage(input, previewId) {
|
||||
if (input.files && input.files[0]) {
|
||||
var reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
document.getElementById(previewId).src = e.target.result;
|
||||
document.getElementById(previewId).style.display = 'block';
|
||||
}
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Acerca de - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Acerca de ARCH</h1>
|
||||
<p class="lead">Sistema de gestión documental especializado para arquitectura</p>
|
||||
|
||||
<h2 class="mt-4">Nuestra Misión</h2>
|
||||
<p>
|
||||
ARCH es una plataforma diseñada para ayudar a los arquitectos y profesionales
|
||||
del sector a gestionar de manera eficiente su documentación técnica, facilitando
|
||||
la organización, clasificación y acceso a los documentos relacionados con proyectos arquitectónicos.
|
||||
</p>
|
||||
|
||||
<h2 class="mt-4">Características</h2>
|
||||
<ul>
|
||||
<li>Gestión de proyectos arquitectónicos</li>
|
||||
<li>Control de versiones de documentos</li>
|
||||
<li>Clasificación por esquemas personalizables</li>
|
||||
<li>Búsqueda avanzada de documentación</li>
|
||||
<li>Informes y estadísticas</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mt-4">Contacto</h2>
|
||||
<p>
|
||||
Para más información o soporte técnico, contacta con el administrador del sistema.
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Volver al Inicio</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,68 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Panel de Administración - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<h1 class="mb-4">Panel de Administración</h1>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-users fa-3x text-primary mb-3"></i>
|
||||
<h5 class="card-title">Gestión de Usuarios</h5>
|
||||
<p class="card-text">Administre usuarios, permisos y accesos al sistema.</p>
|
||||
<a href="{{ url_for('users.list') }}" class="btn btn-primary">Ir a Usuarios</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-file-alt fa-3x text-success mb-3"></i>
|
||||
<h5 class="card-title">Tipos de Archivo</h5>
|
||||
<p class="card-text">Configure los tipos de archivo permitidos en el sistema.</p>
|
||||
<a href="{{ url_for('admin.filetypes') }}" class="btn btn-success">Ir a Tipos de Archivo</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-server fa-3x text-warning mb-3"></i>
|
||||
<h5 class="card-title">Estado del Sistema</h5>
|
||||
<p class="card-text">Supervise el estado y realice tareas de mantenimiento.</p>
|
||||
<a href="{{ url_for('admin.system') }}" class="btn btn-warning">Ver Estado</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-project-diagram fa-3x text-info mb-3"></i>
|
||||
<h5 class="card-title">Esquemas</h5>
|
||||
<p class="card-text">Gestione los esquemas de documentos para proyectos.</p>
|
||||
<a href="{{ url_for('schemas.list') }}" class="btn btn-info">Ir a Esquemas</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-folder fa-3x text-secondary mb-3"></i>
|
||||
<h5 class="card-title">Proyectos</h5>
|
||||
<p class="card-text">Acceda a todos los proyectos del sistema.</p>
|
||||
<a href="{{ url_for('projects.list') }}" class="btn btn-secondary">Ir a Proyectos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,179 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tipos de Archivo - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Tipos de Archivo</h1>
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addFiletypeModal">
|
||||
<i class="fas fa-plus"></i> Nuevo Tipo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Extensión</th>
|
||||
<th>Descripción</th>
|
||||
<th>Tipo MIME</th>
|
||||
<th>Tamaño Máximo</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ext, filetype in filetypes.items() %}
|
||||
<tr>
|
||||
<td>{{ ext }}</td>
|
||||
<td>{{ filetype.descripcion }}</td>
|
||||
<td>{{ filetype.mime_type }}</td>
|
||||
<td>{{ (filetype.tamano_maximo / 1024 / 1024)|round(1) }} MB</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
onclick="editFiletype('{{ ext }}', '{{ filetype.descripcion }}', '{{ filetype.mime_type }}', {{ filetype.tamano_maximo }})"
|
||||
title="Editar">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="confirmDeleteFiletype('{{ ext }}', '{{ filetype.descripcion }}')"
|
||||
title="Eliminar">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No hay tipos de archivo definidos.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para añadir tipo de archivo -->
|
||||
<div class="modal fade" id="addFiletypeModal" tabindex="-1" aria-labelledby="addFiletypeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addFiletypeModalLabel">Nuevo Tipo de Archivo</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.add_filetype') }}">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="extension" class="form-label">Extensión</label>
|
||||
<input type="text" class="form-control" id="extension" name="extension" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="descripcion" class="form-label">Descripción</label>
|
||||
<input type="text" class="form-control" id="descripcion" name="descripcion" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="mime_type" class="form-label">Tipo MIME</label>
|
||||
<input type="text" class="form-control" id="mime_type" name="mime_type" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="tamano_maximo" class="form-label">Tamaño Máximo (MB)</label>
|
||||
<input type="number" class="form-control" id="tamano_maximo" name="tamano_maximo" min="1" max="1000" value="20" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para editar tipo de archivo -->
|
||||
<div class="modal fade" id="editFiletypeModal" tabindex="-1" aria-labelledby="editFiletypeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editFiletypeModalLabel">Editar Tipo de Archivo</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="editFiletypeForm" method="POST" action="">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="edit_descripcion" class="form-label">Descripción</label>
|
||||
<input type="text" class="form-control" id="edit_descripcion" name="descripcion" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit_mime_type" class="form-label">Tipo MIME</label>
|
||||
<input type="text" class="form-control" id="edit_mime_type" name="mime_type" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit_tamano_maximo" class="form-label">Tamaño Máximo (MB)</label>
|
||||
<input type="number" class="form-control" id="edit_tamano_maximo" name="tamano_maximo" min="1" max="1000" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary">Actualizar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para confirmar eliminación -->
|
||||
<div class="modal fade" id="deleteFiletypeModal" tabindex="-1" aria-labelledby="deleteFiletypeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteFiletypeModalLabel">Confirmar Eliminación</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>¿Está seguro que desea eliminar el tipo de archivo <strong id="deleteFiletypeName"></strong>?</p>
|
||||
<p class="text-danger">Esta acción no se puede deshacer y podría afectar a documentos existentes.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<form id="deleteFiletypeForm" method="POST" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function editFiletype(extension, descripcion, mimeType, tamanoMaximo) {
|
||||
document.getElementById('edit_descripcion').value = descripcion;
|
||||
document.getElementById('edit_mime_type').value = mimeType;
|
||||
document.getElementById('edit_tamano_maximo').value = Math.round(tamanoMaximo / 1024 / 1024);
|
||||
|
||||
// Change this line to match the correct route name
|
||||
document.getElementById('editFiletypeForm').action = "/admin/filetypes/" + extension + "/update";
|
||||
|
||||
var editModal = new bootstrap.Modal(document.getElementById('editFiletypeModal'));
|
||||
editModal.show();
|
||||
}
|
||||
|
||||
function confirmDeleteFiletype(extension, descripcion) {
|
||||
document.getElementById('deleteFiletypeName').textContent = descripcion + ' (.' + extension + ')';
|
||||
|
||||
// Also change this to use direct URL instead of url_for
|
||||
document.getElementById('deleteFiletypeForm').action = "/admin/filetypes/" + extension + "/delete";
|
||||
|
||||
var deleteModal = new bootstrap.Modal(document.getElementById('deleteFiletypeModal'));
|
||||
deleteModal.show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,99 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Estado del Sistema - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<h1 class="mb-4">Estado del Sistema</h1>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="card-title mb-0">Información del Sistema</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Versión Python:</dt>
|
||||
<dd class="col-sm-8">{{ stats.python_version }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Plataforma:</dt>
|
||||
<dd class="col-sm-8">{{ stats.platform }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Proyectos:</dt>
|
||||
<dd class="col-sm-8">{{ stats.projects_count }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Hora del servidor:</dt>
|
||||
<dd class="col-sm-8">{{ now.strftime('%Y-%m-%d %H:%M:%S') }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="card-title mb-0">Uso de Almacenamiento</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-0">{{ (stats.storage_size / (1024*1024))|round(2) }} MB</h4>
|
||||
<small class="text-muted">Espacio total utilizado</small>
|
||||
</div>
|
||||
|
||||
<div class="progress mb-4" style="height: 20px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ (stats.storage_size / (1024*1024*1024))|float * 100 }}%;"
|
||||
aria-valuenow="{{ (stats.storage_size / (1024*1024*1024))|float * 100 }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
{{ ((stats.storage_size / (1024*1024*1024))|float * 100)|round(1) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Tamaño de logs:</dt>
|
||||
<dd class="col-sm-8">{{ (stats.log_size / 1024)|round(2) }} KB</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning">
|
||||
<h5 class="card-title mb-0">Mantenimiento del Sistema</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Limpieza de Logs</h5>
|
||||
<p>Elimina los archivos de log rotados para liberar espacio.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.clear_logs') }}"
|
||||
onsubmit="return confirm('¿Está seguro que desea eliminar los logs rotados?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="fas fa-trash"></i> Limpiar Logs Rotados
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h5>Reinicializar Valores Predeterminados</h5>
|
||||
<p>Restablece los tipos de archivo y esquemas predeterminados.</p>
|
||||
|
||||
<a href="{{ url_for('admin.initialize') }}"
|
||||
class="btn btn-outline-warning"
|
||||
onclick="return confirm('¿Está seguro que desea reinicializar los valores predeterminados?');">
|
||||
<i class="fas fa-sync-alt"></i> Reinicializar Valores
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -3,42 +3,42 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}ARCH - Sistema de Gestión de Documentos{% endblock %}</title>
|
||||
<title>{% block title %}ARCH - Sistema de Gestión Documental{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Estilos principales -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||
<!-- Font Awesome Icons -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Estilos específicos -->
|
||||
{% block styles %}{% endblock %}
|
||||
<!-- Custom CSS -->
|
||||
<link href="{{ url_for('static', filename='css/main.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Barra de navegación -->
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('projects.list') }}">
|
||||
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="ARCH" height="30">
|
||||
ARCH
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('projects.list') }}">Proyectos</a>
|
||||
</li>
|
||||
|
||||
{% if current_user.has_permission(5000) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('schemas.list') }}">Esquemas</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
|
||||
|
@ -52,17 +52,25 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.about') }}">Acerca de</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
{{ current_user.nombre }}
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-user-circle me-1"></i> {{ current_user.nombre }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Mi perfil</a></li>
|
||||
{% if current_user.has_permission(5000) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('users.list') }}">Usuarios</a></li>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.dashboard') }}">Administración</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Mi perfil</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Cerrar sesión</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
@ -75,42 +83,63 @@
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Contenido principal -->
|
||||
<div class="container mt-4">
|
||||
<!-- Mensajes flash -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="container mt-3">
|
||||
{% for category, message in messages %}
|
||||
{% set alert_class = 'primary' %}
|
||||
{% if category == 'error' or category == 'danger' %}
|
||||
{% set alert_class = 'danger' %}
|
||||
{% elif category == 'warning' %}
|
||||
{% set alert_class = 'warning' %}
|
||||
{% elif category == 'success' %}
|
||||
{% set alert_class = 'success' %}
|
||||
{% elif category == 'info' %}
|
||||
{% set alert_class = 'info' %}
|
||||
{% endif %}
|
||||
<div class="alert alert-{{ alert_class }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main>
|
||||
<!-- Título de la página -->
|
||||
<h1 class="mb-4">{% block page_title %}{% endblock %}</h1>
|
||||
|
||||
<!-- Contenido -->
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer mt-5 py-3 bg-light">
|
||||
<footer class="bg-light py-4 mt-5">
|
||||
<div class="container">
|
||||
<div class="text-center">
|
||||
<span class="text-muted">ARCH - Sistema de Gestión de Documentos © {{ now.year }}</span>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="mb-0">© {{ now.year }} ARCH - Sistema de Gestión Documental</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<a href="{{ url_for('main.about') }}" class="text-decoration-none">Acerca de</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="{{ url_for('static', filename='js/lib/jquery.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/lib/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- jQuery (For bootstrap plugins that require it) -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
|
||||
<!-- JavaScript específico -->
|
||||
{% block scripts %}{% endblock %}
|
||||
<!-- Custom JavaScript -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,50 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Inicio - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row">
|
||||
<div class="col-md-12 text-center mb-4">
|
||||
<h1 class="display-4">Bienvenido a ARCH</h1>
|
||||
<p class="lead">Sistema de gestión documental para arquitectura</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Mis Proyectos</h5>
|
||||
<p class="card-text">Accede a tus proyectos actuales y archivados.</p>
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-primary">Ver Proyectos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Documentos Recientes</h5>
|
||||
<p class="card-text">Consulta tus documentos más recientes.</p>
|
||||
<a href="{{ url_for('documents.recent_documents') }}" class="btn btn-primary">Ver Documentos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-md-8 text-center">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2>Gestión de documentos arquitectónicos</h2>
|
||||
<p class="lead">Organiza, clasifica y accede a tus documentos de manera eficiente</p>
|
||||
<hr>
|
||||
<p>Para comenzar a utilizar el sistema, por favor inicia sesión:</p>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-lg btn-primary">Iniciar Sesión</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,106 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Esquemas - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Esquemas de Proyecto</h1>
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<a href="{{ url_for('schemas.create') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Nuevo Esquema
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if schemas %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Descripción</th>
|
||||
<th>Tipos de documentos</th>
|
||||
<th>Fecha de creación</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for schema_id, schema in schemas.items() %}
|
||||
<tr>
|
||||
<td>{{ schema.codigo }}</td>
|
||||
<td>{{ schema.descripcion }}</td>
|
||||
<td>{{ schema.documentos|length }}</td>
|
||||
<td>{{ schema.fecha_creacion|default('-') }}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('schemas.view', schema_id=schema_id) }}"
|
||||
class="btn btn-outline-primary" title="Ver detalles">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<a href="{{ url_for('schemas.edit', schema_id=schema_id) }}"
|
||||
class="btn btn-outline-secondary" title="Editar">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="confirmDeleteSchema('{{ schema_id }}', '{{ schema.descripcion }}')"
|
||||
title="Eliminar">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0">
|
||||
No hay esquemas definidos. {% if current_user.has_permission(9000) %}
|
||||
<a href="{{ url_for('schemas.create') }}">Crear el primer esquema</a>.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<!-- Modal de confirmación para eliminar esquema -->
|
||||
<div class="modal fade" id="deleteSchemaModal" tabindex="-1" aria-labelledby="deleteSchemaModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteSchemaModalLabel">Confirmar Eliminación</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>¿Está seguro que desea eliminar el esquema <strong id="schemaDescription"></strong>?</p>
|
||||
<p class="text-danger">Esta acción no se puede deshacer y podría afectar a proyectos existentes.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<form id="deleteSchemaForm" method="POST" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDeleteSchema(schemaId, schemaDesc) {
|
||||
document.getElementById('schemaDescription').textContent = schemaDesc;
|
||||
document.getElementById('deleteSchemaForm').action = "{{ url_for('schemas.delete', schema_id='') }}" + schemaId;
|
||||
|
||||
var deleteModal = new bootstrap.Modal(document.getElementById('deleteSchemaModal'));
|
||||
deleteModal.show();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,131 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ schema.codigo }} - Esquemas - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('schemas.list') }}">Esquemas</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ schema.codigo }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>{{ schema.descripcion }}</h1>
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<div>
|
||||
<a href="{{ url_for('schemas.edit', schema_id=schema.codigo) }}" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> Editar
|
||||
</a>
|
||||
<button class="btn btn-danger" onclick="confirmDeleteSchema('{{ schema.codigo }}', '{{ schema.descripcion }}')">
|
||||
<i class="fas fa-trash"></i> Eliminar
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Información del Esquema</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Código:</dt>
|
||||
<dd class="col-sm-8">{{ schema.codigo }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Descripción:</dt>
|
||||
<dd class="col-sm-8">{{ schema.descripcion }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Fecha de creación:</dt>
|
||||
<dd class="col-sm-8">{{ schema.fecha_creacion|default('-') }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Creado por:</dt>
|
||||
<dd class="col-sm-8">{{ schema.creado_por|default('-') }}</dd>
|
||||
|
||||
{% if schema.ultima_modificacion %}
|
||||
<dt class="col-sm-4">Última modificación:</dt>
|
||||
<dd class="col-sm-8">{{ schema.ultima_modificacion }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Documentos del Esquema</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tipo</th>
|
||||
<th>Nombre</th>
|
||||
<th>Nivel Ver</th>
|
||||
<th>Nivel Editar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if schema.documentos %}
|
||||
{% for doc in schema.documentos %}
|
||||
<tr>
|
||||
<td>{{ doc.tipo }}</td>
|
||||
<td>{{ doc.nombre }}</td>
|
||||
<td>{{ doc.nivel_ver }}</td>
|
||||
<td>{{ doc.nivel_editar }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">No hay documentos definidos en este esquema.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<!-- Modal de confirmación para eliminar esquema -->
|
||||
<div class="modal fade" id="deleteSchemaModal" tabindex="-1" aria-labelledby="deleteSchemaModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteSchemaModalLabel">Confirmar Eliminación</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>¿Está seguro que desea eliminar el esquema <strong id="schemaDescription"></strong>?</p>
|
||||
<p class="text-danger">Esta acción no se puede deshacer y podría afectar a proyectos existentes.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<form id="deleteSchemaForm" method="POST" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDeleteSchema(schemaId, schemaDesc) {
|
||||
document.getElementById('schemaDescription').textContent = schemaDesc;
|
||||
document.getElementById('deleteSchemaForm').action = "{{ url_for('schemas.delete', schema_id='') }}" + schemaId;
|
||||
|
||||
var deleteModal = new bootstrap.Modal(document.getElementById('deleteSchemaModal'));
|
||||
deleteModal.show();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,147 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Editar Usuario - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('users.list') }}">Usuarios</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Editar: {{ username }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Editar Usuario: {{ username }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.edit', username=username) }}">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nombre" class="form-label">Nombre completo</label>
|
||||
{{ form.nombre(class="form-control", id="nombre") }}
|
||||
{% if form.nombre.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.nombre.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
{{ form.email(class="form-control", id="email", type="email") }}
|
||||
{% if form.email.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.email.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Contraseña (Dejar en blanco para mantener)</label>
|
||||
{{ form.password(class="form-control", id="password", type="password") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.password.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password_confirm" class="form-label">Confirmar contraseña</label>
|
||||
{{ form.password_confirm(class="form-control", id="password_confirm", type="password") }}
|
||||
{% if form.password_confirm.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.password_confirm.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nivel" class="form-label">Nivel de acceso</label>
|
||||
{{ form.nivel(class="form-control", id="nivel", type="number") }}
|
||||
{% if form.nivel.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.nivel.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text">
|
||||
0-999: Usuario básico, 1000-4999: Editor, 5000-8999: Gestor, 9000-9999: Administrador
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="idioma" class="form-label">Idioma</label>
|
||||
{{ form.idioma(class="form-select", id="idioma") }}
|
||||
{% if form.idioma.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.idioma.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="estado" class="form-label">Estado</label>
|
||||
{{ form.estado(class="form-select", id="estado") }}
|
||||
{% if form.estado.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.estado.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="empresa" class="form-label">Empresa</label>
|
||||
{{ form.empresa(class="form-control", id="empresa") }}
|
||||
{% if form.empresa.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.empresa.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="fecha_caducidad" class="form-label">Fecha de caducidad (YYYY-MM-DD)</label>
|
||||
{{ form.fecha_caducidad(class="form-control", id="fecha_caducidad") }}
|
||||
{% if form.fecha_caducidad.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.fecha_caducidad.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text">Dejar en blanco para no establecer fecha de caducidad</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('users.list') }}" class="btn btn-secondary">Cancelar</a>
|
||||
<button type="submit" class="btn btn-primary">Actualizar Usuario</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,105 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Usuarios - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Usuarios del Sistema</h1>
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<a href="{{ url_for('users.create') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Nuevo Usuario
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Usuario</th>
|
||||
<th>Nombre</th>
|
||||
<th>Email</th>
|
||||
<th>Nivel</th>
|
||||
<th>Estado</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user_id, user in users.items() %}
|
||||
<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.nombre }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.nivel }}</td>
|
||||
<td>
|
||||
{% if user.estado == 'activo' %}
|
||||
<span class="badge bg-success">Activo</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Inactivo</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('users.edit', username=user.username) }}"
|
||||
class="btn btn-outline-primary" title="Editar">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if user_id != current_user.get_id() and current_user.has_permission(9000) %}
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="confirmDelete('{{ user.username }}', '{{ user.username }}')" title="Eliminar">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">No hay usuarios registrados.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has_permission(9000) %}
|
||||
<!-- Modal de confirmación para eliminar usuario -->
|
||||
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteUserModalLabel">Confirmar Eliminación</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>¿Está seguro que desea eliminar el usuario <strong id="deleteUserName"></strong>?</p>
|
||||
<p class="text-danger">Esta acción no se puede deshacer.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<form id="deleteUserForm" method="POST" action="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">Eliminar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDelete(username, displayName) {
|
||||
document.getElementById('deleteUserName').textContent = displayName;
|
||||
document.getElementById('deleteUserForm').action = "{{ url_for('users.delete', username='') }}" + username;
|
||||
|
||||
var deleteModal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
|
||||
deleteModal.show();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,144 @@
|
|||
import pytest
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from app import create_app
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create a Flask application instance for testing."""
|
||||
# Test configuration
|
||||
test_config = {
|
||||
'TESTING': True,
|
||||
'STORAGE_PATH': 'test_storage',
|
||||
'SECRET_KEY': 'test_key',
|
||||
'WTF_CSRF_ENABLED': False
|
||||
}
|
||||
|
||||
# Create test storage directory
|
||||
if not os.path.exists('test_storage'):
|
||||
os.makedirs('test_storage')
|
||||
|
||||
# Create basic directory structure
|
||||
for dir_name in ['users', 'schemas', 'filetypes', 'projects', 'logs', 'exports']:
|
||||
if not os.path.exists(f'test_storage/{dir_name}'):
|
||||
os.makedirs(f'test_storage/{dir_name}')
|
||||
|
||||
# Create app with test configuration
|
||||
app = create_app('testing')
|
||||
|
||||
# Create context
|
||||
with app.app_context():
|
||||
# Set up test data
|
||||
initialize_test_data()
|
||||
|
||||
yield app
|
||||
|
||||
# Clean up after tests
|
||||
shutil.rmtree('test_storage', ignore_errors=True)
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create a test client for the application."""
|
||||
return app.test_client()
|
||||
|
||||
@pytest.fixture
|
||||
def auth(client):
|
||||
"""Helper for authentication tests."""
|
||||
class AuthActions:
|
||||
def login(self, username='admin', password='admin123'):
|
||||
return client.post('/auth/login', data={
|
||||
'username': username,
|
||||
'password': password
|
||||
}, follow_redirects=True)
|
||||
|
||||
def logout(self):
|
||||
return client.get('/auth/logout', follow_redirects=True)
|
||||
|
||||
return AuthActions()
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_client(client, auth):
|
||||
"""Client that's already logged in as admin."""
|
||||
auth.login()
|
||||
return client
|
||||
|
||||
def initialize_test_data():
|
||||
"""Initialize test data files."""
|
||||
# Create test users
|
||||
users_data = {
|
||||
"admin": {
|
||||
"nombre": "Administrador",
|
||||
"username": "admin",
|
||||
"email": "admin@ejemplo.com",
|
||||
"password_hash": "$2b$12$Q5Nz3QSF0FP.mKAxPmWXmurKn1oor4Cl1KbYZAKsFbGcEWWyPHou6", # admin123
|
||||
"nivel": 9999,
|
||||
"idioma": "es",
|
||||
"fecha_caducidad": None,
|
||||
"empresa": "ARCH",
|
||||
"estado": "activo"
|
||||
},
|
||||
"user1": {
|
||||
"nombre": "Usuario Normal",
|
||||
"username": "user1",
|
||||
"email": "user1@ejemplo.com",
|
||||
"password_hash": "$2b$12$Q5Nz3QSF0FP.mKAxPmWXmurKn1oor4Cl1KbYZAKsFbGcEWWyPHou6", # admin123
|
||||
"nivel": 1000,
|
||||
"idioma": "es",
|
||||
"fecha_caducidad": None,
|
||||
"empresa": "ARCH",
|
||||
"estado": "activo"
|
||||
}
|
||||
}
|
||||
|
||||
with open('test_storage/users/users.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(users_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Create test file types
|
||||
filetypes_data = {
|
||||
"pdf": {
|
||||
"extension": "pdf",
|
||||
"descripcion": "Documento PDF",
|
||||
"mime_type": "application/pdf",
|
||||
"tamano_maximo": 20971520
|
||||
},
|
||||
"txt": {
|
||||
"extension": "txt",
|
||||
"descripcion": "Documento de texto",
|
||||
"mime_type": "text/plain",
|
||||
"tamano_maximo": 5242880
|
||||
}
|
||||
}
|
||||
|
||||
with open('test_storage/filetypes/filetypes.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(filetypes_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Create test schemas
|
||||
schemas_data = {
|
||||
"TEST001": {
|
||||
"codigo": "TEST001",
|
||||
"descripcion": "Esquema de prueba",
|
||||
"fecha_creacion": "2023-10-01T10:00:00Z",
|
||||
"creado_por": "admin",
|
||||
"documentos": [
|
||||
{
|
||||
"tipo": "pdf",
|
||||
"nombre": "Documento de Prueba",
|
||||
"nivel_ver": 0,
|
||||
"nivel_editar": 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
with open('test_storage/schemas/schema.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(schemas_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Create indices file
|
||||
indices_data = {
|
||||
"max_project_id": 0,
|
||||
"max_document_id": 0
|
||||
}
|
||||
|
||||
with open('test_storage/indices.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(indices_data, f, ensure_ascii=False, indent=2)
|
|
@ -0,0 +1,101 @@
|
|||
import json
|
||||
import pytest
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
class JSONReporter:
|
||||
"""
|
||||
Custom pytest plugin to generate JSON test reports.
|
||||
Based on the specification in section 9.4 of descripcion.md.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.start_time = time.time()
|
||||
self.results = {
|
||||
'summary': {
|
||||
'total': 0,
|
||||
'passed': 0,
|
||||
'failed': 0,
|
||||
'skipped': 0,
|
||||
'error': 0,
|
||||
'duration': 0,
|
||||
'timestamp': datetime.datetime.now().isoformat()
|
||||
},
|
||||
'tests': []
|
||||
}
|
||||
|
||||
def pytest_runtest_logreport(self, report):
|
||||
"""Handle the reporting of a test run."""
|
||||
if report.when == 'call' or (report.when == 'setup' and report.skipped):
|
||||
self.results['summary']['total'] += 1
|
||||
|
||||
if report.passed:
|
||||
result = 'passed'
|
||||
self.results['summary']['passed'] += 1
|
||||
elif report.failed:
|
||||
if report.when != 'call':
|
||||
result = 'error'
|
||||
self.results['summary']['error'] += 1
|
||||
else:
|
||||
result = 'failed'
|
||||
self.results['summary']['failed'] += 1
|
||||
elif report.skipped:
|
||||
result = 'skipped'
|
||||
self.results['summary']['skipped'] += 1
|
||||
|
||||
# Extract test metadata
|
||||
test_module = report.nodeid.split('::')[0]
|
||||
test_class = report.nodeid.split('::')[1] if '::' in report.nodeid else None
|
||||
test_name = report.nodeid.split('::')[-1]
|
||||
|
||||
# Extract error details if present
|
||||
error_message = None
|
||||
error_trace = None
|
||||
if hasattr(report, 'longrepr') and report.longrepr:
|
||||
if hasattr(report.longrepr, 'reprcrash') and report.longrepr.reprcrash:
|
||||
error_message = report.longrepr.reprcrash.message
|
||||
error_trace = str(report.longrepr)
|
||||
|
||||
# Add test result to list
|
||||
self.results['tests'].append({
|
||||
'id': report.nodeid,
|
||||
'module': test_module,
|
||||
'class': test_class,
|
||||
'name': test_name,
|
||||
'result': result,
|
||||
'duration': report.duration,
|
||||
'error_message': error_message,
|
||||
'error_trace': error_trace
|
||||
})
|
||||
|
||||
def pytest_sessionfinish(self, session):
|
||||
"""Generate report at end of test session."""
|
||||
self.results['summary']['duration'] = time.time() - self.start_time
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
output_dir = Path('test_reports')
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Generate timestamp for file name
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
output_path = output_dir / f'test_results_{timestamp}.json'
|
||||
|
||||
# Write results to file
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.results, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Also create a symlink/copy to latest results
|
||||
latest_path = output_dir / 'test_results_latest.json'
|
||||
if os.path.exists(latest_path):
|
||||
os.remove(latest_path)
|
||||
with open(latest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.results, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nJSON test report generated: {output_path}")
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_configure(config):
|
||||
"""Register the JSON reporter plugin."""
|
||||
config.pluginmanager.register(JSONReporter(config), 'json_reporter')
|
|
@ -0,0 +1,99 @@
|
|||
import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
class TestAdminFunctions:
|
||||
"""Test administrative functions."""
|
||||
|
||||
def test_admin_dashboard(self, logged_in_client):
|
||||
"""Test access to admin dashboard."""
|
||||
response = logged_in_client.get('/admin/')
|
||||
assert response.status_code == 200
|
||||
assert b'Panel de Administraci' in response.data # "Panel de Administración"
|
||||
|
||||
def test_filetypes_management(self, logged_in_client):
|
||||
"""Test file types management page."""
|
||||
response = logged_in_client.get('/admin/filetypes')
|
||||
assert response.status_code == 200
|
||||
assert b'Tipos de Archivo' in response.data
|
||||
|
||||
def test_system_status(self, logged_in_client):
|
||||
"""Test system status page."""
|
||||
response = logged_in_client.get('/admin/system')
|
||||
assert response.status_code == 200
|
||||
assert b'Estado del Sistema' in response.data
|
||||
|
||||
def test_add_filetype(self, logged_in_client, app):
|
||||
"""Test adding a new file type."""
|
||||
response = logged_in_client.post('/admin/filetypes/add', data={
|
||||
'extension': 'docx',
|
||||
'descripcion': 'Documento Word',
|
||||
'mime_type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'tamano_maximo': 15728640 # 15MB in bytes
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify file type was added
|
||||
with app.app_context():
|
||||
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
|
||||
with open(filetypes_path, 'r') as f:
|
||||
filetypes = json.load(f)
|
||||
assert 'docx' in filetypes
|
||||
assert filetypes['docx']['descripcion'] == 'Documento Word'
|
||||
|
||||
def test_delete_filetype(self, logged_in_client, app):
|
||||
"""Test deleting a file type."""
|
||||
# First add a file type to delete
|
||||
logged_in_client.post('/admin/filetypes/add', data={
|
||||
'extension': 'xlsx',
|
||||
'descripcion': 'Hoja de cálculo Excel',
|
||||
'mime_type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'tamano_maximo': 15728640
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Verify it was added
|
||||
with app.app_context():
|
||||
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
|
||||
with open(filetypes_path, 'r') as f:
|
||||
filetypes = json.load(f)
|
||||
assert 'xlsx' in filetypes
|
||||
|
||||
# Now delete it
|
||||
response = logged_in_client.post('/admin/filetypes/xlsx/delete', follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify it was deleted
|
||||
with app.app_context():
|
||||
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
|
||||
with open(filetypes_path, 'r') as f:
|
||||
filetypes = json.load(f)
|
||||
assert 'xlsx' not in filetypes
|
||||
|
||||
def test_update_filetype(self, logged_in_client, app):
|
||||
"""Test updating a file type."""
|
||||
# First add a file type to update
|
||||
logged_in_client.post('/admin/filetypes/add', data={
|
||||
'extension': 'png',
|
||||
'descripcion': 'Imagen PNG',
|
||||
'mime_type': 'image/png',
|
||||
'tamano_maximo': 5242880 # 5MB
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Now update it
|
||||
response = logged_in_client.post('/admin/filetypes/png/update', data={
|
||||
'descripcion': 'Imagen PNG Actualizada',
|
||||
'mime_type': 'image/png',
|
||||
'tamano_maximo': 10485760 # 10MB (increased)
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify changes
|
||||
with app.app_context():
|
||||
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
|
||||
with open(filetypes_path, 'r') as f:
|
||||
filetypes = json.load(f)
|
||||
assert 'png' in filetypes
|
||||
assert filetypes['png']['descripcion'] == 'Imagen PNG Actualizada'
|
||||
assert filetypes['png']['tamano_maximo'] == 10485760
|
|
@ -0,0 +1,67 @@
|
|||
import pytest
|
||||
from flask import session, g
|
||||
|
||||
class TestAuth:
|
||||
"""Test authentication functionality."""
|
||||
|
||||
def test_login_page(self, client):
|
||||
"""Test that login page loads correctly."""
|
||||
response = client.get('/auth/login')
|
||||
assert response.status_code == 200
|
||||
assert b'Iniciar sesi' in response.data # 'Iniciar sesión' in Spanish
|
||||
|
||||
def test_login_success(self, client):
|
||||
"""Test successful login with correct credentials."""
|
||||
response = client.post(
|
||||
'/auth/login',
|
||||
data={'username': 'admin', 'password': 'admin123'},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Check that we're redirected to the right page after login
|
||||
assert b'Panel' in response.data or b'Proyectos' in response.data
|
||||
|
||||
def test_login_invalid_credentials(self, client):
|
||||
"""Test login with invalid credentials."""
|
||||
response = client.post(
|
||||
'/auth/login',
|
||||
data={'username': 'admin', 'password': 'wrongpassword'},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert b'credenciales' in response.data.lower() # Error message about credentials
|
||||
|
||||
def test_logout(self, auth, client):
|
||||
"""Test logout functionality."""
|
||||
# First login
|
||||
auth.login()
|
||||
|
||||
# Then logout
|
||||
response = auth.logout()
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check if logged out - try to access a protected page
|
||||
response = client.get('/users/', follow_redirects=True)
|
||||
assert b'iniciar sesi' in response.data.lower() # Should see login page
|
||||
|
||||
def test_access_protected_route(self, client):
|
||||
"""Test accessing a protected route without login."""
|
||||
# Try to access users list without login
|
||||
response = client.get('/users/', follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert b'iniciar sesi' in response.data.lower() # Should be redirected to login
|
||||
|
||||
def test_access_protected_route_with_login(self, logged_in_client):
|
||||
"""Test accessing a protected route with login."""
|
||||
# Admin should be able to access users list
|
||||
response = logged_in_client.get('/admin/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_permission_levels(self, client, auth):
|
||||
"""Test different permission levels."""
|
||||
# Login as regular user
|
||||
auth.login(username='user1', password='admin123')
|
||||
|
||||
# Try to access admin-only page
|
||||
response = client.get('/admin/dashboard', follow_redirects=True)
|
||||
assert response.status_code == 403 or b'acceso denegado' in response.data.lower()
|
|
@ -0,0 +1,154 @@
|
|||
import pytest
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
class TestDocuments:
|
||||
"""Test document management functionality."""
|
||||
|
||||
def setup_project(self, logged_in_client, app):
|
||||
"""Helper to create a test project."""
|
||||
# Create a project
|
||||
logged_in_client.post('/projects/create', data={
|
||||
'codigo': 'TESTDOC',
|
||||
'descripcion': 'Proyecto para documentos',
|
||||
'cliente': 'Cliente Test',
|
||||
'esquema': 'TEST001',
|
||||
'destinacion': 'Pruebas',
|
||||
'notas': 'Proyecto para pruebas de documentos'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Find the project ID
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
project_dirs = [d for d in os.listdir(projects_dir) if 'TESTDOC' in d]
|
||||
assert len(project_dirs) > 0
|
||||
|
||||
project_dir = project_dirs[0]
|
||||
return project_dir.split('_')[0].replace('@', '')
|
||||
|
||||
def test_upload_document(self, logged_in_client, app):
|
||||
"""Test uploading a document to a project."""
|
||||
# Create a project and get its ID
|
||||
project_id = self.setup_project(logged_in_client, app)
|
||||
|
||||
# Create a test file
|
||||
test_file = FileStorage(
|
||||
stream=io.BytesIO(b'This is a test document content.'),
|
||||
filename='test_document.txt',
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
# Upload the document
|
||||
response = logged_in_client.post(
|
||||
f'/projects/{project_id}/documents/upload',
|
||||
data={
|
||||
'tipo_doc': 'Documento de Prueba', # Match schema type from TEST001
|
||||
'descripcion': 'Documento de prueba para tests',
|
||||
'file': test_file
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify document was created
|
||||
with app.app_context():
|
||||
project_path = None
|
||||
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
for dir_name in os.listdir(projects_dir):
|
||||
if dir_name.startswith(f'@{project_id}_'):
|
||||
project_path = os.path.join(projects_dir, dir_name)
|
||||
break
|
||||
|
||||
assert project_path is not None
|
||||
|
||||
# Check for documents directory with content
|
||||
docs_path = os.path.join(project_path, 'documents')
|
||||
assert os.path.exists(docs_path)
|
||||
|
||||
# Should have at least one document folder
|
||||
assert len(os.listdir(docs_path)) > 0
|
||||
|
||||
def test_document_versions(self, logged_in_client, app):
|
||||
"""Test document versioning functionality."""
|
||||
# Create a project and upload first version
|
||||
project_id = self.setup_project(logged_in_client, app)
|
||||
|
||||
# Upload first version
|
||||
test_file1 = FileStorage(
|
||||
stream=io.BytesIO(b'Document content version 1'),
|
||||
filename='test_versioning.txt',
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
logged_in_client.post(
|
||||
f'/projects/{project_id}/documents/upload',
|
||||
data={
|
||||
'tipo_doc': 'Documento de Prueba',
|
||||
'descripcion': 'Documento para pruebas de versiones',
|
||||
'file': test_file1
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
# Find the document ID
|
||||
doc_id = None
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
for dir_name in os.listdir(projects_dir):
|
||||
if dir_name.startswith(f'@{project_id}_'):
|
||||
project_path = os.path.join(projects_dir, dir_name)
|
||||
docs_path = os.path.join(project_path, 'documents')
|
||||
|
||||
# Get first document directory
|
||||
doc_dirs = os.listdir(docs_path)
|
||||
if doc_dirs:
|
||||
doc_id = doc_dirs[0].split('_')[0].replace('@', '')
|
||||
break
|
||||
|
||||
assert doc_id is not None
|
||||
|
||||
# Upload second version of the same document
|
||||
test_file2 = FileStorage(
|
||||
stream=io.BytesIO(b'Document content version 2 - UPDATED'),
|
||||
filename='test_versioning_v2.txt',
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
response = logged_in_client.post(
|
||||
f'/projects/{project_id}/documents/{doc_id}/upload',
|
||||
data={
|
||||
'descripcion': 'Segunda versión del documento',
|
||||
'file': test_file2
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check for multiple versions in metadata
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
for dir_name in os.listdir(projects_dir):
|
||||
if dir_name.startswith(f'@{project_id}_'):
|
||||
project_path = os.path.join(projects_dir, dir_name)
|
||||
docs_path = os.path.join(project_path, 'documents')
|
||||
|
||||
# Find document directory
|
||||
for doc_dir in os.listdir(docs_path):
|
||||
if doc_dir.startswith(f'@{doc_id}_'):
|
||||
doc_path = os.path.join(docs_path, doc_dir)
|
||||
meta_path = os.path.join(doc_path, 'meta.json')
|
||||
|
||||
# Check metadata for versions
|
||||
if os.path.exists(meta_path):
|
||||
with open(meta_path, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
assert 'versions' in metadata
|
||||
assert len(metadata['versions']) >= 2
|
|
@ -0,0 +1,162 @@
|
|||
import pytest
|
||||
import os
|
||||
import io
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
class TestIntegration:
|
||||
"""Test integrations between different components."""
|
||||
|
||||
def test_complete_workflow(self, logged_in_client, app):
|
||||
"""Test a complete workflow from project creation to document download."""
|
||||
# 1. Create a new project
|
||||
logged_in_client.post('/projects/create', data={
|
||||
'codigo': 'WORKFLOW',
|
||||
'descripcion': 'Proyecto de flujo completo',
|
||||
'cliente': 'Cliente Integración',
|
||||
'esquema': 'TEST001',
|
||||
'destinacion': 'Pruebas de integración',
|
||||
'notas': 'Notas de proyecto de prueba'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# 2. Find the project ID
|
||||
project_id = None
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
project_dirs = [d for d in os.listdir(projects_dir) if 'WORKFLOW' in d]
|
||||
assert len(project_dirs) > 0
|
||||
project_dir = project_dirs[0]
|
||||
project_id = project_dir.split('_')[0].replace('@', '')
|
||||
|
||||
assert project_id is not None
|
||||
|
||||
# 3. Upload a document to the project
|
||||
test_file = FileStorage(
|
||||
stream=io.BytesIO(b'Content for integration test document'),
|
||||
filename='integration_doc.txt',
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
response = logged_in_client.post(
|
||||
f'/projects/{project_id}/documents/upload',
|
||||
data={
|
||||
'tipo_doc': 'Documento de Prueba',
|
||||
'descripcion': 'Documento de flujo de integración',
|
||||
'file': test_file
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# 4. Find the document ID
|
||||
doc_id = None
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
for dir_name in os.listdir(projects_dir):
|
||||
if dir_name.startswith(f'@{project_id}_'):
|
||||
project_path = os.path.join(projects_dir, dir_name)
|
||||
docs_path = os.path.join(project_path, 'documents')
|
||||
|
||||
doc_dirs = os.listdir(docs_path)
|
||||
if doc_dirs:
|
||||
doc_id = doc_dirs[0].split('_')[0].replace('@', '')
|
||||
break
|
||||
|
||||
assert doc_id is not None
|
||||
|
||||
# 5. View document details
|
||||
response = logged_in_client.get(f'/projects/{project_id}/documents/{doc_id}')
|
||||
assert response.status_code == 200
|
||||
assert b'Documento de flujo de integraci' in response.data
|
||||
|
||||
# 6. Upload a new version of the document
|
||||
test_file2 = FileStorage(
|
||||
stream=io.BytesIO(b'Updated content for version 2'),
|
||||
filename='integration_doc_v2.txt',
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
response = logged_in_client.post(
|
||||
f'/projects/{project_id}/documents/{doc_id}/upload',
|
||||
data={
|
||||
'descripcion': 'Segunda versión del documento de integración',
|
||||
'file': test_file2
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# 7. Download the document
|
||||
response = logged_in_client.get(f'/projects/{project_id}/documents/{doc_id}/download/latest')
|
||||
assert response.status_code == 200
|
||||
assert b'Updated content for version 2' in response.data
|
||||
|
||||
# 8. Download a specific version (the first one)
|
||||
response = logged_in_client.get(f'/projects/{project_id}/documents/{doc_id}/download/1')
|
||||
assert response.status_code == 200
|
||||
assert b'Content for integration test document' in response.data
|
||||
|
||||
def test_schema_project_document_integration(self, logged_in_client, app):
|
||||
"""Test integration between schemas, projects and documents."""
|
||||
# 1. Create a custom schema
|
||||
logged_in_client.post('/schemas/create', data={
|
||||
'codigo': 'CUSTOM',
|
||||
'descripcion': 'Esquema personalizado para integración',
|
||||
'documentos-0-tipo': 'pdf',
|
||||
'documentos-0-nombre': 'Informe Principal',
|
||||
'documentos-0-nivel_ver': 0,
|
||||
'documentos-0-nivel_editar': 5000,
|
||||
'documentos-1-tipo': 'txt',
|
||||
'documentos-1-nombre': 'Notas Adicionales',
|
||||
'documentos-1-nivel_ver': 0,
|
||||
'documentos-1-nivel_editar': 1000
|
||||
}, follow_redirects=True)
|
||||
|
||||
# 2. Create a project with the custom schema
|
||||
logged_in_client.post('/projects/create', data={
|
||||
'codigo': 'PROJ_SCHEMA',
|
||||
'descripcion': 'Proyecto con esquema personalizado',
|
||||
'cliente': 'Cliente Test',
|
||||
'esquema': 'CUSTOM',
|
||||
'destinacion': 'Pruebas',
|
||||
'notas': 'Proyecto para probar integración de esquemas'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# 3. Find the project ID
|
||||
project_id = None
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
project_dirs = [d for d in os.listdir(projects_dir) if 'PROJ_SCHEMA' in d]
|
||||
assert len(project_dirs) > 0
|
||||
project_dir = project_dirs[0]
|
||||
project_id = project_dir.split('_')[0].replace('@', '')
|
||||
|
||||
# 4. Verify project uses the custom schema
|
||||
response = logged_in_client.get(f'/projects/{project_id}')
|
||||
assert response.status_code == 200
|
||||
assert b'CUSTOM' in response.data
|
||||
assert b'Informe Principal' in response.data
|
||||
assert b'Notas Adicionales' in response.data
|
||||
|
||||
# 5. Upload a document of the specified type
|
||||
test_file = FileStorage(
|
||||
stream=io.BytesIO(b'Notes content for schema integration'),
|
||||
filename='notes.txt',
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
response = logged_in_client.post(
|
||||
f'/projects/{project_id}/documents/upload',
|
||||
data={
|
||||
'tipo_doc': 'Notas Adicionales', # This should match schema document type
|
||||
'descripcion': 'Notas para prueba de esquema',
|
||||
'file': test_file
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
|
@ -0,0 +1,93 @@
|
|||
import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
class TestProjects:
|
||||
"""Test project-related functionality."""
|
||||
|
||||
def test_project_list(self, logged_in_client):
|
||||
"""Test listing projects."""
|
||||
response = logged_in_client.get('/projects/')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_create_project(self, logged_in_client, app):
|
||||
"""Test creating a new project."""
|
||||
response = logged_in_client.post('/projects/create', data={
|
||||
'codigo': 'TEST123',
|
||||
'descripcion': 'Proyecto de Prueba',
|
||||
'cliente': 'Cliente Test',
|
||||
'esquema': 'TEST001',
|
||||
'destinacion': 'Pruebas',
|
||||
'notas': 'Notas de prueba'
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check if project was created in storage
|
||||
with app.app_context():
|
||||
# Check indices
|
||||
indices_path = os.path.join(app.config['STORAGE_PATH'], 'indices.json')
|
||||
with open(indices_path, 'r') as f:
|
||||
indices = json.load(f)
|
||||
assert indices['max_project_id'] > 0
|
||||
|
||||
def test_view_project(self, logged_in_client, app):
|
||||
"""Test viewing a project (requires creating one first)."""
|
||||
# Create a project first
|
||||
logged_in_client.post('/projects/create', data={
|
||||
'codigo': 'TEST456',
|
||||
'descripcion': 'Proyecto para visualizar',
|
||||
'cliente': 'Cliente Test',
|
||||
'esquema': 'TEST001',
|
||||
'destinacion': 'Pruebas',
|
||||
'notas': 'Notas de prueba'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Now find the project ID
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
project_dirs = os.listdir(projects_dir)
|
||||
assert len(project_dirs) > 0
|
||||
|
||||
# Get first project directory
|
||||
project_dir = project_dirs[0]
|
||||
project_id = project_dir.split('_')[0].replace('@', '')
|
||||
|
||||
# Try to view it
|
||||
response = logged_in_client.get(f'/projects/{project_id}')
|
||||
assert response.status_code == 200
|
||||
assert b'Proyecto para visualizar' in response.data
|
||||
|
||||
def test_edit_project(self, logged_in_client, app):
|
||||
"""Test editing a project."""
|
||||
# Create a project first
|
||||
logged_in_client.post('/projects/create', data={
|
||||
'codigo': 'TESTEDIT',
|
||||
'descripcion': 'Proyecto para editar',
|
||||
'cliente': 'Cliente Test',
|
||||
'esquema': 'TEST001',
|
||||
'destinacion': 'Pruebas',
|
||||
'notas': 'Notas originales'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Find the project ID
|
||||
with app.app_context():
|
||||
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
|
||||
project_dirs = [d for d in os.listdir(projects_dir) if 'TESTEDIT' in d]
|
||||
assert len(project_dirs) > 0
|
||||
|
||||
project_dir = project_dirs[0]
|
||||
project_id = project_dir.split('_')[0].replace('@', '')
|
||||
|
||||
# Edit the project
|
||||
response = logged_in_client.post(f'/projects/{project_id}/edit', data={
|
||||
'codigo': 'TESTEDIT',
|
||||
'descripcion': 'Proyecto editado',
|
||||
'cliente': 'Cliente Test Modificado',
|
||||
'esquema': 'TEST001',
|
||||
'destinacion': 'Pruebas Modificadas',
|
||||
'notas': 'Notas modificadas'
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'Proyecto editado' in response.data
|
|
@ -0,0 +1,78 @@
|
|||
import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
class TestSchemas:
|
||||
"""Test schema management functionality."""
|
||||
|
||||
def test_list_schemas(self, logged_in_client):
|
||||
"""Test listing schemas."""
|
||||
response = logged_in_client.get('/schemas/')
|
||||
assert response.status_code == 200
|
||||
assert b'Esquemas de Proyecto' in response.data
|
||||
|
||||
def test_view_schema(self, logged_in_client):
|
||||
"""Test viewing a schema."""
|
||||
response = logged_in_client.get('/schemas/view/TEST001')
|
||||
assert response.status_code == 200
|
||||
assert b'Esquema de prueba' in response.data
|
||||
|
||||
def test_create_schema(self, logged_in_client, app):
|
||||
"""Test creating a new schema."""
|
||||
response = logged_in_client.post('/schemas/create', data={
|
||||
'codigo': 'TEST002',
|
||||
'descripcion': 'Nuevo esquema de prueba',
|
||||
'documentos-0-tipo': 'pdf',
|
||||
'documentos-0-nombre': 'Manual de Usuario',
|
||||
'documentos-0-nivel_ver': 0,
|
||||
'documentos-0-nivel_editar': 5000,
|
||||
'documentos-1-tipo': 'txt',
|
||||
'documentos-1-nombre': 'Notas de Proyecto',
|
||||
'documentos-1-nivel_ver': 0,
|
||||
'documentos-1-nivel_editar': 1000
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check if schema was created
|
||||
with app.app_context():
|
||||
schemas_path = os.path.join(app.config['STORAGE_PATH'], 'schemas', 'schema.json')
|
||||
with open(schemas_path, 'r') as f:
|
||||
schemas = json.load(f)
|
||||
assert 'TEST002' in schemas
|
||||
assert schemas['TEST002']['descripcion'] == 'Nuevo esquema de prueba'
|
||||
assert len(schemas['TEST002']['documentos']) == 2
|
||||
|
||||
def test_edit_schema(self, logged_in_client, app):
|
||||
"""Test editing a schema."""
|
||||
# First create a schema to edit
|
||||
logged_in_client.post('/schemas/create', data={
|
||||
'codigo': 'TESTEDIT',
|
||||
'descripcion': 'Esquema para editar',
|
||||
'documentos-0-tipo': 'pdf',
|
||||
'documentos-0-nombre': 'Documento Original',
|
||||
'documentos-0-nivel_ver': 0,
|
||||
'documentos-0-nivel_editar': 5000
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Now edit it
|
||||
response = logged_in_client.post('/schemas/edit/TESTEDIT', data={
|
||||
'codigo': 'TESTEDIT',
|
||||
'descripcion': 'Esquema editado',
|
||||
'documentos-0-tipo': 'pdf',
|
||||
'documentos-0-nombre': 'Documento Modificado',
|
||||
'documentos-0-nivel_ver': 500,
|
||||
'documentos-0-nivel_editar': 6000
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify changes
|
||||
with app.app_context():
|
||||
schemas_path = os.path.join(app.config['STORAGE_PATH'], 'schemas', 'schema.json')
|
||||
with open(schemas_path, 'r') as f:
|
||||
schemas = json.load(f)
|
||||
assert 'TESTEDIT' in schemas
|
||||
assert schemas['TESTEDIT']['descripcion'] == 'Esquema editado'
|
||||
assert schemas['TESTEDIT']['documentos'][0]['nombre'] == 'Documento Modificado'
|
||||
assert schemas['TESTEDIT']['documentos'][0]['nivel_ver'] == 500
|
|
@ -0,0 +1,128 @@
|
|||
import pytest
|
||||
import json
|
||||
import os
|
||||
|
||||
class TestUserManagement:
|
||||
"""Test user management functionality."""
|
||||
|
||||
def test_list_users(self, logged_in_client):
|
||||
"""Test listing users."""
|
||||
response = logged_in_client.get('/users/')
|
||||
assert response.status_code == 200
|
||||
assert b'Usuarios del Sistema' in response.data
|
||||
# Check for existing users
|
||||
assert b'admin' in response.data
|
||||
assert b'user1' in response.data
|
||||
|
||||
def test_create_user(self, logged_in_client, app):
|
||||
"""Test creating a new user."""
|
||||
response = logged_in_client.post('/users/create', data={
|
||||
'nombre': 'Usuario de Prueba',
|
||||
'username': 'testuser',
|
||||
'email': 'test@example.com',
|
||||
'password': 'password123',
|
||||
'password_confirm': 'password123',
|
||||
'nivel': 1000,
|
||||
'idioma': 'es',
|
||||
'empresa': 'Empresa Test',
|
||||
'estado': 'activo',
|
||||
'fecha_caducidad': ''
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check if user was created
|
||||
with app.app_context():
|
||||
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
|
||||
with open(users_path, 'r') as f:
|
||||
users = json.load(f)
|
||||
assert 'testuser' in users
|
||||
assert users['testuser']['nombre'] == 'Usuario de Prueba'
|
||||
assert users['testuser']['email'] == 'test@example.com'
|
||||
assert users['testuser']['nivel'] == 1000
|
||||
|
||||
def test_edit_user(self, logged_in_client, app):
|
||||
"""Test editing an existing user."""
|
||||
# First create a user to edit
|
||||
logged_in_client.post('/users/create', data={
|
||||
'nombre': 'Usuario para Editar',
|
||||
'username': 'edit_user',
|
||||
'email': 'edit@example.com',
|
||||
'password': 'password123',
|
||||
'password_confirm': 'password123',
|
||||
'nivel': 1000,
|
||||
'idioma': 'es',
|
||||
'empresa': 'Empresa Original',
|
||||
'estado': 'activo',
|
||||
'fecha_caducidad': ''
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Now edit the user
|
||||
response = logged_in_client.post('/users/edit/edit_user', data={
|
||||
'nombre': 'Usuario Editado',
|
||||
'email': 'edited@example.com',
|
||||
'password': '', # Empty password means no change
|
||||
'password_confirm': '',
|
||||
'nivel': 5000, # Changed level
|
||||
'idioma': 'en', # Changed language
|
||||
'empresa': 'Empresa Modificada',
|
||||
'estado': 'activo',
|
||||
'fecha_caducidad': ''
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify changes
|
||||
with app.app_context():
|
||||
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
|
||||
with open(users_path, 'r') as f:
|
||||
users = json.load(f)
|
||||
assert 'edit_user' in users
|
||||
assert users['edit_user']['nombre'] == 'Usuario Editado'
|
||||
assert users['edit_user']['email'] == 'edited@example.com'
|
||||
assert users['edit_user']['nivel'] == 5000
|
||||
assert users['edit_user']['idioma'] == 'en'
|
||||
assert users['edit_user']['empresa'] == 'Empresa Modificada'
|
||||
|
||||
def test_delete_user(self, logged_in_client, app):
|
||||
"""Test deleting a user."""
|
||||
# First create a user to delete
|
||||
logged_in_client.post('/users/create', data={
|
||||
'nombre': 'Usuario para Eliminar',
|
||||
'username': 'delete_user',
|
||||
'email': 'delete@example.com',
|
||||
'password': 'password123',
|
||||
'password_confirm': 'password123',
|
||||
'nivel': 1000,
|
||||
'idioma': 'es',
|
||||
'empresa': 'Empresa Test',
|
||||
'estado': 'activo',
|
||||
'fecha_caducidad': ''
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Verify user was created
|
||||
with app.app_context():
|
||||
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
|
||||
with open(users_path, 'r') as f:
|
||||
users = json.load(f)
|
||||
assert 'delete_user' in users
|
||||
|
||||
# Now delete the user
|
||||
response = logged_in_client.post('/users/delete/delete_user', follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify user was deleted
|
||||
with app.app_context():
|
||||
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
|
||||
with open(users_path, 'r') as f:
|
||||
users = json.load(f)
|
||||
assert 'delete_user' not in users
|
||||
|
||||
def test_cannot_delete_admin(self, logged_in_client):
|
||||
"""Test that admin user cannot be deleted."""
|
||||
response = logged_in_client.post('/users/delete/admin', follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Should see an error message
|
||||
assert b'No se puede eliminar' in response.data or b'no puede' in response.data
|
|
@ -6,204 +6,288 @@ from datetime import datetime
|
|||
import pytz
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
import mimetypes
|
||||
|
||||
# Initialize mimetypes
|
||||
mimetypes.init()
|
||||
|
||||
# Try to import magic library but don't fail if it's not available
|
||||
try:
|
||||
import magic
|
||||
|
||||
HAS_MAGIC = True
|
||||
except ImportError:
|
||||
HAS_MAGIC = False
|
||||
|
||||
|
||||
def ensure_dir_exists(directory):
|
||||
"""
|
||||
Asegurar que un directorio existe, creándolo si es necesario.
|
||||
|
||||
|
||||
Args:
|
||||
directory (str): Ruta del directorio
|
||||
"""
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
|
||||
def load_json_file(file_path, default=None):
|
||||
"""
|
||||
Cargar un archivo JSON.
|
||||
|
||||
Load JSON data from a file.
|
||||
|
||||
Args:
|
||||
file_path (str): Ruta al archivo JSON
|
||||
default: Valor por defecto si el archivo no existe o no es válido
|
||||
|
||||
file_path (str): Path to the JSON file
|
||||
default: Default value to return if file doesn't exist
|
||||
|
||||
Returns:
|
||||
dict: Contenido del archivo JSON o valor por defecto
|
||||
dict or list: Loaded JSON data or default value
|
||||
"""
|
||||
if default is None:
|
||||
default = {}
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {} if default is None else default
|
||||
except json.JSONDecodeError:
|
||||
current_app.logger.error(f"Error decoding JSON from {file_path}")
|
||||
return default
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error loading file {file_path}: {str(e)}")
|
||||
return default
|
||||
|
||||
|
||||
def save_json_file(file_path, data):
|
||||
"""
|
||||
Guardar datos en un archivo JSON.
|
||||
|
||||
Save data to a JSON file.
|
||||
|
||||
Args:
|
||||
file_path (str): Ruta al archivo JSON
|
||||
data: Datos a guardar (deben ser serializables a JSON)
|
||||
file_path (str): Path to the JSON file
|
||||
data (dict or list): Data to save
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
# Asegurar que el directorio existe
|
||||
directory = os.path.dirname(file_path)
|
||||
ensure_dir_exists(directory)
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
try:
|
||||
# Ensure directory exists
|
||||
directory = os.path.dirname(file_path)
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error saving file {file_path}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_next_id(id_type):
|
||||
"""
|
||||
Obtener el siguiente ID disponible para proyectos o documentos.
|
||||
|
||||
|
||||
Args:
|
||||
id_type (str): Tipo de ID ('project' o 'document')
|
||||
|
||||
|
||||
Returns:
|
||||
int: Siguiente ID disponible
|
||||
"""
|
||||
storage_path = current_app.config['STORAGE_PATH']
|
||||
indices_file = os.path.join(storage_path, 'indices.json')
|
||||
|
||||
storage_path = current_app.config["STORAGE_PATH"]
|
||||
indices_file = os.path.join(storage_path, "indices.json")
|
||||
|
||||
# Cargar índices
|
||||
indices = load_json_file(indices_file, {"max_project_id": 0, "max_document_id": 0})
|
||||
|
||||
|
||||
# Incrementar y guardar
|
||||
if id_type == 'project':
|
||||
indices['max_project_id'] += 1
|
||||
new_id = indices['max_project_id']
|
||||
elif id_type == 'document':
|
||||
indices['max_document_id'] += 1
|
||||
new_id = indices['max_document_id']
|
||||
if id_type == "project":
|
||||
indices["max_project_id"] += 1
|
||||
new_id = indices["max_project_id"]
|
||||
elif id_type == "document":
|
||||
indices["max_document_id"] += 1
|
||||
new_id = indices["max_document_id"]
|
||||
else:
|
||||
raise ValueError(f"Tipo de ID no válido: {id_type}")
|
||||
|
||||
|
||||
save_json_file(indices_file, indices)
|
||||
|
||||
|
||||
return new_id
|
||||
|
||||
|
||||
def format_project_directory_name(project_id, project_name):
|
||||
"""
|
||||
Formatear nombre de directorio para un proyecto.
|
||||
|
||||
|
||||
Args:
|
||||
project_id (int): ID del proyecto
|
||||
project_name (str): Nombre del proyecto
|
||||
|
||||
|
||||
Returns:
|
||||
str: Nombre de directorio formateado
|
||||
"""
|
||||
# Sanitizar nombre de proyecto
|
||||
safe_name = secure_filename(project_name)
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
|
||||
safe_name = safe_name.replace(" ", "_")
|
||||
|
||||
# Formatear como @id_num_@project_name
|
||||
return f"@{project_id:03d}_@{safe_name}"
|
||||
|
||||
|
||||
def format_document_directory_name(document_id, document_name):
|
||||
"""
|
||||
Formatear nombre de directorio para un documento.
|
||||
|
||||
|
||||
Args:
|
||||
document_id (int): ID del documento
|
||||
document_name (str): Nombre del documento
|
||||
|
||||
|
||||
Returns:
|
||||
str: Nombre de directorio formateado
|
||||
"""
|
||||
# Sanitizar nombre de documento
|
||||
safe_name = secure_filename(document_name)
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
|
||||
safe_name = safe_name.replace(" ", "_")
|
||||
|
||||
# Formatear como @id_num_@doc_name
|
||||
return f"@{document_id:03d}_@{safe_name}"
|
||||
|
||||
|
||||
def format_version_filename(version, document_name, extension):
|
||||
"""
|
||||
Formatear nombre de archivo para una versión de documento.
|
||||
|
||||
|
||||
Args:
|
||||
version (int): Número de versión
|
||||
document_name (str): Nombre base del documento
|
||||
extension (str): Extensión del archivo
|
||||
|
||||
|
||||
Returns:
|
||||
str: Nombre de archivo formateado
|
||||
"""
|
||||
# Sanitizar nombre
|
||||
safe_name = secure_filename(document_name)
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
|
||||
safe_name = safe_name.replace(" ", "_")
|
||||
|
||||
# Asegurar que la extensión no tiene el punto
|
||||
if extension.startswith('.'):
|
||||
if extension.startswith("."):
|
||||
extension = extension[1:]
|
||||
|
||||
|
||||
# Formatear como v001_doc_name.ext
|
||||
return f"v{version:03d}_{safe_name}.{extension}"
|
||||
|
||||
|
||||
def create_zip_archive(source_dir, files_to_include, output_path):
|
||||
"""
|
||||
Crear un archivo ZIP con los documentos seleccionados.
|
||||
|
||||
|
||||
Args:
|
||||
source_dir (str): Directorio fuente
|
||||
files_to_include (list): Lista de archivos a incluir
|
||||
output_path (str): Ruta de salida para el ZIP
|
||||
|
||||
|
||||
Returns:
|
||||
str: Ruta al archivo ZIP creado
|
||||
"""
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_info in files_to_include:
|
||||
zipf.write(
|
||||
os.path.join(source_dir, file_info['path']),
|
||||
arcname=file_info['arcname']
|
||||
os.path.join(source_dir, file_info["path"]),
|
||||
arcname=file_info["arcname"],
|
||||
)
|
||||
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def get_directory_size(directory):
|
||||
"""
|
||||
Calcular el tamaño total de un directorio en bytes.
|
||||
|
||||
|
||||
Args:
|
||||
directory (str): Ruta al directorio
|
||||
|
||||
|
||||
Returns:
|
||||
int: Tamaño en bytes
|
||||
"""
|
||||
total_size = 0
|
||||
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(directory):
|
||||
for filename in filenames:
|
||||
file_path = os.path.join(dirpath, filename)
|
||||
total_size += os.path.getsize(file_path)
|
||||
|
||||
|
||||
return total_size
|
||||
|
||||
|
||||
def get_file_info(file_path):
|
||||
"""
|
||||
Obtener información básica sobre un archivo.
|
||||
|
||||
|
||||
Args:
|
||||
file_path (str): Ruta al archivo
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Información del archivo
|
||||
"""
|
||||
stat_info = os.stat(file_path)
|
||||
|
||||
|
||||
return {
|
||||
'filename': os.path.basename(file_path),
|
||||
'size': stat_info.st_size,
|
||||
'created': datetime.fromtimestamp(stat_info.st_ctime, pytz.UTC).isoformat(),
|
||||
'modified': datetime.fromtimestamp(stat_info.st_mtime, pytz.UTC).isoformat(),
|
||||
'path': file_path
|
||||
"filename": os.path.basename(file_path),
|
||||
"size": stat_info.st_size,
|
||||
"created": datetime.fromtimestamp(stat_info.st_ctime, pytz.UTC).isoformat(),
|
||||
"modified": datetime.fromtimestamp(stat_info.st_mtime, pytz.UTC).isoformat(),
|
||||
"path": file_path,
|
||||
}
|
||||
|
||||
|
||||
def delete_directory_with_content(directory):
|
||||
"""
|
||||
Eliminar un directorio y todo su contenido.
|
||||
|
||||
|
||||
Args:
|
||||
directory (str): Ruta al directorio a eliminar
|
||||
"""
|
||||
if os.path.exists(directory):
|
||||
shutil.rmtree(directory)
|
||||
shutil.rmtree(directory)
|
||||
|
||||
|
||||
def detect_file_type(file_path=None, file_data=None):
|
||||
"""
|
||||
Detect file type using either libmagic (if available) or file extension.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
file_data: Binary data of the file (used with magic)
|
||||
|
||||
Returns:
|
||||
tuple: (mime_type, encoding)
|
||||
"""
|
||||
if HAS_MAGIC:
|
||||
# Use libmagic for more accurate file type detection
|
||||
try:
|
||||
mime = magic.Magic(mime=True)
|
||||
if file_data:
|
||||
mime_type = mime.from_buffer(file_data)
|
||||
return mime_type, None
|
||||
elif file_path and os.path.exists(file_path):
|
||||
mime_type = mime.from_file(file_path)
|
||||
return mime_type, None
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error using magic library: {str(e)}")
|
||||
# Fall back to extension-based detection
|
||||
|
||||
# Fallback method using file extensions
|
||||
if file_path:
|
||||
mime_type, encoding = mimetypes.guess_type(file_path)
|
||||
return mime_type or "application/octet-stream", encoding
|
||||
|
||||
# If no path or data, return a default
|
||||
return "application/octet-stream", None
|
||||
|
||||
|
||||
def is_allowed_file(filename, allowed_extensions):
|
||||
"""Check if a file has an allowed extension"""
|
||||
if "." not in filename:
|
||||
return False
|
||||
ext = filename.rsplit(".", 1)[1].lower()
|
||||
return ext in allowed_extensions
|
||||
|
|
347
utils/logger.py
347
utils/logger.py
|
@ -0,0 +1,347 @@
|
|||
import os
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from flask import current_app, request, has_request_context
|
||||
|
||||
|
||||
class RequestFormatter(logging.Formatter):
|
||||
"""Formatter that adds request-specific info to logs when available."""
|
||||
|
||||
def format(self, record):
|
||||
if has_request_context():
|
||||
record.url = request.url
|
||||
record.method = request.method
|
||||
record.remote_addr = request.remote_addr
|
||||
record.user_agent = request.user_agent
|
||||
else:
|
||||
record.url = None
|
||||
record.method = None
|
||||
record.remote_addr = None
|
||||
record.user_agent = None
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def setup_logger(app):
|
||||
"""Configure application logging.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
log_dir = os.path.join(app.config.get("STORAGE_PATH", "storage"), "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Determine environment mode (using debug instead of ENV)
|
||||
env_mode = "development" if app.debug else "production"
|
||||
|
||||
# Configure log level based on environment
|
||||
log_level = logging.DEBUG if app.debug else logging.INFO
|
||||
|
||||
# Base file names
|
||||
log_file = os.path.join(log_dir, f"app.log")
|
||||
error_log_file = os.path.join(log_dir, f"error.log")
|
||||
access_log_file = os.path.join(log_dir, f"access.log")
|
||||
|
||||
# Configure formatters
|
||||
standard_formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(module)s: %(message)s"
|
||||
)
|
||||
|
||||
detailed_formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(module)s.%(funcName)s:%(lineno)d: %(message)s"
|
||||
)
|
||||
|
||||
access_formatter = RequestFormatter(
|
||||
"%(asctime)s - %(remote_addr)s - %(method)s %(url)s - "
|
||||
"%(user_agent)s - %(message)s"
|
||||
)
|
||||
|
||||
# Create system logger (general application logs)
|
||||
system_logger = logging.getLogger("app")
|
||||
system_logger.setLevel(log_level)
|
||||
|
||||
# Rotating file handler for general logs (10 MB files, keep 10 backups)
|
||||
rfh = RotatingFileHandler(
|
||||
log_file, maxBytes=10 * 1024 * 1024, backupCount=10, encoding="utf-8"
|
||||
)
|
||||
rfh.setFormatter(standard_formatter)
|
||||
rfh.setLevel(log_level)
|
||||
system_logger.addHandler(rfh)
|
||||
|
||||
# Error logger with more detailed output
|
||||
error_logger = logging.getLogger("app.error")
|
||||
error_logger.setLevel(logging.ERROR)
|
||||
|
||||
error_handler = RotatingFileHandler(
|
||||
error_log_file, maxBytes=10 * 1024 * 1024, backupCount=10, encoding="utf-8"
|
||||
)
|
||||
error_handler.setFormatter(detailed_formatter)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_logger.addHandler(error_handler)
|
||||
|
||||
# Access logger for HTTP requests
|
||||
access_logger = logging.getLogger("app.access")
|
||||
access_logger.setLevel(logging.INFO)
|
||||
|
||||
# Daily rotating access logs
|
||||
access_handler = TimedRotatingFileHandler(
|
||||
access_log_file, when="midnight", interval=1, backupCount=30, encoding="utf-8"
|
||||
)
|
||||
access_handler.setFormatter(access_formatter)
|
||||
access_handler.setLevel(logging.INFO)
|
||||
access_logger.addHandler(access_handler)
|
||||
|
||||
# Add handlers to Flask logger
|
||||
for handler in [rfh, error_handler]:
|
||||
app.logger.addHandler(handler)
|
||||
|
||||
app.logger.setLevel(log_level)
|
||||
|
||||
# Initial startup log entries
|
||||
system_logger.info(f"Aplicación iniciada en modo: {env_mode}")
|
||||
system_logger.info(
|
||||
f"Nivel de logging establecido a: {logging.getLevelName(log_level)}"
|
||||
)
|
||||
|
||||
# Register access log handler for requests
|
||||
@app.after_request
|
||||
def log_request(response):
|
||||
if not request.path.startswith("/static/"):
|
||||
access_logger.info(
|
||||
f"Status: {response.status_code} - Size: {response.calculate_content_length()}"
|
||||
)
|
||||
return response
|
||||
|
||||
return system_logger
|
||||
|
||||
|
||||
def log_access(username, endpoint, method, ip_address, status_code):
|
||||
"""
|
||||
Registrar acceso a la aplicación.
|
||||
|
||||
Args:
|
||||
username (str): Nombre de usuario
|
||||
endpoint (str): Endpoint accedido
|
||||
method (str): Método HTTP
|
||||
ip_address (str): Dirección IP
|
||||
status_code (int): Código de estado HTTP
|
||||
"""
|
||||
logger = logging.getLogger("app.access")
|
||||
logger.info(
|
||||
f"Usuario: {username} | Endpoint: {endpoint} | Método: {method} | IP: {ip_address} | Estado: {status_code}"
|
||||
)
|
||||
|
||||
|
||||
def log_error(error, username=None):
|
||||
"""
|
||||
Registrar error en la aplicación.
|
||||
|
||||
Args:
|
||||
error: Excepción o mensaje de error
|
||||
username (str, optional): Nombre de usuario
|
||||
"""
|
||||
logger = logging.getLogger("app.error")
|
||||
|
||||
if username:
|
||||
logger.error(f"Usuario: {username} | Error: {str(error)}")
|
||||
else:
|
||||
logger.error(str(error))
|
||||
|
||||
|
||||
def log_system_event(event_type, message):
|
||||
"""
|
||||
Registrar evento del sistema.
|
||||
|
||||
Args:
|
||||
event_type (str): Tipo de evento
|
||||
message (str): Mensaje descriptivo
|
||||
"""
|
||||
logger = logging.getLogger("app")
|
||||
logger.info(f"Evento: {event_type} | {message}")
|
||||
|
||||
|
||||
def log_user_activity(
|
||||
user_id, activity_type, ip_address=None, user_agent=None, details=None
|
||||
):
|
||||
"""
|
||||
Registrar actividad de usuario para auditoría.
|
||||
|
||||
Args:
|
||||
user_id (str): ID del usuario
|
||||
activity_type (str): Tipo de actividad
|
||||
ip_address (str, optional): Dirección IP
|
||||
user_agent (str, optional): User-Agent del navegador
|
||||
details (dict, optional): Detalles adicionales
|
||||
"""
|
||||
logger = logging.getLogger("audit")
|
||||
|
||||
# Crear registro de actividad
|
||||
activity = {
|
||||
"timestamp": datetime.now(pytz.UTC).isoformat(),
|
||||
"user_id": user_id,
|
||||
"activity_type": activity_type,
|
||||
"ip_address": ip_address or request.remote_addr if request else "unknown",
|
||||
"user_agent": user_agent
|
||||
or (request.user_agent.string if request and request.user_agent else "unknown"),
|
||||
}
|
||||
|
||||
# Añadir detalles si se proporcionan
|
||||
if details:
|
||||
activity["details"] = details
|
||||
|
||||
# Registrar como JSON
|
||||
logger.info(json.dumps(activity))
|
||||
|
||||
|
||||
def log_document_access(user_id, project_id, document_id, version, action):
|
||||
"""
|
||||
Registrar acceso a documentos.
|
||||
|
||||
Args:
|
||||
user_id (str): ID del usuario
|
||||
project_id (int): ID del proyecto
|
||||
document_id (int): ID del documento
|
||||
version (int): Versión del documento
|
||||
action (str): Acción realizada ('view', 'download', 'upload')
|
||||
"""
|
||||
log_user_activity(
|
||||
user_id=user_id,
|
||||
activity_type=f"document_{action}",
|
||||
details={
|
||||
"project_id": project_id,
|
||||
"document_id": document_id,
|
||||
"version": version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def log_project_activity(user_id, project_id, action, details=None):
|
||||
"""
|
||||
Registrar actividad en proyectos.
|
||||
|
||||
Args:
|
||||
user_id (str): ID del usuario
|
||||
project_id (int): ID del proyecto
|
||||
action (str): Acción realizada ('create', 'update', 'delete')
|
||||
details (dict, optional): Detalles adicionales
|
||||
"""
|
||||
activity_details = {"project_id": project_id}
|
||||
|
||||
if details:
|
||||
activity_details.update(details)
|
||||
|
||||
log_user_activity(
|
||||
user_id=user_id, activity_type=f"project_{action}", details=activity_details
|
||||
)
|
||||
|
||||
|
||||
def log_user_management(admin_id, target_user_id, action, details=None):
|
||||
"""
|
||||
Registrar actividad de gestión de usuarios.
|
||||
|
||||
Args:
|
||||
admin_id (str): ID del administrador
|
||||
target_user_id (str): ID del usuario objetivo
|
||||
action (str): Acción realizada ('create', 'update', 'delete')
|
||||
details (dict, optional): Detalles adicionales
|
||||
"""
|
||||
activity_details = {"target_user_id": target_user_id}
|
||||
|
||||
if details:
|
||||
activity_details.update(details)
|
||||
|
||||
log_user_activity(
|
||||
user_id=admin_id, activity_type=f"user_{action}", details=activity_details
|
||||
)
|
||||
|
||||
|
||||
def get_audit_logs(filters=None, limit=100):
|
||||
"""
|
||||
Obtener registros de auditoría filtrados.
|
||||
|
||||
Args:
|
||||
filters (dict, optional): Filtros a aplicar
|
||||
- user_id: ID de usuario
|
||||
- activity_type: Tipo de actividad
|
||||
- start_date: Fecha de inicio (ISO format)
|
||||
- end_date: Fecha de fin (ISO format)
|
||||
limit (int, optional): Número máximo de registros a devolver
|
||||
|
||||
Returns:
|
||||
list: Lista de registros de auditoría
|
||||
"""
|
||||
log_dir = current_app.config["LOG_DIR"]
|
||||
audit_log_file = os.path.join(log_dir, "audit.log")
|
||||
|
||||
if not os.path.exists(audit_log_file):
|
||||
return []
|
||||
|
||||
logs = []
|
||||
|
||||
with open(audit_log_file, "r") as f:
|
||||
for line in f:
|
||||
try:
|
||||
# Extraer la parte JSON del log
|
||||
json_start = line.find("{")
|
||||
if json_start == -1:
|
||||
continue
|
||||
|
||||
json_str = line[json_start:]
|
||||
log_entry = json.loads(json_str)
|
||||
|
||||
# Aplicar filtros
|
||||
if filters:
|
||||
# Filtrar por usuario
|
||||
if (
|
||||
"user_id" in filters
|
||||
and filters["user_id"]
|
||||
and log_entry.get("user_id") != filters["user_id"]
|
||||
):
|
||||
continue
|
||||
|
||||
# Filtrar por tipo de actividad
|
||||
if (
|
||||
"activity_type" in filters
|
||||
and filters["activity_type"]
|
||||
and log_entry.get("activity_type") != filters["activity_type"]
|
||||
):
|
||||
continue
|
||||
|
||||
# Filtrar por fecha de inicio
|
||||
if "start_date" in filters and filters["start_date"]:
|
||||
start_date = datetime.fromisoformat(
|
||||
filters["start_date"].replace("Z", "+00:00")
|
||||
)
|
||||
log_date = datetime.fromisoformat(
|
||||
log_entry.get("timestamp", "").replace("Z", "+00:00")
|
||||
)
|
||||
if log_date < start_date:
|
||||
continue
|
||||
|
||||
# Filtrar por fecha de fin
|
||||
if "end_date" in filters and filters["end_date"]:
|
||||
end_date = datetime.fromisoformat(
|
||||
filters["end_date"].replace("Z", "+00:00")
|
||||
)
|
||||
log_date = datetime.fromisoformat(
|
||||
log_entry.get("timestamp", "").replace("Z", "+00:00")
|
||||
)
|
||||
if log_date > end_date:
|
||||
continue
|
||||
|
||||
logs.append(log_entry)
|
||||
|
||||
# Limitar número de registros
|
||||
if len(logs) >= limit:
|
||||
break
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
# Ordenar por timestamp (más reciente primero)
|
||||
logs.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
|
||||
return logs
|
|
@ -1,122 +1,143 @@
|
|||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import magic
|
||||
from flask import current_app
|
||||
from flask import current_app, request, abort
|
||||
from functools import wraps
|
||||
from flask_login import current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
# Try to import magic with a fallback for Windows
|
||||
try:
|
||||
import magic
|
||||
|
||||
HAS_MAGIC = True
|
||||
except ImportError:
|
||||
HAS_MAGIC = False
|
||||
current_app.logger.warning("libmagic not found, using basic file type detection")
|
||||
|
||||
from utils.file_utils import detect_file_type
|
||||
|
||||
|
||||
def check_file_type(file_stream, allowed_mime_types):
|
||||
"""
|
||||
Verificar el tipo MIME real de un archivo.
|
||||
|
||||
|
||||
Args:
|
||||
file_stream: Stream del archivo a verificar
|
||||
allowed_mime_types (list): Lista de tipos MIME permitidos
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True si el tipo es permitido, False en caso contrario
|
||||
"""
|
||||
# Guardar posición actual en el stream
|
||||
current_position = file_stream.tell()
|
||||
|
||||
|
||||
# Leer los primeros bytes para detectar el tipo
|
||||
file_head = file_stream.read(2048)
|
||||
file_stream.seek(current_position) # Restaurar posición
|
||||
|
||||
|
||||
# Detectar tipo MIME
|
||||
mime = magic.Magic(mime=True)
|
||||
file_type = mime.from_buffer(file_head)
|
||||
|
||||
|
||||
return file_type in allowed_mime_types
|
||||
|
||||
|
||||
def calculate_checksum(file_path):
|
||||
"""
|
||||
Calcular el hash SHA-256 de un archivo.
|
||||
|
||||
|
||||
Args:
|
||||
file_path (str): Ruta al archivo
|
||||
|
||||
|
||||
Returns:
|
||||
str: Hash SHA-256 en formato hexadecimal
|
||||
"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
# Leer por bloques para archivos grandes
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
|
||||
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def sanitize_filename(filename):
|
||||
"""
|
||||
Sanitizar nombre de archivo.
|
||||
|
||||
|
||||
Args:
|
||||
filename (str): Nombre de archivo original
|
||||
|
||||
|
||||
Returns:
|
||||
str: Nombre de archivo sanitizado
|
||||
"""
|
||||
# Primero usar secure_filename de Werkzeug
|
||||
safe_name = secure_filename(filename)
|
||||
|
||||
|
||||
# Eliminar caracteres problemáticos adicionales
|
||||
safe_name = re.sub(r'[^\w\s.-]', '', safe_name)
|
||||
|
||||
safe_name = re.sub(r"[^\w\s.-]", "", safe_name)
|
||||
|
||||
# Reemplazar espacios por guiones bajos
|
||||
safe_name = safe_name.replace(' ', '_')
|
||||
|
||||
safe_name = safe_name.replace(" ", "_")
|
||||
|
||||
return safe_name
|
||||
|
||||
|
||||
def generate_unique_filename(base_dir, filename_pattern):
|
||||
"""
|
||||
Generar nombre de archivo único basado en un patrón.
|
||||
|
||||
|
||||
Args:
|
||||
base_dir (str): Directorio base
|
||||
filename_pattern (str): Patrón de nombre (puede contener {counter})
|
||||
|
||||
|
||||
Returns:
|
||||
str: Nombre de archivo único
|
||||
"""
|
||||
counter = 1
|
||||
filename = filename_pattern.format(counter=counter)
|
||||
|
||||
|
||||
while os.path.exists(os.path.join(base_dir, filename)):
|
||||
counter += 1
|
||||
filename = filename_pattern.format(counter=counter)
|
||||
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def permission_required(min_level):
|
||||
"""
|
||||
Decorador para verificar nivel de permisos.
|
||||
|
||||
|
||||
Args:
|
||||
min_level (int): Nivel mínimo requerido
|
||||
|
||||
|
||||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
|
||||
|
||||
if not current_user.has_permission(min_level):
|
||||
return forbidden_error()
|
||||
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def forbidden_error():
|
||||
"""Respuesta para error 403 (acceso denegado)."""
|
||||
from flask import render_template
|
||||
return render_template('error.html',
|
||||
error_code=403,
|
||||
error_message="Acceso denegado"), 403
|
||||
|
||||
return (
|
||||
render_template("error.html", error_code=403, error_message="Acceso denegado"),
|
||||
403,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,332 @@
|
|||
import re
|
||||
import os
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask import current_app
|
||||
|
||||
def validate_email(email):
|
||||
"""
|
||||
Validar formato de dirección de correo electrónico.
|
||||
|
||||
Args:
|
||||
email (str): Dirección de correo a validar
|
||||
|
||||
Returns:
|
||||
bool: True si es válido, False en caso contrario
|
||||
"""
|
||||
# Patrón básico para validar emails
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return bool(re.match(pattern, email))
|
||||
|
||||
def validate_username(username):
|
||||
"""
|
||||
Validar formato de nombre de usuario.
|
||||
|
||||
Args:
|
||||
username (str): Nombre de usuario a validar
|
||||
|
||||
Returns:
|
||||
bool: True si es válido, False en caso contrario
|
||||
"""
|
||||
# Solo letras, números, guiones y guiones bajos, longitud entre 3 y 20
|
||||
pattern = r'^[a-zA-Z0-9_-]{3,20}$'
|
||||
return bool(re.match(pattern, username))
|
||||
|
||||
def validate_password_strength(password):
|
||||
"""
|
||||
Validar fortaleza de contraseña.
|
||||
|
||||
Args:
|
||||
password (str): Contraseña a validar
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, message)
|
||||
- is_valid (bool): True si es válida
|
||||
- message (str): Mensaje descriptivo si no es válida
|
||||
"""
|
||||
# Verificar longitud mínima
|
||||
if len(password) < 8:
|
||||
return False, "La contraseña debe tener al menos 8 caracteres."
|
||||
|
||||
# Verificar presencia de letras
|
||||
if not re.search(r'[a-zA-Z]', password):
|
||||
return False, "La contraseña debe contener al menos una letra."
|
||||
|
||||
# Verificar presencia de números
|
||||
if not re.search(r'\d', password):
|
||||
return False, "La contraseña debe contener al menos un número."
|
||||
|
||||
# Verificar presencia de caracteres especiales (opcional)
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||
return False, "La contraseña debe contener al menos un carácter especial."
|
||||
|
||||
return True, "Contraseña válida."
|
||||
|
||||
def validate_date_format(date_str, format='%Y-%m-%d'):
|
||||
"""
|
||||
Validar formato de fecha.
|
||||
|
||||
Args:
|
||||
date_str (str): Fecha en formato string
|
||||
format (str, optional): Formato esperado. Por defecto '%Y-%m-%d'.
|
||||
|
||||
Returns:
|
||||
bool: True si es válido, False en caso contrario
|
||||
"""
|
||||
try:
|
||||
datetime.strptime(date_str, format)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def validate_iso_date(date_str):
|
||||
"""
|
||||
Validar fecha en formato ISO 8601.
|
||||
|
||||
Args:
|
||||
date_str (str): Fecha en formato ISO 8601
|
||||
|
||||
Returns:
|
||||
bool: True si es válido, False en caso contrario
|
||||
"""
|
||||
try:
|
||||
datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return True
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
def validate_file_extension(filename, allowed_extensions):
|
||||
"""
|
||||
Validar extensión de archivo.
|
||||
|
||||
Args:
|
||||
filename (str): Nombre del archivo
|
||||
allowed_extensions (list): Lista de extensiones permitidas
|
||||
|
||||
Returns:
|
||||
bool: True si es válido, False en caso contrario
|
||||
"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
||||
|
||||
def validate_file_size(file, max_size_bytes):
|
||||
"""
|
||||
Validar tamaño de archivo.
|
||||
|
||||
Args:
|
||||
file: Objeto de archivo (de Flask)
|
||||
max_size_bytes (int): Tamaño máximo en bytes
|
||||
|
||||
Returns:
|
||||
bool: True si es válido, False en caso contrario
|
||||
"""
|
||||
# Guardar posición actual
|
||||
current_position = file.tell()
|
||||
|
||||
# Ir al final para obtener el tamaño
|
||||
file.seek(0, os.SEEK_END)
|
||||
size = file.tell()
|
||||
|
||||
# Restaurar posición
|
||||
file.seek(current_position)
|
||||
|
||||
return size <= max_size_bytes
|
||||
|
||||
def validate_project_data(data):
|
||||
"""
|
||||
Validar datos de proyecto.
|
||||
|
||||
Args:
|
||||
data (dict): Datos del proyecto a validar
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, errors)
|
||||
- is_valid (bool): True si es válido
|
||||
- errors (dict): Diccionario con errores por campo
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
# Validar campos obligatorios
|
||||
required_fields = ['descripcion', 'cliente', 'esquema']
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
errors[field] = f"El campo {field} es obligatorio."
|
||||
|
||||
# Validar longitud de descripción
|
||||
if 'descripcion' in data and data['descripcion']:
|
||||
if len(data['descripcion']) < 5:
|
||||
errors['descripcion'] = "La descripción debe tener al menos 5 caracteres."
|
||||
elif len(data['descripcion']) > 100:
|
||||
errors['descripcion'] = "La descripción no puede exceder los 100 caracteres."
|
||||
|
||||
# Validar esquema
|
||||
if 'esquema' in data and data['esquema']:
|
||||
from services.schema_service import get_schema
|
||||
schema = get_schema(data['esquema'])
|
||||
if not schema:
|
||||
errors['esquema'] = "El esquema seleccionado no existe."
|
||||
|
||||
# Validar proyecto padre si se especifica
|
||||
if 'proyecto_padre' in data and data['proyecto_padre']:
|
||||
from services.project_service import get_project
|
||||
parent_id = data['proyecto_padre'].replace('PROJ', '')
|
||||
try:
|
||||
parent_id = int(parent_id)
|
||||
parent = get_project(parent_id)
|
||||
if not parent:
|
||||
errors['proyecto_padre'] = "El proyecto padre seleccionado no existe."
|
||||
except ValueError:
|
||||
errors['proyecto_padre'] = "Formato de ID de proyecto padre inválido."
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def validate_document_data(data, file):
|
||||
"""
|
||||
Validar datos de documento.
|
||||
|
||||
Args:
|
||||
data (dict): Datos del documento a validar
|
||||
file: Objeto de archivo (de Flask)
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, errors)
|
||||
- is_valid (bool): True si es válido
|
||||
- errors (dict): Diccionario con errores por campo
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
# Validar campos obligatorios
|
||||
if 'nombre' not in data or not data['nombre']:
|
||||
errors['nombre'] = "El nombre del documento es obligatorio."
|
||||
|
||||
# Validar archivo
|
||||
if not file:
|
||||
errors['file'] = "Debe seleccionar un archivo."
|
||||
else:
|
||||
# Validar extensión
|
||||
filename = secure_filename(file.filename)
|
||||
extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
from services.document_service import get_allowed_filetypes
|
||||
allowed_filetypes = get_allowed_filetypes()
|
||||
|
||||
if extension not in allowed_filetypes:
|
||||
errors['file'] = f"Tipo de archivo no permitido: {extension}"
|
||||
else:
|
||||
# Validar tamaño
|
||||
max_size = allowed_filetypes[extension].get('tamano_maximo', 10485760) # 10MB por defecto
|
||||
|
||||
if not validate_file_size(file, max_size):
|
||||
errors['file'] = f"El archivo excede el tamaño máximo permitido ({max_size // 1048576} MB)."
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def validate_schema_data(data):
|
||||
"""
|
||||
Validar datos de esquema.
|
||||
|
||||
Args:
|
||||
data (dict): Datos del esquema a validar
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, errors)
|
||||
- is_valid (bool): True si es válido
|
||||
- errors (dict): Diccionario con errores por campo
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
# Validar campos obligatorios
|
||||
if 'descripcion' not in data or not data['descripcion']:
|
||||
errors['descripcion'] = "La descripción del esquema es obligatoria."
|
||||
|
||||
if 'documentos' not in data or not data['documentos']:
|
||||
errors['documentos'] = "Se requiere al menos un tipo de documento en el esquema."
|
||||
|
||||
# Validar documentos
|
||||
if 'documentos' in data and data['documentos']:
|
||||
for i, doc in enumerate(data['documentos']):
|
||||
# Validar campos obligatorios de cada documento
|
||||
if 'tipo' not in doc or not doc['tipo']:
|
||||
errors[f'documentos[{i}].tipo'] = "El tipo de documento es obligatorio."
|
||||
|
||||
if 'nombre' not in doc or not doc['nombre']:
|
||||
errors[f'documentos[{i}].nombre'] = "El nombre del documento es obligatorio."
|
||||
|
||||
# Validar tipo de documento
|
||||
if 'tipo' in doc and doc['tipo']:
|
||||
from services.document_service import get_allowed_filetypes
|
||||
allowed_filetypes = get_allowed_filetypes()
|
||||
|
||||
if doc['tipo'] not in allowed_filetypes:
|
||||
errors[f'documentos[{i}].tipo'] = f"Tipo de documento no permitido: {doc['tipo']}"
|
||||
|
||||
# Validar niveles
|
||||
if 'nivel_ver' in doc and not isinstance(doc['nivel_ver'], int):
|
||||
errors[f'documentos[{i}].nivel_ver'] = "El nivel de visualización debe ser un número entero."
|
||||
|
||||
if 'nivel_editar' in doc and not isinstance(doc['nivel_editar'], int):
|
||||
errors[f'documentos[{i}].nivel_editar'] = "El nivel de edición debe ser un número entero."
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def validate_user_data(data, is_new_user=True):
|
||||
"""
|
||||
Validar datos de usuario.
|
||||
|
||||
Args:
|
||||
data (dict): Datos del usuario a validar
|
||||
is_new_user (bool, optional): Indica si es un nuevo usuario.
|
||||
Por defecto es True.
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, errors)
|
||||
- is_valid (bool): True si es válido
|
||||
- errors (dict): Diccionario con errores por campo
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
# Validar campos obligatorios para nuevos usuarios
|
||||
if is_new_user:
|
||||
required_fields = ['nombre', 'username', 'email', 'password']
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
errors[field] = f"El campo {field} es obligatorio."
|
||||
|
||||
# Validar username
|
||||
if 'username' in data and data['username']:
|
||||
if not validate_username(data['username']):
|
||||
errors['username'] = "El nombre de usuario debe contener solo letras, números, guiones y guiones bajos, y tener entre 3 y 20 caracteres."
|
||||
elif is_new_user:
|
||||
# Verificar disponibilidad solo para nuevos usuarios
|
||||
from services.user_service import check_username_availability
|
||||
if not check_username_availability(data['username']):
|
||||
errors['username'] = "El nombre de usuario ya está en uso."
|
||||
|
||||
# Validar email
|
||||
if 'email' in data and data['email']:
|
||||
if not validate_email(data['email']):
|
||||
errors['email'] = "El formato de correo electrónico no es válido."
|
||||
|
||||
# Validar contraseña para nuevos usuarios o si se proporciona
|
||||
if is_new_user or ('password' in data and data['password']):
|
||||
if 'password' in data and data['password']:
|
||||
is_valid, message = validate_password_strength(data['password'])
|
||||
if not is_valid:
|
||||
errors['password'] = message
|
||||
|
||||
# Validar nivel
|
||||
if 'nivel' in data:
|
||||
try:
|
||||
nivel = int(data['nivel'])
|
||||
if nivel < 0 or nivel > 9999:
|
||||
errors['nivel'] = "El nivel debe estar entre 0 y 9999."
|
||||
except (ValueError, TypeError):
|
||||
errors['nivel'] = "El nivel debe ser un número entero."
|
||||
|
||||
# Validar fecha de caducidad
|
||||
if 'fecha_caducidad' in data and data['fecha_caducidad']:
|
||||
if not validate_iso_date(data['fecha_caducidad']):
|
||||
errors['fecha_caducidad'] = "El formato de fecha de caducidad no es válido. Use formato ISO 8601 (YYYY-MM-DDTHH:MM:SSZ)."
|
||||
|
||||
return len(errors) == 0, errors
|
Loading…
Reference in New Issue