Compare commits
3 Commits
1a931474b0
...
9eb2bd5648
Author | SHA1 | Date |
---|---|---|
|
9eb2bd5648 | |
|
b48058e495 | |
|
79194e0f61 |
98
app.py
98
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,19 +11,20 @@ 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])
|
||||
|
@ -36,6 +38,7 @@ def create_app(config_name=None):
|
|||
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
|
||||
|
@ -43,6 +46,7 @@ def create_app(config_name=None):
|
|||
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)
|
||||
|
@ -60,58 +64,96 @@ def create_app(config_name=None):
|
|||
# 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)
|
103
config.py
103
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 base."""
|
||||
|
||||
# Configuración de sesión
|
||||
SESSION_TYPE = 'filesystem'
|
||||
SESSION_FILE_DIR = os.path.join(STORAGE_PATH, 'sessions')
|
||||
# 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)
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
|
||||
SESSION_USE_SIGNER = True
|
||||
|
||||
# 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'
|
||||
# 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
|
||||
# Verificar SECRET_KEY
|
||||
if app.config["SECRET_KEY"] == "clave-secreta-por-defecto":
|
||||
import warnings
|
||||
|
||||
# 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')
|
||||
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,48 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
Simple script to generate a test report.
|
||||
This is a simplified version of run_tests.py focused on generating the JSON report.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def main():
|
||||
"""Run tests and generate JSON report."""
|
||||
# Create directories if they don't exist
|
||||
os.makedirs("test_reports", exist_ok=True)
|
||||
|
||||
# Generate timestamp for current run
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
print(f"Running tests and generating JSON report with timestamp: {timestamp}")
|
||||
|
||||
# Run pytest with the JSON reporter plugin loaded
|
||||
cmd = [
|
||||
"pytest",
|
||||
"-v",
|
||||
"--tb=short",
|
||||
f"--junitxml=test_reports/junit_{timestamp}.xml",
|
||||
"-p",
|
||||
"tests.json_reporter",
|
||||
"tests/",
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=False)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("\nTests completed successfully!")
|
||||
else:
|
||||
print(f"\nTests completed with some failures (exit code: {result.returncode})")
|
||||
|
||||
print(
|
||||
f"JSON report should be available at: test_reports/test_results_{timestamp}.json"
|
||||
)
|
||||
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
|
@ -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,231 @@
|
|||
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(1000) # Minimum permission level
|
||||
def list():
|
||||
"""List 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) # Admin level permission required
|
||||
def create(): # Changed from create_user to create
|
||||
"""Create a new user."""
|
||||
form = UserForm()
|
||||
|
||||
if request.method == "POST" and form.validate_on_submit():
|
||||
try:
|
||||
# Extract user data from form
|
||||
user_data = {
|
||||
"username": form.username.data,
|
||||
"nombre": form.nombre.data,
|
||||
"email": form.email.data,
|
||||
"password": form.password.data,
|
||||
"nivel": form.nivel.data,
|
||||
"empresa": form.empresa.data,
|
||||
"idioma": form.idioma.data,
|
||||
}
|
||||
|
||||
# Create the user
|
||||
result = create_user(user_data) # This is a function call, not a route
|
||||
|
||||
if result["success"]:
|
||||
flash("Usuario creado exitosamente.", "success")
|
||||
return redirect(url_for("users.list"))
|
||||
else:
|
||||
flash(f'Error al crear usuario: {result["error"]}', "danger")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error inesperado: {str(e)}", "danger")
|
||||
|
||||
# If GET request or form validation failed
|
||||
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"))
|
||||
|
||||
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"))
|
||||
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"))
|
||||
|
||||
if username == "admin":
|
||||
flash("No se puede eliminar el usuario administrador.", "danger")
|
||||
return redirect(url_for("users.list"))
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
# 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,83 @@
|
|||
#!/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
|
||||
"-p",
|
||||
"tests.json_reporter", # Load the JSON reporter plugin explicitly
|
||||
]
|
||||
|
||||
# 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_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.
|
||||
"""Obtener todos los esquemas disponibles."""
|
||||
return load_json_file(get_schemas_file_path(), {})
|
||||
|
||||
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_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_id)
|
||||
|
||||
return schemas.get(schema_code)
|
||||
|
||||
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
|
||||
user_id (str): ID del usuario que crea el esquema
|
||||
|
||||
Returns:
|
||||
tuple: (success, message, schema_code)
|
||||
tuple: (éxito, mensaje)
|
||||
"""
|
||||
# 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
|
||||
# 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}."
|
||||
|
||||
# Crear nuevo código
|
||||
schema_code = f"ESQ{last_code + 1:03d}"
|
||||
# Añadir metadatos
|
||||
schema_data["fecha_creacion"] = datetime.now(pytz.UTC).isoformat()
|
||||
schema_data["creado_por"] = user_id
|
||||
|
||||
# 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 esquema
|
||||
schemas[schema_id] = schema_data
|
||||
save_json_file(get_schemas_file_path(), schemas)
|
||||
|
||||
# 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, f"Esquema '{schema_data['descripcion']}' creado correctamente."
|
||||
|
||||
return True, "Esquema creado correctamente.", schema_code
|
||||
|
||||
def update_schema(schema_code, schema_data):
|
||||
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}."
|
||||
if schema_id not in schemas:
|
||||
return False, f"No existe un esquema con el código {schema_id}."
|
||||
|
||||
# Actualizar campos permitidos
|
||||
if 'descripcion' in schema_data:
|
||||
schemas[schema_code]['descripcion'] = schema_data['descripcion']
|
||||
# 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()
|
||||
|
||||
if 'documentos' in schema_data:
|
||||
schemas[schema_code]['documentos'] = schema_data['documentos']
|
||||
# Actualizar esquema
|
||||
schemas[schema_id] = schema_data
|
||||
save_json_file(get_schemas_file_path(), schemas)
|
||||
|
||||
# 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, f"Esquema '{schema_data['descripcion']}' actualizado correctamente."
|
||||
|
||||
return True, "Esquema actualizado correctamente."
|
||||
|
||||
def delete_schema(schema_code):
|
||||
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]
|
||||
schema_desc = schemas[schema_id].get("descripcion", schema_id)
|
||||
del schemas[schema_id]
|
||||
save_json_file(get_schemas_file_path(), schemas)
|
||||
|
||||
# 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, f"Esquema '{schema_desc}' eliminado correctamente."
|
||||
|
||||
return True, "Esquema eliminado correctamente."
|
||||
|
||||
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', [])
|
||||
|
||||
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': [
|
||||
{
|
||||
'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': 'zip',
|
||||
'nombre': 'Archivos Fuente',
|
||||
'nivel_ver': 1000,
|
||||
'nivel_editar': 5000
|
||||
}
|
||||
]
|
||||
}, 'admin')
|
||||
# Si ya hay esquemas, no hacer nada
|
||||
if schemas:
|
||||
return
|
||||
|
||||
current_app.logger.info('Esquema predeterminado creado.')
|
||||
# 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": "dwg",
|
||||
"nombre": "Planos Técnicos",
|
||||
"nivel_ver": 0,
|
||||
"nivel_editar": 5000,
|
||||
},
|
||||
{
|
||||
"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,12 +1,85 @@
|
|||
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.
|
||||
|
@ -26,23 +99,23 @@ def filter_users(filter_params):
|
|||
|
||||
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
|
||||
|
@ -50,6 +123,7 @@ def filter_users(filter_params):
|
|||
|
||||
return filtered_users
|
||||
|
||||
|
||||
def get_user_stats():
|
||||
"""
|
||||
Obtener estadísticas sobre los usuarios.
|
||||
|
@ -61,47 +135,48 @@ def get_user_stats():
|
|||
|
||||
# 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.
|
||||
|
|
|
@ -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;
|
||||
|
@ -175,3 +196,51 @@ 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>
|
||||
|
@ -76,41 +84,62 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Contenido principal -->
|
||||
<div class="container mt-4">
|
||||
<!-- Mensajes flash -->
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="container mt-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show">
|
||||
{% 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"></button>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- JavaScript específico -->
|
||||
{% block scripts %}{% endblock %}
|
||||
<!-- jQuery (For bootstrap plugins that require it) -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
|
||||
<!-- 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,79 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Crear Proyecto - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1>Crear Nuevo Proyecto</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Información del Proyecto</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('projects.create') }}">
|
||||
{{ form.csrf_token if form.csrf_token }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="codigo" class="form-label">Código del Proyecto</label>
|
||||
<input type="text" class="form-control" id="codigo" name="codigo" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="descripcion" class="form-label">Descripción</label>
|
||||
<textarea class="form-control" id="descripcion" name="descripcion" rows="3" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="esquema" class="form-label">Esquema</label>
|
||||
<select class="form-select" id="esquema" name="esquema" required>
|
||||
{% if schemas %}
|
||||
{% for codigo, schema in schemas.items() %}
|
||||
<option value="{{ codigo }}">{{ codigo }} - {{ schema.descripcion }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">No hay esquemas disponibles</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cliente" class="form-label">Cliente</label>
|
||||
<input type="text" class="form-control" id="cliente" name="cliente">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="destinacion" class="form-label">Destinación</label>
|
||||
<input type="text" class="form-control" id="destinacion" name="destinacion">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="proyecto_padre" class="form-label">Proyecto Padre (opcional)</label>
|
||||
<select class="form-select" id="proyecto_padre" name="proyecto_padre">
|
||||
<option value="">Ninguno</option>
|
||||
{% if projects %}
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}">{{ project.codigo }} - {{ project.descripcion }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="estado" class="form-label">Estado</label>
|
||||
<select class="form-select" id="estado" name="estado">
|
||||
<option value="activo" selected>Activo</option>
|
||||
<option value="archivado">Archivado</option>
|
||||
<option value="suspendido">Suspendido</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-primary">Crear Proyecto</button>
|
||||
<a href="{{ url_for('projects.list') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,140 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Crear Esquema - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1>Crear Nuevo Esquema</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Información del Esquema</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('schemas.create') }}" id="schema-form">
|
||||
{{ form.csrf_token if form.csrf_token }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="codigo" class="form-label">Código del Esquema</label>
|
||||
<input type="text" class="form-control" id="codigo" name="codigo" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="descripcion" class="form-label">Descripción</label>
|
||||
<textarea class="form-control" id="descripcion" name="descripcion" rows="3" required></textarea>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h4>Documentos del Esquema</h4>
|
||||
|
||||
<div id="documents-container">
|
||||
<!-- Initial document template -->
|
||||
<div class="document-item card mb-3 p-3">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Tipo de Documento</label>
|
||||
<select class="form-select" name="doc_tipo[]" required>
|
||||
{% if filetypes %}
|
||||
{% for extension, filetype in filetypes.items() %}
|
||||
<option value="{{ extension }}">{{ filetype.descripcion }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">No hay tipos disponibles</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Nombre</label>
|
||||
<input type="text" class="form-control" name="doc_nombre[]" required>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label">Nivel Ver</label>
|
||||
<input type="number" class="form-control" name="doc_nivel_ver[]" value="0" min="0" max="9999">
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label">Nivel Editar</label>
|
||||
<input type="number" class="form-control" name="doc_nivel_editar[]" value="1000" min="0" max="9999">
|
||||
</div>
|
||||
<div class="col-md-1 mb-3 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-danger remove-document" style="display:none;">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<button type="button" class="btn btn-secondary" id="add-document">
|
||||
<i class="fas fa-plus"></i> Agregar Documento
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-primary">Crear Esquema</button>
|
||||
<a href="{{ url_for('schemas.list') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const container = document.getElementById('documents-container');
|
||||
const addButton = document.getElementById('add-document');
|
||||
|
||||
// Initially enable the remove button if more than one document exists
|
||||
updateRemoveButtons();
|
||||
|
||||
addButton.addEventListener('click', function() {
|
||||
const documentTemplate = container.querySelector('.document-item').cloneNode(true);
|
||||
|
||||
// Reset values
|
||||
const inputs = documentTemplate.querySelectorAll('input[type="text"], input[type="number"]');
|
||||
inputs.forEach(input => {
|
||||
if (input.name.includes('nivel_ver')) {
|
||||
input.value = '0';
|
||||
} else if (input.name.includes('nivel_editar')) {
|
||||
input.value = '1000';
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(documentTemplate);
|
||||
updateRemoveButtons();
|
||||
|
||||
// Add event listener to the new remove button
|
||||
documentTemplate.querySelector('.remove-document').addEventListener('click', function() {
|
||||
this.closest('.document-item').remove();
|
||||
updateRemoveButtons();
|
||||
});
|
||||
});
|
||||
|
||||
// Function to update the visibility of remove buttons
|
||||
function updateRemoveButtons() {
|
||||
const items = container.querySelectorAll('.document-item');
|
||||
items.forEach((item, index) => {
|
||||
const removeBtn = item.querySelector('.remove-document');
|
||||
if (items.length > 1) {
|
||||
removeBtn.style.display = 'block';
|
||||
} else {
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listeners to initial remove buttons
|
||||
document.querySelectorAll('.remove-document').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
this.closest('.document-item').remove();
|
||||
updateRemoveButtons();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% 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,73 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Crear Usuario - ARCH{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1>Crear Usuario</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Información del Usuario</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('users.create') }}">
|
||||
{{ form.csrf_token if form.csrf_token }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Nombre de Usuario</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nombre" class="form-label">Nombre Completo</label>
|
||||
<input type="text" class="form-control" id="nombre" name="nombre" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Correo Electrónico</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Contraseña</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password_confirm" class="form-label">Confirmar Contraseña</label>
|
||||
<input type="password" class="form-control" id="password_confirm" name="password_confirm" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nivel" class="form-label">Nivel de Acceso</label>
|
||||
<select class="form-select" id="nivel" name="nivel" required>
|
||||
<option value="1000">Usuario (1000)</option>
|
||||
<option value="5000">Supervisor (5000)</option>
|
||||
<option value="9000">Administrador (9000)</option>
|
||||
<option value="9999">Super Administrador (9999)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="empresa" class="form-label">Empresa</label>
|
||||
<input type="text" class="form-control" id="empresa" name="empresa" value="ARCH">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="idioma" class="form-label">Idioma</label>
|
||||
<select class="form-select" id="idioma" name="idioma">
|
||||
<option value="es" selected>Español</option>
|
||||
<option value="en">Inglés</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-primary">Crear Usuario</button>
|
||||
<a href="{{ url_for('users.list') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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,107 @@
|
|||
{% 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>
|
||||
{% if users and users|length > 0 %}
|
||||
{% for user in users %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center">No hay usuarios registrados.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</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 %}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"max_project_id": 0,
|
||||
"max_document_id": 0
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"ESQ001": {
|
||||
"codigo": "ESQ001",
|
||||
"descripcion": "Proyecto estándar",
|
||||
"fecha_creacion": "2025-03-04T10:25:03.799581+00:00",
|
||||
"creado_por": "admin",
|
||||
"documentos": [
|
||||
{
|
||||
"tipo": "pdf",
|
||||
"nombre": "Manual de Usuario",
|
||||
"nivel_ver": 0,
|
||||
"nivel_editar": 5000
|
||||
},
|
||||
{
|
||||
"tipo": "dwg",
|
||||
"nombre": "Planos Técnicos",
|
||||
"nivel_ver": 0,
|
||||
"nivel_editar": 5000
|
||||
},
|
||||
{
|
||||
"tipo": "zip",
|
||||
"nombre": "Archivos Fuente",
|
||||
"nivel_ver": 1000,
|
||||
"nivel_editar": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"admin": {
|
||||
"nombre": "Administrador",
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"password_hash": "$2b$04$UGmPF4BBNejhS7ZKA58/hu3A8DtXkx7/Fyw.8igRby6eDw/RwkvAq",
|
||||
"nivel": 9999,
|
||||
"idioma": "es",
|
||||
"fecha_caducidad": null,
|
||||
"empresa": "",
|
||||
"estado": "activo",
|
||||
"ultimo_acceso": "2025-03-04T10:25:03.799581+00:00"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
# This file makes the tests directory a Python package
|
||||
# allowing the json_reporter module to be imported
|
|
@ -0,0 +1,205 @@
|
|||
import pytest
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import tempfile
|
||||
from app import create_app
|
||||
|
||||
|
||||
def init_test_environment(storage_path):
|
||||
"""Initialize the test environment with required directories."""
|
||||
if os.path.exists(storage_path):
|
||||
try:
|
||||
shutil.rmtree(storage_path)
|
||||
except PermissionError:
|
||||
# Try to handle Windows file locking issues
|
||||
print(f"Warning: Could not remove {storage_path} directory, retrying...")
|
||||
time.sleep(1)
|
||||
try:
|
||||
shutil.rmtree(storage_path)
|
||||
except Exception as e:
|
||||
print(f"Failed to remove directory: {e}")
|
||||
|
||||
# Create main storage directory
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
|
||||
# Create required subdirectories
|
||||
for dir_name in ["users", "schemas", "filetypes", "projects", "logs"]:
|
||||
os.makedirs(os.path.join(storage_path, dir_name), exist_ok=True)
|
||||
|
||||
# Initialize test data
|
||||
initialize_test_data(storage_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
# Use a unique temporary directory for each test session
|
||||
storage_path = tempfile.mkdtemp(prefix="arch_test_")
|
||||
|
||||
# Set up the test environment
|
||||
init_test_environment(storage_path)
|
||||
|
||||
# Create app with test configuration
|
||||
app = create_app("testing")
|
||||
|
||||
# Update the app's config with our test settings
|
||||
app.config.update(
|
||||
{
|
||||
"TESTING": True,
|
||||
"STORAGE_PATH": storage_path,
|
||||
"WTF_CSRF_ENABLED": False, # Disable CSRF protection for testing
|
||||
"SERVER_NAME": "localhost.localdomain", # Set server name to ensure correct URLs
|
||||
}
|
||||
)
|
||||
|
||||
# Create app context
|
||||
with app.app_context():
|
||||
yield app
|
||||
|
||||
# Clean up after tests - with retry mechanism for Windows
|
||||
max_retries = 3
|
||||
retry_delay = 1
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if os.path.exists(storage_path):
|
||||
shutil.rmtree(storage_path)
|
||||
break
|
||||
except PermissionError:
|
||||
if attempt < max_retries - 1:
|
||||
print(
|
||||
f"Cleanup attempt {attempt+1} failed, retrying in {retry_delay}s..."
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
print(f"Warning: Could not clean up {storage_path}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create a test client for the application."""
|
||||
with app.test_client() as client:
|
||||
# Enable cookies and sessions for the test client
|
||||
client.testing = True
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth(client):
|
||||
"""Helper for authentication tests."""
|
||||
|
||||
class AuthActions:
|
||||
def login(self, username="admin", password="admin123"):
|
||||
# Use session for better cookie handling
|
||||
with client.session_transaction() as session:
|
||||
# Pre-clear any existing session data
|
||||
session.clear()
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": username, "password": password},
|
||||
follow_redirects=True,
|
||||
)
|
||||
return response
|
||||
|
||||
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."""
|
||||
response = auth.login()
|
||||
|
||||
# Verify login was successful
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check for expected content in response to confirm logged in state
|
||||
assert (
|
||||
b"Cerrar" in response.data
|
||||
or b"Logout" in response.data
|
||||
or b"Panel" in response.data
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def initialize_test_data(storage_path):
|
||||
"""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(f"{storage_path}/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(f"{storage_path}/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(f"{storage_path}/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(f"{storage_path}/indices.json", "w", encoding="utf-8") as f:
|
||||
json.dump(indices_data, f, ensure_ascii=False, indent=2)
|
|
@ -0,0 +1,15 @@
|
|||
"""Helper functions for tests."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def ensure_clean_session(client):
|
||||
"""Ensure we have a clean session before each test."""
|
||||
# Get the root URL to reset application state
|
||||
with client.session_transaction() as session:
|
||||
session.clear()
|
||||
|
||||
# Make a request to reset client state
|
||||
client.get("/")
|
||||
|
||||
return client
|
|
@ -0,0 +1,108 @@
|
|||
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}")
|
||||
|
||||
|
||||
# Register the plugin
|
||||
@pytest.hookimpl(trylast=True)
|
||||
def pytest_configure(config):
|
||||
"""Register the JSON reporter plugin."""
|
||||
config._json_reporter = JSONReporter(config)
|
||||
config.pluginmanager.register(config._json_reporter, "json_reporter")
|
|
@ -0,0 +1,132 @@
|
|||
import pytest
|
||||
import json
|
||||
import os
|
||||
from .helpers import ensure_clean_session
|
||||
|
||||
|
||||
class TestAdminFunctions:
|
||||
"""Test administrative functions."""
|
||||
|
||||
def test_admin_dashboard(self, logged_in_client):
|
||||
"""Test accessing admin dashboard."""
|
||||
# Ensure we have a clean, authenticated session
|
||||
logged_in_client = ensure_clean_session(logged_in_client)
|
||||
|
||||
# Access the admin dashboard
|
||||
response = logged_in_client.get("/admin/dashboard")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Administraci" in response.data or b"Dashboard" in response.data
|
||||
|
||||
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,72 @@
|
|||
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,156 @@
|
|||
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,179 @@
|
|||
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,110 @@
|
|||
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,98 @@
|
|||
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,147 @@
|
|||
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,6 +6,19 @@ 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):
|
||||
"""
|
||||
|
@ -17,37 +30,59 @@ def ensure_dir_exists(directory):
|
|||
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)
|
||||
"""
|
||||
# Asegurar que el directorio existe
|
||||
directory = os.path.dirname(file_path)
|
||||
ensure_dir_exists(directory)
|
||||
file_path (str): Path to the JSON file
|
||||
data (dict or list): Data to save
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
|
@ -59,19 +94,19 @@ def get_next_id(id_type):
|
|||
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}")
|
||||
|
||||
|
@ -79,6 +114,7 @@ def get_next_id(id_type):
|
|||
|
||||
return new_id
|
||||
|
||||
|
||||
def format_project_directory_name(project_id, project_name):
|
||||
"""
|
||||
Formatear nombre de directorio para un proyecto.
|
||||
|
@ -92,11 +128,12 @@ def format_project_directory_name(project_id, project_name):
|
|||
"""
|
||||
# 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.
|
||||
|
@ -110,11 +147,12 @@ def format_document_directory_name(document_id, document_name):
|
|||
"""
|
||||
# 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.
|
||||
|
@ -129,15 +167,16 @@ def format_version_filename(version, document_name, extension):
|
|||
"""
|
||||
# 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.
|
||||
|
@ -150,15 +189,16 @@ def create_zip_archive(source_dir, files_to_include, output_path):
|
|||
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.
|
||||
|
@ -178,6 +218,7 @@ def get_directory_size(directory):
|
|||
|
||||
return total_size
|
||||
|
||||
|
||||
def get_file_info(file_path):
|
||||
"""
|
||||
Obtener información básica sobre un archivo.
|
||||
|
@ -191,13 +232,14 @@ def get_file_info(file_path):
|
|||
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.
|
||||
|
@ -207,3 +249,45 @@ def delete_directory_with_content(directory):
|
|||
"""
|
||||
if os.path.exists(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,12 +1,23 @@
|
|||
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.
|
||||
|
@ -31,6 +42,7 @@ def check_file_type(file_stream, allowed_mime_types):
|
|||
|
||||
return file_type in allowed_mime_types
|
||||
|
||||
|
||||
def calculate_checksum(file_path):
|
||||
"""
|
||||
Calcular el hash SHA-256 de un archivo.
|
||||
|
@ -50,6 +62,7 @@ def calculate_checksum(file_path):
|
|||
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def sanitize_filename(filename):
|
||||
"""
|
||||
Sanitizar nombre de archivo.
|
||||
|
@ -64,13 +77,14 @@ def sanitize_filename(filename):
|
|||
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.
|
||||
|
@ -91,6 +105,7 @@ def generate_unique_filename(base_dir, filename_pattern):
|
|||
|
||||
return filename
|
||||
|
||||
|
||||
def permission_required(min_level):
|
||||
"""
|
||||
Decorador para verificar nivel de permisos.
|
||||
|
@ -101,6 +116,7 @@ def permission_required(min_level):
|
|||
Returns:
|
||||
function: Decorador configurado
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
|
@ -111,12 +127,17 @@ def permission_required(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