Version base funcionando pre-test

This commit is contained in:
Miguel 2025-03-03 21:50:11 +01:00
parent 1a931474b0
commit 79194e0f61
45 changed files with 5209 additions and 456 deletions

0
.blackboxrules Normal file
View File

128
app.py
View File

@ -1,5 +1,6 @@
import os
from flask import Flask
from datetime import datetime
from flask import Flask, render_template
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
from flask_session import Session
@ -10,108 +11,149 @@ from config import config
# Inicialización de extensiones
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Por favor inicie sesión para acceder a esta página.'
login_manager.login_message_category = 'warning'
login_manager.login_view = "auth.login"
login_manager.login_message = "Por favor inicie sesión para acceder a esta página."
login_manager.login_message_category = "warning"
bcrypt = Bcrypt()
csrf = CSRFProtect()
session = Session()
cache = Cache()
def create_app(config_name=None):
"""Fábrica de aplicación Flask."""
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'default')
config_name = os.environ.get("FLASK_ENV", "default")
app = Flask(__name__)
app.config.from_object(config[config_name])
config[config_name].init_app(app)
# Inicializar extensiones
login_manager.init_app(app)
bcrypt.init_app(app)
csrf.init_app(app)
session.init_app(app)
cache.init_app(app)
# Importar y registrar blueprints
from routes.main_routes import main_bp
from routes.auth_routes import auth_bp
from routes.user_routes import users_bp
from routes.project_routes import projects_bp
from routes.document_routes import documents_bp
from routes.schema_routes import schemas_bp
from routes.admin_routes import admin_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(users_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(documents_bp)
app.register_blueprint(schemas_bp)
app.register_blueprint(admin_bp)
# Configurar cargador de usuario para Flask-Login
from services.user_service import get_user_by_id
@login_manager.user_loader
def load_user(user_id):
return get_user_by_id(user_id)
# Registrar handlers para errores
register_error_handlers(app)
# Context processor para variables globales en templates
@app.context_processor
def inject_globals():
return {"now": datetime.now()}
# Asegurar que existen los directorios de almacenamiento
initialize_storage_structure(app)
# Configurar sistema de logging
from utils.logger import setup_logger
setup_logger(app)
# Inicializar datos por defecto
with app.app_context():
# Inicializar usuario administrador si no existe
from services.auth_service import initialize_admin_user
initialize_admin_user()
# Inicializar esquemas predeterminados si no existen
from services.schema_service import initialize_default_schemas
initialize_default_schemas()
return app
def register_error_handlers(app):
"""Registrar manejadores de errores para la aplicación."""
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html',
error_code=404,
error_message="Página no encontrada"), 404
return (
render_template(
"error.html", error_code=404, error_message="Página no encontrada"
),
404,
)
@app.errorhandler(403)
def forbidden(e):
return render_template('error.html',
error_code=403,
error_message="Acceso denegado"), 403
return (
render_template(
"error.html", error_code=403, error_message="Acceso denegado"
),
403,
)
@app.errorhandler(500)
def internal_error(e):
return render_template('error.html',
error_code=500,
error_message="Error interno del servidor"), 500
return (
render_template(
"error.html", error_code=500, error_message="Error interno del servidor"
),
500,
)
def initialize_storage_structure(app):
"""Crear estructura de almacenamiento si no existe."""
storage_path = app.config['STORAGE_PATH']
storage_path = app.config["STORAGE_PATH"]
# Crear directorios principales
os.makedirs(os.path.join(storage_path, 'logs'), exist_ok=True)
os.makedirs(os.path.join(storage_path, 'schemas'), exist_ok=True)
os.makedirs(os.path.join(storage_path, 'users'), exist_ok=True)
os.makedirs(os.path.join(storage_path, 'filetypes'), exist_ok=True)
os.makedirs(os.path.join(storage_path, 'projects'), exist_ok=True)
os.makedirs(os.path.join(storage_path, "logs"), exist_ok=True)
os.makedirs(os.path.join(storage_path, "schemas"), exist_ok=True)
os.makedirs(os.path.join(storage_path, "users"), exist_ok=True)
os.makedirs(os.path.join(storage_path, "filetypes"), exist_ok=True)
os.makedirs(os.path.join(storage_path, "projects"), exist_ok=True)
os.makedirs(os.path.join(storage_path, "exports"), exist_ok=True)
# Inicializar archivos JSON si no existen
init_json_file(os.path.join(storage_path, 'schemas', 'schema.json'), {})
init_json_file(os.path.join(storage_path, 'users', 'users.json'), {})
init_json_file(os.path.join(storage_path, 'filetypes', 'filetypes.json'), {})
init_json_file(os.path.join(storage_path, 'indices.json'),
{"max_project_id": 0, "max_document_id": 0})
init_json_file(os.path.join(storage_path, "schemas", "schema.json"), {})
init_json_file(os.path.join(storage_path, "users", "users.json"), {})
init_json_file(os.path.join(storage_path, "filetypes", "filetypes.json"), {})
init_json_file(
os.path.join(storage_path, "indices.json"),
{"max_project_id": 0, "max_document_id": 0},
)
def init_json_file(file_path, default_content):
"""Inicializar un archivo JSON si no existe."""
if not os.path.exists(file_path):
import json
with open(file_path, 'w', encoding='utf-8') as f:
with open(file_path, "w", encoding="utf-8") as f:
json.dump(default_content, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
from flask import render_template
if __name__ == "__main__":
app = create_app()
app.run(debug=True)
app.run(debug=True)

123
config.py
View File

@ -5,82 +5,91 @@ from dotenv import load_dotenv
# Cargar variables de entorno desde archivo .env
load_dotenv()
# Directorio base donde se ejecuta la aplicación
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
"""Configuración base para la aplicación."""
# Configuración de Flask
SECRET_KEY = os.environ.get('SECRET_KEY') or 'clave-secreta-predeterminada'
STORAGE_PATH = os.environ.get('STORAGE_PATH') or 'storage'
# Configuración de sesión
SESSION_TYPE = 'filesystem'
SESSION_FILE_DIR = os.path.join(STORAGE_PATH, 'sessions')
"""Configuración base."""
# Seguridad
SECRET_KEY = os.environ.get("SECRET_KEY") or "clave-secreta-por-defecto"
BCRYPT_LOG_ROUNDS = 12
WTF_CSRF_ENABLED = True
# Sesión
SESSION_TYPE = "filesystem"
SESSION_FILE_DIR = os.path.join(basedir, "flask_session")
SESSION_PERMANENT = True
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
# Configuración de carga de archivos
MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB límite global
UPLOAD_FOLDER = os.path.join(STORAGE_PATH, 'projects')
# Configuración de caché
CACHE_TYPE = 'SimpleCache'
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
SESSION_USE_SIGNER = True
# Caché
CACHE_TYPE = "SimpleCache"
CACHE_DEFAULT_TIMEOUT = 300
# Configuración de logging
LOG_DIR = os.path.join(STORAGE_PATH, 'logs')
# Almacenamiento
STORAGE_PATH = os.environ.get("STORAGE_PATH") or os.path.join(basedir, "storage")
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
# Configuración de la aplicación
APP_NAME = "Arch"
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL") or "admin@example.com"
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") or "admin"
@staticmethod
def init_app(app):
"""Inicialización adicional de la aplicación."""
# Asegurar que existen directorios necesarios
os.makedirs(Config.SESSION_FILE_DIR, exist_ok=True)
os.makedirs(Config.LOG_DIR, exist_ok=True)
class DevelopmentConfig(Config):
"""Configuración para entorno de desarrollo."""
"""Configuración para desarrollo."""
DEBUG = True
TESTING = False
class TestingConfig(Config):
"""Configuración para entorno de pruebas."""
"""Configuración para pruebas."""
TESTING = True
STORAGE_PATH = 'test_storage'
WTF_CSRF_ENABLED = False # Deshabilitar CSRF para pruebas
DEBUG = True
WTF_CSRF_ENABLED = False
BCRYPT_LOG_ROUNDS = 4 # más rápido para pruebas
STORAGE_PATH = os.path.join(basedir, "test_storage")
class ProductionConfig(Config):
"""Configuración para entorno de producción."""
"""Configuración para producción."""
DEBUG = False
TESTING = False
@classmethod
def init_app(cls, app):
# Override secret key in production
SECRET_KEY = os.environ.get("SECRET_KEY")
# Opciones de seguridad más estrictas
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True
@staticmethod
def init_app(app):
Config.init_app(app)
# Configuración adicional para producción
import logging
from logging.handlers import RotatingFileHandler
# Configurar handler para errores
file_handler = RotatingFileHandler(
os.path.join(cls.LOG_DIR, 'arch.log'),
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=10
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('ARCH inicializado')
# Verificar SECRET_KEY
if app.config["SECRET_KEY"] == "clave-secreta-por-defecto":
import warnings
warnings.warn("SECRET_KEY no configurada para producción!")
# Diccionario con las configuraciones disponibles
# Mapeo de configuraciones
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
"development": DevelopmentConfig,
"testing": TestingConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}

View File

@ -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.
"""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.
"""

View File

@ -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})

22
routes/main_routes.py Normal file
View File

@ -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")

View File

@ -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)

View File

@ -0,0 +1,259 @@
from flask import (
Blueprint,
render_template,
redirect,
url_for,
flash,
request,
jsonify,
current_app,
)
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import (
StringField,
PasswordField,
SelectField,
IntegerField,
EmailField,
BooleanField,
SubmitField,
)
from wtforms.validators import DataRequired, Email, Length, Optional, EqualTo
from services.auth_service import (
get_all_users,
get_user_by_username,
create_user,
update_user,
delete_user,
)
from services.user_service import (
filter_users,
get_user_stats,
check_username_availability,
)
from utils.validators import validate_user_data
from utils.security import permission_required
from utils.logger import log_user_management
# Definir Blueprint
users_bp = Blueprint("users", __name__, url_prefix="/users")
# Formularios
class UserForm(FlaskForm):
"""Formulario para crear/editar usuarios."""
nombre = StringField("Nombre completo", validators=[DataRequired(), Length(1, 100)])
username = StringField(
"Nombre de usuario", validators=[DataRequired(), Length(3, 20)]
)
email = EmailField("Email", validators=[DataRequired(), Email()])
password = PasswordField("Contraseña", validators=[Optional(), Length(8, 64)])
password_confirm = PasswordField(
"Confirmar contraseña",
validators=[
Optional(),
EqualTo("password", message="Las contraseñas deben coincidir"),
],
)
nivel = IntegerField("Nivel de acceso", validators=[DataRequired()])
idioma = SelectField("Idioma", choices=[("es", "Español"), ("en", "Inglés")])
empresa = StringField("Empresa", validators=[Optional(), Length(0, 100)])
estado = SelectField(
"Estado", choices=[("activo", "Activo"), ("inactivo", "Inactivo")]
)
fecha_caducidad = StringField(
"Fecha de caducidad (YYYY-MM-DD)", validators=[Optional()]
)
submit = SubmitField("Guardar")
class UserFilterForm(FlaskForm):
"""Formulario para filtrar usuarios."""
empresa = StringField("Empresa", validators=[Optional()])
estado = SelectField(
"Estado",
choices=[("", "Todos"), ("activo", "Activo"), ("inactivo", "Inactivo")],
validators=[Optional()],
)
nivel_min = IntegerField("Nivel mínimo", validators=[Optional()])
nivel_max = IntegerField("Nivel máximo", validators=[Optional()])
submit = SubmitField("Filtrar")
# Rutas
@users_bp.route("/")
@login_required
@permission_required(5000) # Assuming 5000+ is admin level permission
def list():
"""List all users."""
# Import here to avoid circular imports
from services.user_service import get_all_users
users = get_all_users()
return render_template("users/list.html", users=users)
@users_bp.route("/create", methods=["GET", "POST"])
@login_required
@permission_required(9000) # Solo administradores
def create():
"""Crear nuevo usuario."""
form = UserForm()
if form.validate_on_submit():
# Validar datos
data = {
"nombre": form.nombre.data,
"username": form.username.data,
"email": form.email.data,
"password": form.password.data,
"nivel": form.nivel.data,
"idioma": form.idioma.data,
"empresa": form.empresa.data,
"estado": form.estado.data,
"fecha_caducidad": (
form.fecha_caducidad.data if form.fecha_caducidad.data else None
),
}
is_valid, errors = validate_user_data(data)
if not is_valid:
for field, error in errors.items():
flash(f"Error en {field}: {error}", "danger")
return render_template("users/create.html", form=form)
# Crear usuario
success, message = create_user(
username=data["username"],
nombre=data["nombre"],
email=data["email"],
password=data["password"],
nivel=data["nivel"],
idioma=data["idioma"],
fecha_caducidad=data["fecha_caducidad"],
empresa=data["empresa"],
estado=data["estado"],
)
if success:
flash(message, "success")
# Registrar actividad
log_user_management(
admin_id=current_user.id,
target_user_id=data["username"],
action="create",
)
return redirect(url_for("users.list_users"))
else:
flash(message, "danger")
return render_template("users/create.html", form=form)
@users_bp.route("/edit/<username>", methods=["GET", "POST"])
@login_required
@permission_required(9000) # Solo administradores
def edit(username):
"""Editar usuario existente."""
user = get_user_by_username(username)
if not user:
flash(f"Usuario {username} no encontrado.", "danger")
return redirect(url_for("users.list_users"))
form = UserForm(obj=user)
if form.validate_on_submit():
# Validar datos
data = {
"nombre": form.nombre.data,
"email": form.email.data,
"password": form.password.data,
"nivel": form.nivel.data,
"idioma": form.idioma.data,
"empresa": form.empresa.data,
"estado": form.estado.data,
"fecha_caducidad": (
form.fecha_caducidad.data if form.fecha_caducidad.data else None
),
}
is_valid, errors = validate_user_data(data, is_new_user=False)
if not is_valid:
for field, error in errors.items():
flash(f"Error en {field}: {error}", "danger")
return render_template("users/edit.html", form=form, username=username)
# Actualizar usuario
success, message = update_user(username, data)
if success:
flash(message, "success")
# Registrar actividad
log_user_management(
admin_id=current_user.id,
target_user_id=username,
action="update",
details={"fields_updated": list(data.keys())},
)
return redirect(url_for("users.list_users"))
else:
flash(message, "danger")
return render_template("users/edit.html", form=form, username=username)
@users_bp.route("/delete/<username>", methods=["POST"])
@login_required
@permission_required(9000) # Solo administradores
def delete(username):
"""Eliminar usuario."""
if username == current_user.id:
flash("No puede eliminar su propio usuario.", "danger")
return redirect(url_for("users.list_users"))
if username == "admin":
flash("No se puede eliminar el usuario administrador.", "danger")
return redirect(url_for("users.list_users"))
success, message = delete_user(username)
if success:
flash(message, "success")
# Registrar actividad
log_user_management(
admin_id=current_user.id, target_user_id=username, action="delete"
)
else:
flash(message, "danger")
return redirect(url_for("users.list_users"))
# API para verificar disponibilidad de nombre de usuario
@users_bp.route("/api/check_username", methods=["POST"])
@login_required
@permission_required(9000) # Solo administradores
def api_check_username():
"""Verificar disponibilidad de nombre de usuario."""
data = request.get_json()
if not data or "username" not in data:
return jsonify({"error": "Se requiere nombre de usuario"}), 400
username = data["username"]
available = check_username_availability(username)
return jsonify({"available": available})

73
run_tests.py Normal file
View File

@ -0,0 +1,73 @@
#!/usr/bin/env python
"""
Script to run the test suite for ARCH application.
Executes all tests and generates JSON and HTML reports.
"""
import pytest
import os
import sys
import argparse
import json
import shutil
from datetime import datetime
def run_tests(args):
"""Run pytest with specified arguments and generate reports."""
# Create test reports directory if needed
os.makedirs('test_reports', exist_ok=True)
# Generate timestamp for report files
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Base pytest arguments
pytest_args = [
'-v', # Verbose output
'--no-header', # No header in the output
'--tb=short', # Short traceback
f'--junitxml=test_reports/junit_{timestamp}.xml', # JUnit XML report
'--cov=app', # Coverage for app module
'--cov=routes', # Coverage for routes
'--cov=services', # Coverage for services
'--cov=utils', # Coverage for utils
'--cov-report=html:test_reports/coverage', # HTML coverage report
]
# Add test files/directories
if args.tests:
pytest_args.extend(args.tests)
else:
pytest_args.append('tests/')
# Execute tests
print(f"\n{'='*80}")
print(f"Running tests at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*80}")
result = pytest.main(pytest_args)
# Generate JSON report
print(f"\n{'='*80}")
print(f"Test report available at: test_reports/test_results_{timestamp}.json")
print(f"Coverage report available at: test_reports/coverage/index.html")
print(f"{'='*80}\n")
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run tests for ARCH application")
parser.add_argument('tests', nargs='*', help='Specific test files or directories to run')
parser.add_argument('--clean', action='store_true', help='Clean test storage and results before running')
args = parser.parse_args()
if args.clean:
# Clean temporary test storage
if os.path.exists('test_storage'):
shutil.rmtree('test_storage')
# Clean previous test reports
if os.path.exists('test_reports'):
shutil.rmtree('test_reports')
print("Cleaned test storage and reports.")
# Run tests and exit with the pytest result code
sys.exit(run_tests(args))

View File

@ -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.
"""

View File

@ -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

View File

@ -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.")

View File

@ -1,212 +1,176 @@
import os
import json
from datetime import datetime
import pytz
from flask import current_app
from utils.file_utils import load_json_file, save_json_file
def get_all_schemas():
"""
Obtener todos los esquemas disponibles.
Returns:
dict: Diccionario de esquemas
"""
storage_path = current_app.config['STORAGE_PATH']
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
return load_json_file(schema_file, {})
def get_schema(schema_code):
def get_schemas_file_path():
"""Obtener ruta al archivo de esquemas."""
storage_path = current_app.config["STORAGE_PATH"]
return os.path.join(storage_path, "schemas", "schema.json")
def get_all_schemas():
"""Obtener todos los esquemas disponibles."""
return load_json_file(get_schemas_file_path(), {})
def get_schema_by_id(schema_id):
"""
Obtener un esquema específico.
Obtener un esquema por su ID.
Args:
schema_code (str): Código del esquema
schema_id (str): ID del esquema a buscar
Returns:
dict: Datos del esquema o None si no existe
"""
schemas = get_all_schemas()
return schemas.get(schema_code)
return schemas.get(schema_id)
def create_schema(schema_data, creator_username):
def create_schema(schema_data, user_id):
"""
Crear un nuevo esquema.
Args:
schema_data (dict): Datos del esquema
creator_username (str): Usuario que crea el esquema
Returns:
tuple: (success, message, schema_code)
"""
# Validar datos obligatorios
if 'descripcion' not in schema_data or not schema_data['descripcion']:
return False, "La descripción del esquema es obligatoria.", None
if 'documentos' not in schema_data or not schema_data['documentos']:
return False, "Se requiere al menos un tipo de documento en el esquema.", None
# Generar código de esquema
schemas = get_all_schemas()
# Encontrar el último código numérico
last_code = 0
for code in schemas.keys():
if code.startswith('ESQ'):
try:
num = int(code[3:])
if num > last_code:
last_code = num
except ValueError:
pass
# Crear nuevo código
schema_code = f"ESQ{last_code + 1:03d}"
# Crear esquema
schemas[schema_code] = {
'codigo': schema_code,
'descripcion': schema_data['descripcion'],
'fecha_creacion': datetime.now(pytz.UTC).isoformat(),
'creado_por': creator_username,
'documentos': schema_data['documentos']
}
# Guardar esquemas
storage_path = current_app.config['STORAGE_PATH']
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
save_json_file(schema_file, schemas)
return True, "Esquema creado correctamente.", schema_code
user_id (str): ID del usuario que crea el esquema
def update_schema(schema_code, schema_data):
Returns:
tuple: (éxito, mensaje)
"""
schemas = get_all_schemas()
# Verificar si ya existe un esquema con ese código
schema_id = schema_data["codigo"]
if schema_id in schemas:
return False, f"Ya existe un esquema con el código {schema_id}."
# Añadir metadatos
schema_data["fecha_creacion"] = datetime.now(pytz.UTC).isoformat()
schema_data["creado_por"] = user_id
# Guardar esquema
schemas[schema_id] = schema_data
save_json_file(get_schemas_file_path(), schemas)
return True, f"Esquema '{schema_data['descripcion']}' creado correctamente."
def update_schema(schema_id, schema_data):
"""
Actualizar un esquema existente.
Args:
schema_code (str): Código del esquema
schema_data (dict): Datos actualizados
schema_id (str): ID del esquema a actualizar
schema_data (dict): Nuevos datos del esquema
Returns:
tuple: (success, message)
tuple: (éxito, mensaje)
"""
schemas = get_all_schemas()
# Verificar si existe el esquema
if schema_code not in schemas:
return False, f"No se encontró el esquema con código {schema_code}."
# Actualizar campos permitidos
if 'descripcion' in schema_data:
schemas[schema_code]['descripcion'] = schema_data['descripcion']
if 'documentos' in schema_data:
schemas[schema_code]['documentos'] = schema_data['documentos']
# Guardar esquemas
storage_path = current_app.config['STORAGE_PATH']
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
save_json_file(schema_file, schemas)
return True, "Esquema actualizado correctamente."
def delete_schema(schema_code):
# Verificar si existe el esquema
if schema_id not in schemas:
return False, f"No existe un esquema con el código {schema_id}."
# Preservar metadatos originales
schema_data["fecha_creacion"] = schemas[schema_id].get("fecha_creacion")
schema_data["creado_por"] = schemas[schema_id].get("creado_por")
schema_data["ultima_modificacion"] = datetime.now(pytz.UTC).isoformat()
# Actualizar esquema
schemas[schema_id] = schema_data
save_json_file(get_schemas_file_path(), schemas)
return True, f"Esquema '{schema_data['descripcion']}' actualizado correctamente."
def delete_schema(schema_id):
"""
Eliminar un esquema.
Args:
schema_code (str): Código del esquema
schema_id (str): ID del esquema a eliminar
Returns:
tuple: (success, message)
tuple: (éxito, mensaje)
"""
schemas = get_all_schemas()
# Verificar si existe el esquema
if schema_code not in schemas:
return False, f"No se encontró el esquema con código {schema_code}."
if schema_id not in schemas:
return False, f"No existe un esquema con el código {schema_id}."
# Verificar si el esquema está en uso
# [Implementar verificación si el esquema está asociado a proyectos]
# Eliminar esquema
del schemas[schema_code]
# Guardar esquemas
storage_path = current_app.config['STORAGE_PATH']
schema_file = os.path.join(storage_path, 'schemas', 'schema.json')
save_json_file(schema_file, schemas)
return True, "Esquema eliminado correctamente."
schema_desc = schemas[schema_id].get("descripcion", schema_id)
del schemas[schema_id]
save_json_file(get_schemas_file_path(), schemas)
def get_schema_document_types(schema_code):
"""
Obtener los tipos de documento definidos en un esquema.
Args:
schema_code (str): Código del esquema
Returns:
list: Lista de tipos de documento
"""
schema = get_schema(schema_code)
if not schema:
return []
return schema.get('documentos', [])
return True, f"Esquema '{schema_desc}' eliminado correctamente."
def validate_document_for_schema(schema_code, document_type):
"""
Validar si un tipo de documento está permitido en un esquema.
Args:
schema_code (str): Código del esquema
document_type (str): Tipo de documento
Returns:
tuple: (is_valid, required_level) - Validez y nivel requerido
"""
schema = get_schema(schema_code)
if not schema:
return False, None
for doc in schema.get('documentos', []):
if doc.get('tipo') == document_type:
return True, doc.get('nivel_editar', 0)
return False, None
def initialize_default_schemas():
"""
Inicializar esquemas predeterminados si no existen.
"""
"""Inicializar esquemas predeterminados si no existen."""
schemas = get_all_schemas()
if not schemas:
# Crear esquema estándar
create_schema({
'descripcion': 'Proyecto estándar',
'documentos': [
# Si ya hay esquemas, no hacer nada
if schemas:
return
# Esquema predeterminado para proyecto estándar
default_schema = {
"ESQ001": {
"codigo": "ESQ001",
"descripcion": "Proyecto estándar",
"fecha_creacion": datetime.now(pytz.UTC).isoformat(),
"creado_por": "admin",
"documentos": [
{
'tipo': 'pdf',
'nombre': 'Manual de Usuario',
'nivel_ver': 0,
'nivel_editar': 5000
"tipo": "pdf",
"nombre": "Manual de Usuario",
"nivel_ver": 0,
"nivel_editar": 5000,
},
{
'tipo': 'txt',
'nombre': 'Notas del Proyecto',
'nivel_ver': 0,
'nivel_editar': 1000
"tipo": "dwg",
"nombre": "Planos Técnicos",
"nivel_ver": 0,
"nivel_editar": 5000,
},
{
'tipo': 'zip',
'nombre': 'Archivos Fuente',
'nivel_ver': 1000,
'nivel_editar': 5000
}
]
}, 'admin')
current_app.logger.info('Esquema predeterminado creado.')
"tipo": "zip",
"nombre": "Archivos Fuente",
"nivel_ver": 1000,
"nivel_editar": 5000,
},
],
}
}
save_json_file(get_schemas_file_path(), default_schema)
current_app.logger.info("Esquemas predeterminados inicializados.")
def get_schema_document_types(schema_id):
"""
Obtener los tipos de documentos definidos en un esquema.
Args:
schema_id (str): ID del esquema
Returns:
list: Lista de tipos de documentos en el esquema, o lista vacía si no existe
"""
schema = get_schema_by_id(schema_id)
if not schema or "documentos" not in schema:
return []
return schema["documentos"]

View File

@ -1,116 +1,191 @@
import os
import json
import uuid
from datetime import datetime
from flask import current_app
from flask_login import UserMixin
from app import bcrypt
from utils.file_utils import load_json_file, save_json_file
from services.auth_service import (
get_user_by_username, get_user_by_id, get_all_users,
create_user, update_user, delete_user
get_user_by_username,
get_user_by_id,
get_all_users,
create_user,
update_user,
delete_user,
)
class User(UserMixin):
"""Clase de usuario para Flask-Login."""
def __init__(self, user_id, user_data):
self.id = user_id
self.username = user_data.get("username", "")
self.nombre = user_data.get("nombre", "")
self.email = user_data.get("email", "")
self.nivel = user_data.get("nivel", 0)
self.idioma = user_data.get("idioma", "es")
self.fecha_caducidad = user_data.get("fecha_caducidad")
self.empresa = user_data.get("empresa", "")
self.estado = user_data.get("estado", "activo")
self.ultimo_acceso = user_data.get("ultimo_acceso")
def has_permission(self, required_level):
"""Verificar si el usuario tiene el nivel de permiso requerido."""
return self.nivel >= required_level and self.estado == "activo"
def to_dict(self):
"""Convertir el usuario a un diccionario para almacenamiento."""
return {
"username": self.username,
"nombre": self.nombre,
"email": self.email,
"nivel": self.nivel,
"idioma": self.idioma,
"fecha_caducidad": self.fecha_caducidad,
"empresa": self.empresa,
"estado": self.estado,
"ultimo_acceso": self.ultimo_acceso,
}
def get_users_file_path():
"""Obtener la ruta al archivo de usuarios."""
storage_path = current_app.config["STORAGE_PATH"]
return os.path.join(storage_path, "users", "users.json")
def get_all_users():
"""Obtener todos los usuarios del sistema."""
users_data = load_json_file(get_users_file_path(), {})
return users_data
def get_user_by_id(user_id):
"""Obtener un usuario por su ID."""
if not user_id:
return None
users_data = load_json_file(get_users_file_path(), {})
if user_id in users_data:
return User(user_id, users_data[user_id])
return None
# Este archivo importa funcionalidades de auth_service.py para mantener
# consistencia en la estructura del proyecto, y agrega funcionalidades específicas
# de gestión de usuarios que no están relacionadas con la autenticación.
def filter_users(filter_params):
"""
Filtrar usuarios según los parámetros proporcionados.
Args:
filter_params (dict): Diccionario con parámetros de filtrado
- empresa: Nombre de empresa
- estado: Estado del usuario ('activo', 'inactivo')
- nivel_min: Nivel mínimo de permiso
- nivel_max: Nivel máximo de permiso
Returns:
list: Lista de objetos User que cumplen los criterios
"""
users = get_all_users()
filtered_users = []
for user in users:
# Filtrar por empresa
if 'empresa' in filter_params and filter_params['empresa']:
if user.empresa != filter_params['empresa']:
if "empresa" in filter_params and filter_params["empresa"]:
if user.empresa != filter_params["empresa"]:
continue
# Filtrar por estado
if 'estado' in filter_params and filter_params['estado']:
if user.estado != filter_params['estado']:
if "estado" in filter_params and filter_params["estado"]:
if user.estado != filter_params["estado"]:
continue
# Filtrar por nivel mínimo
if 'nivel_min' in filter_params and filter_params['nivel_min'] is not None:
if user.nivel < int(filter_params['nivel_min']):
if "nivel_min" in filter_params and filter_params["nivel_min"] is not None:
if user.nivel < int(filter_params["nivel_min"]):
continue
# Filtrar por nivel máximo
if 'nivel_max' in filter_params and filter_params['nivel_max'] is not None:
if user.nivel > int(filter_params['nivel_max']):
if "nivel_max" in filter_params and filter_params["nivel_max"] is not None:
if user.nivel > int(filter_params["nivel_max"]):
continue
# Si pasó todos los filtros, agregar a la lista
filtered_users.append(user)
return filtered_users
def get_user_stats():
"""
Obtener estadísticas sobre los usuarios.
Returns:
dict: Diccionario con estadísticas
"""
users = get_all_users()
# Inicializar estadísticas
stats = {
'total': len(users),
'activos': 0,
'inactivos': 0,
'expirados': 0,
'por_empresa': {},
'por_nivel': {
'admin': 0, # Nivel 9000+
'gestor': 0, # Nivel 5000-8999
'editor': 0, # Nivel 1000-4999
'lector': 0 # Nivel 0-999
}
"total": len(users),
"activos": 0,
"inactivos": 0,
"expirados": 0,
"por_empresa": {},
"por_nivel": {
"admin": 0, # Nivel 9000+
"gestor": 0, # Nivel 5000-8999
"editor": 0, # Nivel 1000-4999
"lector": 0, # Nivel 0-999
},
}
# Calcular estadísticas
for user in users:
# Estado
if user.estado == 'activo':
stats['activos'] += 1
if user.estado == "activo":
stats["activos"] += 1
else:
stats['inactivos'] += 1
stats["inactivos"] += 1
# Expirados
if user.is_expired():
stats['expirados'] += 1
stats["expirados"] += 1
# Por empresa
empresa = user.empresa or 'Sin empresa'
stats['por_empresa'][empresa] = stats['por_empresa'].get(empresa, 0) + 1
empresa = user.empresa or "Sin empresa"
stats["por_empresa"][empresa] = stats["por_empresa"].get(empresa, 0) + 1
# Por nivel
if user.nivel >= 9000:
stats['por_nivel']['admin'] += 1
stats["por_nivel"]["admin"] += 1
elif user.nivel >= 5000:
stats['por_nivel']['gestor'] += 1
stats["por_nivel"]["gestor"] += 1
elif user.nivel >= 1000:
stats['por_nivel']['editor'] += 1
stats["por_nivel"]["editor"] += 1
else:
stats['por_nivel']['lector'] += 1
stats["por_nivel"]["lector"] += 1
return stats
def check_username_availability(username):
"""
Verificar si un nombre de usuario está disponible.
Args:
username (str): Nombre de usuario a verificar
Returns:
bool: True si está disponible, False si ya existe
"""
user = get_user_by_username(username)
return user is None
return user is None

View File

@ -21,6 +21,14 @@ body {
margin-top: auto;
}
main {
flex: 1 0 auto;
}
footer {
flex-shrink: 0;
}
/* Encabezados */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
@ -35,7 +43,7 @@ h1, h2, h3, h4, h5, h6 {
}
.card-header {
background-color: #f8f9fa;
background-color: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
padding: 0.75rem 1.25rem;
}
@ -55,12 +63,20 @@ h1, h2, h3, h4, h5, h6 {
background-color: rgba(0, 123, 255, 0.05);
}
.table-responsive {
overflow-x: auto;
}
/* Botones */
.btn {
border-radius: 0.25rem;
font-weight: 500;
}
.btn-icon {
padding: 0.25rem 0.5rem;
}
/* Navegación */
.navbar {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
@ -93,6 +109,11 @@ h1, h2, h3, h4, h5, h6 {
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.required-field::after {
content: " *";
color: red;
}
/* Alertas */
.alert {
border-radius: 0.25rem;
@ -174,4 +195,52 @@ h1, h2, h3, h4, h5, h6 {
.text-primary-dark {
color: #0056b3;
}
/* Login form styling */
.login-container {
max-width: 400px;
margin: 2rem auto;
}
/* Project card styling */
.project-card {
transition: transform 0.2s;
}
.project-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
/* Document version styling */
.version-timeline {
position: relative;
padding-left: 30px;
}
.version-timeline:before {
content: '';
position: absolute;
left: 10px;
top: 0;
height: 100%;
width: 2px;
background-color: #dee2e6;
}
.version-item {
position: relative;
margin-bottom: 1.5rem;
}
.version-item:before {
content: '';
position: absolute;
left: -25px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #0d6efd;
}

BIN
static/img/icons/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

BIN
static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

41
static/js/main.js Normal file
View File

@ -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]);
}
}

39
templates/about.html Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -3,42 +3,42 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ARCH - Sistema de Gestión de Documentos{% endblock %}</title>
<title>{% block title %}ARCH - Sistema de Gestión Documental{% endblock %}</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Estilos principales -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<!-- Font Awesome Icons -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Estilos específicos -->
{% block styles %}{% endblock %}
<!-- Custom CSS -->
<link href="{{ url_for('static', filename='css/main.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Barra de navegación -->
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('projects.list') }}">
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="ARCH" height="30">
ARCH
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('projects.list') }}">Proyectos</a>
</li>
{% if current_user.has_permission(5000) %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('schemas.list') }}">Esquemas</a>
</li>
{% endif %}
{% if current_user.has_permission(9000) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
@ -52,17 +52,25 @@
</li>
{% endif %}
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.about') }}">Acerca de</a>
</li>
</ul>
<ul class="navbar-nav">
{% if current_user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
{{ current_user.nombre }}
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-user-circle me-1"></i> {{ current_user.nombre }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Mi perfil</a></li>
{% if current_user.has_permission(5000) %}
<li><a class="dropdown-item" href="{{ url_for('users.list') }}">Usuarios</a></li>
{% endif %}
{% if current_user.has_permission(9000) %}
<li><a class="dropdown-item" href="{{ url_for('admin.dashboard') }}">Administración</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Mi perfil</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Cerrar sesión</a></li>
</ul>
</li>
@ -75,42 +83,63 @@
</div>
</div>
</nav>
<!-- Contenido principal -->
<div class="container mt-4">
<!-- Mensajes flash -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container mt-3">
{% for category, message in messages %}
{% set alert_class = 'primary' %}
{% if category == 'error' or category == 'danger' %}
{% set alert_class = 'danger' %}
{% elif category == 'warning' %}
{% set alert_class = 'warning' %}
{% elif category == 'success' %}
{% set alert_class = 'success' %}
{% elif category == 'info' %}
{% set alert_class = 'info' %}
{% endif %}
<div class="alert alert-{{ alert_class }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main>
<!-- Título de la página -->
<h1 class="mb-4">{% block page_title %}{% endblock %}</h1>
<!-- Contenido -->
{% block content %}{% endblock %}
</div>
</main>
<!-- Footer -->
<footer class="footer mt-5 py-3 bg-light">
<footer class="bg-light py-4 mt-5">
<div class="container">
<div class="text-center">
<span class="text-muted">ARCH - Sistema de Gestión de Documentos © {{ now.year }}</span>
<div class="row">
<div class="col-md-6">
<p class="mb-0">&copy; {{ now.year }} ARCH - Sistema de Gestión Documental</p>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('main.about') }}" class="text-decoration-none">Acerca de</a>
</div>
</div>
</div>
</footer>
<!-- Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- JavaScript -->
<script src="{{ url_for('static', filename='js/lib/jquery.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/lib/bootstrap.bundle.min.js') }}"></script>
<!-- jQuery (For bootstrap plugins that require it) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- JavaScript específico -->
{% block scripts %}{% endblock %}
<!-- Custom JavaScript -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

50
templates/index.html Normal file
View File

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

View File

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

131
templates/schemas/view.html Normal file
View File

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

View File

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

View File

@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Usuarios - ARCH{% endblock %}
{% block content %}
<div class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Usuarios del Sistema</h1>
{% if current_user.has_permission(9000) %}
<a href="{{ url_for('users.create') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Nuevo Usuario
</a>
{% endif %}
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Usuario</th>
<th>Nombre</th>
<th>Email</th>
<th>Nivel</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for user_id, user in users.items() %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.nombre }}</td>
<td>{{ user.email }}</td>
<td>{{ user.nivel }}</td>
<td>
{% if user.estado == 'activo' %}
<span class="badge bg-success">Activo</span>
{% else %}
<span class="badge bg-danger">Inactivo</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('users.edit', username=user.username) }}"
class="btn btn-outline-primary" title="Editar">
<i class="fas fa-edit"></i>
</a>
{% if user_id != current_user.get_id() and current_user.has_permission(9000) %}
<button type="button" class="btn btn-outline-danger"
onclick="confirmDelete('{{ user.username }}', '{{ user.username }}')" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">No hay usuarios registrados.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% if current_user.has_permission(9000) %}
<!-- Modal de confirmación para eliminar usuario -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteUserModalLabel">Confirmar Eliminación</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>¿Está seguro que desea eliminar el usuario <strong id="deleteUserName"></strong>?</p>
<p class="text-danger">Esta acción no se puede deshacer.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<form id="deleteUserForm" method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Eliminar</button>
</form>
</div>
</div>
</div>
</div>
<script>
function confirmDelete(username, displayName) {
document.getElementById('deleteUserName').textContent = displayName;
document.getElementById('deleteUserForm').action = "{{ url_for('users.delete', username='') }}" + username;
var deleteModal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
deleteModal.show();
}
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,144 @@
import pytest
import os
import json
import shutil
from app import create_app
@pytest.fixture
def app():
"""Create a Flask application instance for testing."""
# Test configuration
test_config = {
'TESTING': True,
'STORAGE_PATH': 'test_storage',
'SECRET_KEY': 'test_key',
'WTF_CSRF_ENABLED': False
}
# Create test storage directory
if not os.path.exists('test_storage'):
os.makedirs('test_storage')
# Create basic directory structure
for dir_name in ['users', 'schemas', 'filetypes', 'projects', 'logs', 'exports']:
if not os.path.exists(f'test_storage/{dir_name}'):
os.makedirs(f'test_storage/{dir_name}')
# Create app with test configuration
app = create_app('testing')
# Create context
with app.app_context():
# Set up test data
initialize_test_data()
yield app
# Clean up after tests
shutil.rmtree('test_storage', ignore_errors=True)
@pytest.fixture
def client(app):
"""Create a test client for the application."""
return app.test_client()
@pytest.fixture
def auth(client):
"""Helper for authentication tests."""
class AuthActions:
def login(self, username='admin', password='admin123'):
return client.post('/auth/login', data={
'username': username,
'password': password
}, follow_redirects=True)
def logout(self):
return client.get('/auth/logout', follow_redirects=True)
return AuthActions()
@pytest.fixture
def logged_in_client(client, auth):
"""Client that's already logged in as admin."""
auth.login()
return client
def initialize_test_data():
"""Initialize test data files."""
# Create test users
users_data = {
"admin": {
"nombre": "Administrador",
"username": "admin",
"email": "admin@ejemplo.com",
"password_hash": "$2b$12$Q5Nz3QSF0FP.mKAxPmWXmurKn1oor4Cl1KbYZAKsFbGcEWWyPHou6", # admin123
"nivel": 9999,
"idioma": "es",
"fecha_caducidad": None,
"empresa": "ARCH",
"estado": "activo"
},
"user1": {
"nombre": "Usuario Normal",
"username": "user1",
"email": "user1@ejemplo.com",
"password_hash": "$2b$12$Q5Nz3QSF0FP.mKAxPmWXmurKn1oor4Cl1KbYZAKsFbGcEWWyPHou6", # admin123
"nivel": 1000,
"idioma": "es",
"fecha_caducidad": None,
"empresa": "ARCH",
"estado": "activo"
}
}
with open('test_storage/users/users.json', 'w', encoding='utf-8') as f:
json.dump(users_data, f, ensure_ascii=False, indent=2)
# Create test file types
filetypes_data = {
"pdf": {
"extension": "pdf",
"descripcion": "Documento PDF",
"mime_type": "application/pdf",
"tamano_maximo": 20971520
},
"txt": {
"extension": "txt",
"descripcion": "Documento de texto",
"mime_type": "text/plain",
"tamano_maximo": 5242880
}
}
with open('test_storage/filetypes/filetypes.json', 'w', encoding='utf-8') as f:
json.dump(filetypes_data, f, ensure_ascii=False, indent=2)
# Create test schemas
schemas_data = {
"TEST001": {
"codigo": "TEST001",
"descripcion": "Esquema de prueba",
"fecha_creacion": "2023-10-01T10:00:00Z",
"creado_por": "admin",
"documentos": [
{
"tipo": "pdf",
"nombre": "Documento de Prueba",
"nivel_ver": 0,
"nivel_editar": 1000
}
]
}
}
with open('test_storage/schemas/schema.json', 'w', encoding='utf-8') as f:
json.dump(schemas_data, f, ensure_ascii=False, indent=2)
# Create indices file
indices_data = {
"max_project_id": 0,
"max_document_id": 0
}
with open('test_storage/indices.json', 'w', encoding='utf-8') as f:
json.dump(indices_data, f, ensure_ascii=False, indent=2)

View File

@ -0,0 +1,101 @@
import json
import pytest
import datetime
import os
import time
from pathlib import Path
class JSONReporter:
"""
Custom pytest plugin to generate JSON test reports.
Based on the specification in section 9.4 of descripcion.md.
"""
def __init__(self, config):
self.config = config
self.start_time = time.time()
self.results = {
'summary': {
'total': 0,
'passed': 0,
'failed': 0,
'skipped': 0,
'error': 0,
'duration': 0,
'timestamp': datetime.datetime.now().isoformat()
},
'tests': []
}
def pytest_runtest_logreport(self, report):
"""Handle the reporting of a test run."""
if report.when == 'call' or (report.when == 'setup' and report.skipped):
self.results['summary']['total'] += 1
if report.passed:
result = 'passed'
self.results['summary']['passed'] += 1
elif report.failed:
if report.when != 'call':
result = 'error'
self.results['summary']['error'] += 1
else:
result = 'failed'
self.results['summary']['failed'] += 1
elif report.skipped:
result = 'skipped'
self.results['summary']['skipped'] += 1
# Extract test metadata
test_module = report.nodeid.split('::')[0]
test_class = report.nodeid.split('::')[1] if '::' in report.nodeid else None
test_name = report.nodeid.split('::')[-1]
# Extract error details if present
error_message = None
error_trace = None
if hasattr(report, 'longrepr') and report.longrepr:
if hasattr(report.longrepr, 'reprcrash') and report.longrepr.reprcrash:
error_message = report.longrepr.reprcrash.message
error_trace = str(report.longrepr)
# Add test result to list
self.results['tests'].append({
'id': report.nodeid,
'module': test_module,
'class': test_class,
'name': test_name,
'result': result,
'duration': report.duration,
'error_message': error_message,
'error_trace': error_trace
})
def pytest_sessionfinish(self, session):
"""Generate report at end of test session."""
self.results['summary']['duration'] = time.time() - self.start_time
# Create output directory if it doesn't exist
output_dir = Path('test_reports')
output_dir.mkdir(exist_ok=True)
# Generate timestamp for file name
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
output_path = output_dir / f'test_results_{timestamp}.json'
# Write results to file
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(self.results, f, indent=2, ensure_ascii=False)
# Also create a symlink/copy to latest results
latest_path = output_dir / 'test_results_latest.json'
if os.path.exists(latest_path):
os.remove(latest_path)
with open(latest_path, 'w', encoding='utf-8') as f:
json.dump(self.results, f, indent=2, ensure_ascii=False)
print(f"\nJSON test report generated: {output_path}")
@pytest.hookimpl(tryfirst=True)
def pytest_configure(config):
"""Register the JSON reporter plugin."""
config.pluginmanager.register(JSONReporter(config), 'json_reporter')

99
tests/test_admin.py Normal file
View File

@ -0,0 +1,99 @@
import pytest
import json
import os
class TestAdminFunctions:
"""Test administrative functions."""
def test_admin_dashboard(self, logged_in_client):
"""Test access to admin dashboard."""
response = logged_in_client.get('/admin/')
assert response.status_code == 200
assert b'Panel de Administraci' in response.data # "Panel de Administración"
def test_filetypes_management(self, logged_in_client):
"""Test file types management page."""
response = logged_in_client.get('/admin/filetypes')
assert response.status_code == 200
assert b'Tipos de Archivo' in response.data
def test_system_status(self, logged_in_client):
"""Test system status page."""
response = logged_in_client.get('/admin/system')
assert response.status_code == 200
assert b'Estado del Sistema' in response.data
def test_add_filetype(self, logged_in_client, app):
"""Test adding a new file type."""
response = logged_in_client.post('/admin/filetypes/add', data={
'extension': 'docx',
'descripcion': 'Documento Word',
'mime_type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'tamano_maximo': 15728640 # 15MB in bytes
}, follow_redirects=True)
assert response.status_code == 200
# Verify file type was added
with app.app_context():
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
with open(filetypes_path, 'r') as f:
filetypes = json.load(f)
assert 'docx' in filetypes
assert filetypes['docx']['descripcion'] == 'Documento Word'
def test_delete_filetype(self, logged_in_client, app):
"""Test deleting a file type."""
# First add a file type to delete
logged_in_client.post('/admin/filetypes/add', data={
'extension': 'xlsx',
'descripcion': 'Hoja de cálculo Excel',
'mime_type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'tamano_maximo': 15728640
}, follow_redirects=True)
# Verify it was added
with app.app_context():
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
with open(filetypes_path, 'r') as f:
filetypes = json.load(f)
assert 'xlsx' in filetypes
# Now delete it
response = logged_in_client.post('/admin/filetypes/xlsx/delete', follow_redirects=True)
assert response.status_code == 200
# Verify it was deleted
with app.app_context():
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
with open(filetypes_path, 'r') as f:
filetypes = json.load(f)
assert 'xlsx' not in filetypes
def test_update_filetype(self, logged_in_client, app):
"""Test updating a file type."""
# First add a file type to update
logged_in_client.post('/admin/filetypes/add', data={
'extension': 'png',
'descripcion': 'Imagen PNG',
'mime_type': 'image/png',
'tamano_maximo': 5242880 # 5MB
}, follow_redirects=True)
# Now update it
response = logged_in_client.post('/admin/filetypes/png/update', data={
'descripcion': 'Imagen PNG Actualizada',
'mime_type': 'image/png',
'tamano_maximo': 10485760 # 10MB (increased)
}, follow_redirects=True)
assert response.status_code == 200
# Verify changes
with app.app_context():
filetypes_path = os.path.join(app.config['STORAGE_PATH'], 'filetypes', 'filetypes.json')
with open(filetypes_path, 'r') as f:
filetypes = json.load(f)
assert 'png' in filetypes
assert filetypes['png']['descripcion'] == 'Imagen PNG Actualizada'
assert filetypes['png']['tamano_maximo'] == 10485760

View File

@ -0,0 +1,67 @@
import pytest
from flask import session, g
class TestAuth:
"""Test authentication functionality."""
def test_login_page(self, client):
"""Test that login page loads correctly."""
response = client.get('/auth/login')
assert response.status_code == 200
assert b'Iniciar sesi' in response.data # 'Iniciar sesión' in Spanish
def test_login_success(self, client):
"""Test successful login with correct credentials."""
response = client.post(
'/auth/login',
data={'username': 'admin', 'password': 'admin123'},
follow_redirects=True
)
assert response.status_code == 200
# Check that we're redirected to the right page after login
assert b'Panel' in response.data or b'Proyectos' in response.data
def test_login_invalid_credentials(self, client):
"""Test login with invalid credentials."""
response = client.post(
'/auth/login',
data={'username': 'admin', 'password': 'wrongpassword'},
follow_redirects=True
)
assert response.status_code == 200
assert b'credenciales' in response.data.lower() # Error message about credentials
def test_logout(self, auth, client):
"""Test logout functionality."""
# First login
auth.login()
# Then logout
response = auth.logout()
assert response.status_code == 200
# Check if logged out - try to access a protected page
response = client.get('/users/', follow_redirects=True)
assert b'iniciar sesi' in response.data.lower() # Should see login page
def test_access_protected_route(self, client):
"""Test accessing a protected route without login."""
# Try to access users list without login
response = client.get('/users/', follow_redirects=True)
assert response.status_code == 200
assert b'iniciar sesi' in response.data.lower() # Should be redirected to login
def test_access_protected_route_with_login(self, logged_in_client):
"""Test accessing a protected route with login."""
# Admin should be able to access users list
response = logged_in_client.get('/admin/dashboard')
assert response.status_code == 200
def test_permission_levels(self, client, auth):
"""Test different permission levels."""
# Login as regular user
auth.login(username='user1', password='admin123')
# Try to access admin-only page
response = client.get('/admin/dashboard', follow_redirects=True)
assert response.status_code == 403 or b'acceso denegado' in response.data.lower()

View File

@ -0,0 +1,154 @@
import pytest
import os
import io
import json
from werkzeug.datastructures import FileStorage
class TestDocuments:
"""Test document management functionality."""
def setup_project(self, logged_in_client, app):
"""Helper to create a test project."""
# Create a project
logged_in_client.post('/projects/create', data={
'codigo': 'TESTDOC',
'descripcion': 'Proyecto para documentos',
'cliente': 'Cliente Test',
'esquema': 'TEST001',
'destinacion': 'Pruebas',
'notas': 'Proyecto para pruebas de documentos'
}, follow_redirects=True)
# Find the project ID
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
project_dirs = [d for d in os.listdir(projects_dir) if 'TESTDOC' in d]
assert len(project_dirs) > 0
project_dir = project_dirs[0]
return project_dir.split('_')[0].replace('@', '')
def test_upload_document(self, logged_in_client, app):
"""Test uploading a document to a project."""
# Create a project and get its ID
project_id = self.setup_project(logged_in_client, app)
# Create a test file
test_file = FileStorage(
stream=io.BytesIO(b'This is a test document content.'),
filename='test_document.txt',
content_type='text/plain',
)
# Upload the document
response = logged_in_client.post(
f'/projects/{project_id}/documents/upload',
data={
'tipo_doc': 'Documento de Prueba', # Match schema type from TEST001
'descripcion': 'Documento de prueba para tests',
'file': test_file
},
content_type='multipart/form-data',
follow_redirects=True
)
assert response.status_code == 200
# Verify document was created
with app.app_context():
project_path = None
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
for dir_name in os.listdir(projects_dir):
if dir_name.startswith(f'@{project_id}_'):
project_path = os.path.join(projects_dir, dir_name)
break
assert project_path is not None
# Check for documents directory with content
docs_path = os.path.join(project_path, 'documents')
assert os.path.exists(docs_path)
# Should have at least one document folder
assert len(os.listdir(docs_path)) > 0
def test_document_versions(self, logged_in_client, app):
"""Test document versioning functionality."""
# Create a project and upload first version
project_id = self.setup_project(logged_in_client, app)
# Upload first version
test_file1 = FileStorage(
stream=io.BytesIO(b'Document content version 1'),
filename='test_versioning.txt',
content_type='text/plain',
)
logged_in_client.post(
f'/projects/{project_id}/documents/upload',
data={
'tipo_doc': 'Documento de Prueba',
'descripcion': 'Documento para pruebas de versiones',
'file': test_file1
},
content_type='multipart/form-data',
follow_redirects=True
)
# Find the document ID
doc_id = None
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
for dir_name in os.listdir(projects_dir):
if dir_name.startswith(f'@{project_id}_'):
project_path = os.path.join(projects_dir, dir_name)
docs_path = os.path.join(project_path, 'documents')
# Get first document directory
doc_dirs = os.listdir(docs_path)
if doc_dirs:
doc_id = doc_dirs[0].split('_')[0].replace('@', '')
break
assert doc_id is not None
# Upload second version of the same document
test_file2 = FileStorage(
stream=io.BytesIO(b'Document content version 2 - UPDATED'),
filename='test_versioning_v2.txt',
content_type='text/plain',
)
response = logged_in_client.post(
f'/projects/{project_id}/documents/{doc_id}/upload',
data={
'descripcion': 'Segunda versión del documento',
'file': test_file2
},
content_type='multipart/form-data',
follow_redirects=True
)
assert response.status_code == 200
# Check for multiple versions in metadata
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
for dir_name in os.listdir(projects_dir):
if dir_name.startswith(f'@{project_id}_'):
project_path = os.path.join(projects_dir, dir_name)
docs_path = os.path.join(project_path, 'documents')
# Find document directory
for doc_dir in os.listdir(docs_path):
if doc_dir.startswith(f'@{doc_id}_'):
doc_path = os.path.join(docs_path, doc_dir)
meta_path = os.path.join(doc_path, 'meta.json')
# Check metadata for versions
if os.path.exists(meta_path):
with open(meta_path, 'r') as f:
metadata = json.load(f)
assert 'versions' in metadata
assert len(metadata['versions']) >= 2

162
tests/test_integration.py Normal file
View File

@ -0,0 +1,162 @@
import pytest
import os
import io
from werkzeug.datastructures import FileStorage
class TestIntegration:
"""Test integrations between different components."""
def test_complete_workflow(self, logged_in_client, app):
"""Test a complete workflow from project creation to document download."""
# 1. Create a new project
logged_in_client.post('/projects/create', data={
'codigo': 'WORKFLOW',
'descripcion': 'Proyecto de flujo completo',
'cliente': 'Cliente Integración',
'esquema': 'TEST001',
'destinacion': 'Pruebas de integración',
'notas': 'Notas de proyecto de prueba'
}, follow_redirects=True)
# 2. Find the project ID
project_id = None
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
project_dirs = [d for d in os.listdir(projects_dir) if 'WORKFLOW' in d]
assert len(project_dirs) > 0
project_dir = project_dirs[0]
project_id = project_dir.split('_')[0].replace('@', '')
assert project_id is not None
# 3. Upload a document to the project
test_file = FileStorage(
stream=io.BytesIO(b'Content for integration test document'),
filename='integration_doc.txt',
content_type='text/plain',
)
response = logged_in_client.post(
f'/projects/{project_id}/documents/upload',
data={
'tipo_doc': 'Documento de Prueba',
'descripcion': 'Documento de flujo de integración',
'file': test_file
},
content_type='multipart/form-data',
follow_redirects=True
)
assert response.status_code == 200
# 4. Find the document ID
doc_id = None
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
for dir_name in os.listdir(projects_dir):
if dir_name.startswith(f'@{project_id}_'):
project_path = os.path.join(projects_dir, dir_name)
docs_path = os.path.join(project_path, 'documents')
doc_dirs = os.listdir(docs_path)
if doc_dirs:
doc_id = doc_dirs[0].split('_')[0].replace('@', '')
break
assert doc_id is not None
# 5. View document details
response = logged_in_client.get(f'/projects/{project_id}/documents/{doc_id}')
assert response.status_code == 200
assert b'Documento de flujo de integraci' in response.data
# 6. Upload a new version of the document
test_file2 = FileStorage(
stream=io.BytesIO(b'Updated content for version 2'),
filename='integration_doc_v2.txt',
content_type='text/plain',
)
response = logged_in_client.post(
f'/projects/{project_id}/documents/{doc_id}/upload',
data={
'descripcion': 'Segunda versión del documento de integración',
'file': test_file2
},
content_type='multipart/form-data',
follow_redirects=True
)
assert response.status_code == 200
# 7. Download the document
response = logged_in_client.get(f'/projects/{project_id}/documents/{doc_id}/download/latest')
assert response.status_code == 200
assert b'Updated content for version 2' in response.data
# 8. Download a specific version (the first one)
response = logged_in_client.get(f'/projects/{project_id}/documents/{doc_id}/download/1')
assert response.status_code == 200
assert b'Content for integration test document' in response.data
def test_schema_project_document_integration(self, logged_in_client, app):
"""Test integration between schemas, projects and documents."""
# 1. Create a custom schema
logged_in_client.post('/schemas/create', data={
'codigo': 'CUSTOM',
'descripcion': 'Esquema personalizado para integración',
'documentos-0-tipo': 'pdf',
'documentos-0-nombre': 'Informe Principal',
'documentos-0-nivel_ver': 0,
'documentos-0-nivel_editar': 5000,
'documentos-1-tipo': 'txt',
'documentos-1-nombre': 'Notas Adicionales',
'documentos-1-nivel_ver': 0,
'documentos-1-nivel_editar': 1000
}, follow_redirects=True)
# 2. Create a project with the custom schema
logged_in_client.post('/projects/create', data={
'codigo': 'PROJ_SCHEMA',
'descripcion': 'Proyecto con esquema personalizado',
'cliente': 'Cliente Test',
'esquema': 'CUSTOM',
'destinacion': 'Pruebas',
'notas': 'Proyecto para probar integración de esquemas'
}, follow_redirects=True)
# 3. Find the project ID
project_id = None
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
project_dirs = [d for d in os.listdir(projects_dir) if 'PROJ_SCHEMA' in d]
assert len(project_dirs) > 0
project_dir = project_dirs[0]
project_id = project_dir.split('_')[0].replace('@', '')
# 4. Verify project uses the custom schema
response = logged_in_client.get(f'/projects/{project_id}')
assert response.status_code == 200
assert b'CUSTOM' in response.data
assert b'Informe Principal' in response.data
assert b'Notas Adicionales' in response.data
# 5. Upload a document of the specified type
test_file = FileStorage(
stream=io.BytesIO(b'Notes content for schema integration'),
filename='notes.txt',
content_type='text/plain',
)
response = logged_in_client.post(
f'/projects/{project_id}/documents/upload',
data={
'tipo_doc': 'Notas Adicionales', # This should match schema document type
'descripcion': 'Notas para prueba de esquema',
'file': test_file
},
content_type='multipart/form-data',
follow_redirects=True
)
assert response.status_code == 200

View File

@ -0,0 +1,93 @@
import pytest
import json
import os
class TestProjects:
"""Test project-related functionality."""
def test_project_list(self, logged_in_client):
"""Test listing projects."""
response = logged_in_client.get('/projects/')
assert response.status_code == 200
def test_create_project(self, logged_in_client, app):
"""Test creating a new project."""
response = logged_in_client.post('/projects/create', data={
'codigo': 'TEST123',
'descripcion': 'Proyecto de Prueba',
'cliente': 'Cliente Test',
'esquema': 'TEST001',
'destinacion': 'Pruebas',
'notas': 'Notas de prueba'
}, follow_redirects=True)
assert response.status_code == 200
# Check if project was created in storage
with app.app_context():
# Check indices
indices_path = os.path.join(app.config['STORAGE_PATH'], 'indices.json')
with open(indices_path, 'r') as f:
indices = json.load(f)
assert indices['max_project_id'] > 0
def test_view_project(self, logged_in_client, app):
"""Test viewing a project (requires creating one first)."""
# Create a project first
logged_in_client.post('/projects/create', data={
'codigo': 'TEST456',
'descripcion': 'Proyecto para visualizar',
'cliente': 'Cliente Test',
'esquema': 'TEST001',
'destinacion': 'Pruebas',
'notas': 'Notas de prueba'
}, follow_redirects=True)
# Now find the project ID
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
project_dirs = os.listdir(projects_dir)
assert len(project_dirs) > 0
# Get first project directory
project_dir = project_dirs[0]
project_id = project_dir.split('_')[0].replace('@', '')
# Try to view it
response = logged_in_client.get(f'/projects/{project_id}')
assert response.status_code == 200
assert b'Proyecto para visualizar' in response.data
def test_edit_project(self, logged_in_client, app):
"""Test editing a project."""
# Create a project first
logged_in_client.post('/projects/create', data={
'codigo': 'TESTEDIT',
'descripcion': 'Proyecto para editar',
'cliente': 'Cliente Test',
'esquema': 'TEST001',
'destinacion': 'Pruebas',
'notas': 'Notas originales'
}, follow_redirects=True)
# Find the project ID
with app.app_context():
projects_dir = os.path.join(app.config['STORAGE_PATH'], 'projects')
project_dirs = [d for d in os.listdir(projects_dir) if 'TESTEDIT' in d]
assert len(project_dirs) > 0
project_dir = project_dirs[0]
project_id = project_dir.split('_')[0].replace('@', '')
# Edit the project
response = logged_in_client.post(f'/projects/{project_id}/edit', data={
'codigo': 'TESTEDIT',
'descripcion': 'Proyecto editado',
'cliente': 'Cliente Test Modificado',
'esquema': 'TEST001',
'destinacion': 'Pruebas Modificadas',
'notas': 'Notas modificadas'
}, follow_redirects=True)
assert response.status_code == 200
assert b'Proyecto editado' in response.data

View File

@ -0,0 +1,78 @@
import pytest
import json
import os
class TestSchemas:
"""Test schema management functionality."""
def test_list_schemas(self, logged_in_client):
"""Test listing schemas."""
response = logged_in_client.get('/schemas/')
assert response.status_code == 200
assert b'Esquemas de Proyecto' in response.data
def test_view_schema(self, logged_in_client):
"""Test viewing a schema."""
response = logged_in_client.get('/schemas/view/TEST001')
assert response.status_code == 200
assert b'Esquema de prueba' in response.data
def test_create_schema(self, logged_in_client, app):
"""Test creating a new schema."""
response = logged_in_client.post('/schemas/create', data={
'codigo': 'TEST002',
'descripcion': 'Nuevo esquema de prueba',
'documentos-0-tipo': 'pdf',
'documentos-0-nombre': 'Manual de Usuario',
'documentos-0-nivel_ver': 0,
'documentos-0-nivel_editar': 5000,
'documentos-1-tipo': 'txt',
'documentos-1-nombre': 'Notas de Proyecto',
'documentos-1-nivel_ver': 0,
'documentos-1-nivel_editar': 1000
}, follow_redirects=True)
assert response.status_code == 200
# Check if schema was created
with app.app_context():
schemas_path = os.path.join(app.config['STORAGE_PATH'], 'schemas', 'schema.json')
with open(schemas_path, 'r') as f:
schemas = json.load(f)
assert 'TEST002' in schemas
assert schemas['TEST002']['descripcion'] == 'Nuevo esquema de prueba'
assert len(schemas['TEST002']['documentos']) == 2
def test_edit_schema(self, logged_in_client, app):
"""Test editing a schema."""
# First create a schema to edit
logged_in_client.post('/schemas/create', data={
'codigo': 'TESTEDIT',
'descripcion': 'Esquema para editar',
'documentos-0-tipo': 'pdf',
'documentos-0-nombre': 'Documento Original',
'documentos-0-nivel_ver': 0,
'documentos-0-nivel_editar': 5000
}, follow_redirects=True)
# Now edit it
response = logged_in_client.post('/schemas/edit/TESTEDIT', data={
'codigo': 'TESTEDIT',
'descripcion': 'Esquema editado',
'documentos-0-tipo': 'pdf',
'documentos-0-nombre': 'Documento Modificado',
'documentos-0-nivel_ver': 500,
'documentos-0-nivel_editar': 6000
}, follow_redirects=True)
assert response.status_code == 200
# Verify changes
with app.app_context():
schemas_path = os.path.join(app.config['STORAGE_PATH'], 'schemas', 'schema.json')
with open(schemas_path, 'r') as f:
schemas = json.load(f)
assert 'TESTEDIT' in schemas
assert schemas['TESTEDIT']['descripcion'] == 'Esquema editado'
assert schemas['TESTEDIT']['documentos'][0]['nombre'] == 'Documento Modificado'
assert schemas['TESTEDIT']['documentos'][0]['nivel_ver'] == 500

128
tests/test_users.py Normal file
View File

@ -0,0 +1,128 @@
import pytest
import json
import os
class TestUserManagement:
"""Test user management functionality."""
def test_list_users(self, logged_in_client):
"""Test listing users."""
response = logged_in_client.get('/users/')
assert response.status_code == 200
assert b'Usuarios del Sistema' in response.data
# Check for existing users
assert b'admin' in response.data
assert b'user1' in response.data
def test_create_user(self, logged_in_client, app):
"""Test creating a new user."""
response = logged_in_client.post('/users/create', data={
'nombre': 'Usuario de Prueba',
'username': 'testuser',
'email': 'test@example.com',
'password': 'password123',
'password_confirm': 'password123',
'nivel': 1000,
'idioma': 'es',
'empresa': 'Empresa Test',
'estado': 'activo',
'fecha_caducidad': ''
}, follow_redirects=True)
assert response.status_code == 200
# Check if user was created
with app.app_context():
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
with open(users_path, 'r') as f:
users = json.load(f)
assert 'testuser' in users
assert users['testuser']['nombre'] == 'Usuario de Prueba'
assert users['testuser']['email'] == 'test@example.com'
assert users['testuser']['nivel'] == 1000
def test_edit_user(self, logged_in_client, app):
"""Test editing an existing user."""
# First create a user to edit
logged_in_client.post('/users/create', data={
'nombre': 'Usuario para Editar',
'username': 'edit_user',
'email': 'edit@example.com',
'password': 'password123',
'password_confirm': 'password123',
'nivel': 1000,
'idioma': 'es',
'empresa': 'Empresa Original',
'estado': 'activo',
'fecha_caducidad': ''
}, follow_redirects=True)
# Now edit the user
response = logged_in_client.post('/users/edit/edit_user', data={
'nombre': 'Usuario Editado',
'email': 'edited@example.com',
'password': '', # Empty password means no change
'password_confirm': '',
'nivel': 5000, # Changed level
'idioma': 'en', # Changed language
'empresa': 'Empresa Modificada',
'estado': 'activo',
'fecha_caducidad': ''
}, follow_redirects=True)
assert response.status_code == 200
# Verify changes
with app.app_context():
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
with open(users_path, 'r') as f:
users = json.load(f)
assert 'edit_user' in users
assert users['edit_user']['nombre'] == 'Usuario Editado'
assert users['edit_user']['email'] == 'edited@example.com'
assert users['edit_user']['nivel'] == 5000
assert users['edit_user']['idioma'] == 'en'
assert users['edit_user']['empresa'] == 'Empresa Modificada'
def test_delete_user(self, logged_in_client, app):
"""Test deleting a user."""
# First create a user to delete
logged_in_client.post('/users/create', data={
'nombre': 'Usuario para Eliminar',
'username': 'delete_user',
'email': 'delete@example.com',
'password': 'password123',
'password_confirm': 'password123',
'nivel': 1000,
'idioma': 'es',
'empresa': 'Empresa Test',
'estado': 'activo',
'fecha_caducidad': ''
}, follow_redirects=True)
# Verify user was created
with app.app_context():
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
with open(users_path, 'r') as f:
users = json.load(f)
assert 'delete_user' in users
# Now delete the user
response = logged_in_client.post('/users/delete/delete_user', follow_redirects=True)
assert response.status_code == 200
# Verify user was deleted
with app.app_context():
users_path = os.path.join(app.config['STORAGE_PATH'], 'users', 'users.json')
with open(users_path, 'r') as f:
users = json.load(f)
assert 'delete_user' not in users
def test_cannot_delete_admin(self, logged_in_client):
"""Test that admin user cannot be deleted."""
response = logged_in_client.post('/users/delete/admin', follow_redirects=True)
assert response.status_code == 200
# Should see an error message
assert b'No se puede eliminar' in response.data or b'no puede' in response.data

View File

@ -6,204 +6,288 @@ from datetime import datetime
import pytz
from flask import current_app
from werkzeug.utils import secure_filename
import mimetypes
# Initialize mimetypes
mimetypes.init()
# Try to import magic library but don't fail if it's not available
try:
import magic
HAS_MAGIC = True
except ImportError:
HAS_MAGIC = False
def ensure_dir_exists(directory):
"""
Asegurar que un directorio existe, creándolo si es necesario.
Args:
directory (str): Ruta del directorio
"""
if not os.path.exists(directory):
os.makedirs(directory)
def load_json_file(file_path, default=None):
"""
Cargar un archivo JSON.
Load JSON data from a file.
Args:
file_path (str): Ruta al archivo JSON
default: Valor por defecto si el archivo no existe o no es válido
file_path (str): Path to the JSON file
default: Default value to return if file doesn't exist
Returns:
dict: Contenido del archivo JSON o valor por defecto
dict or list: Loaded JSON data or default value
"""
if default is None:
default = {}
if not os.path.exists(file_path):
return default
try:
with open(file_path, 'r', encoding='utf-8') as f:
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {} if default is None else default
except json.JSONDecodeError:
current_app.logger.error(f"Error decoding JSON from {file_path}")
return default
except Exception as e:
current_app.logger.error(f"Error loading file {file_path}: {str(e)}")
return default
def save_json_file(file_path, data):
"""
Guardar datos en un archivo JSON.
Save data to a JSON file.
Args:
file_path (str): Ruta al archivo JSON
data: Datos a guardar (deben ser serializables a JSON)
file_path (str): Path to the JSON file
data (dict or list): Data to save
Returns:
bool: True if successful, False otherwise
"""
# Asegurar que el directorio existe
directory = os.path.dirname(file_path)
ensure_dir_exists(directory)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
try:
# Ensure directory exists
directory = os.path.dirname(file_path)
if not os.path.exists(directory):
os.makedirs(directory)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
current_app.logger.error(f"Error saving file {file_path}: {str(e)}")
return False
def get_next_id(id_type):
"""
Obtener el siguiente ID disponible para proyectos o documentos.
Args:
id_type (str): Tipo de ID ('project' o 'document')
Returns:
int: Siguiente ID disponible
"""
storage_path = current_app.config['STORAGE_PATH']
indices_file = os.path.join(storage_path, 'indices.json')
storage_path = current_app.config["STORAGE_PATH"]
indices_file = os.path.join(storage_path, "indices.json")
# Cargar índices
indices = load_json_file(indices_file, {"max_project_id": 0, "max_document_id": 0})
# Incrementar y guardar
if id_type == 'project':
indices['max_project_id'] += 1
new_id = indices['max_project_id']
elif id_type == 'document':
indices['max_document_id'] += 1
new_id = indices['max_document_id']
if id_type == "project":
indices["max_project_id"] += 1
new_id = indices["max_project_id"]
elif id_type == "document":
indices["max_document_id"] += 1
new_id = indices["max_document_id"]
else:
raise ValueError(f"Tipo de ID no válido: {id_type}")
save_json_file(indices_file, indices)
return new_id
def format_project_directory_name(project_id, project_name):
"""
Formatear nombre de directorio para un proyecto.
Args:
project_id (int): ID del proyecto
project_name (str): Nombre del proyecto
Returns:
str: Nombre de directorio formateado
"""
# Sanitizar nombre de proyecto
safe_name = secure_filename(project_name)
safe_name = safe_name.replace(' ', '_')
safe_name = safe_name.replace(" ", "_")
# Formatear como @id_num_@project_name
return f"@{project_id:03d}_@{safe_name}"
def format_document_directory_name(document_id, document_name):
"""
Formatear nombre de directorio para un documento.
Args:
document_id (int): ID del documento
document_name (str): Nombre del documento
Returns:
str: Nombre de directorio formateado
"""
# Sanitizar nombre de documento
safe_name = secure_filename(document_name)
safe_name = safe_name.replace(' ', '_')
safe_name = safe_name.replace(" ", "_")
# Formatear como @id_num_@doc_name
return f"@{document_id:03d}_@{safe_name}"
def format_version_filename(version, document_name, extension):
"""
Formatear nombre de archivo para una versión de documento.
Args:
version (int): Número de versión
document_name (str): Nombre base del documento
extension (str): Extensión del archivo
Returns:
str: Nombre de archivo formateado
"""
# Sanitizar nombre
safe_name = secure_filename(document_name)
safe_name = safe_name.replace(' ', '_')
safe_name = safe_name.replace(" ", "_")
# Asegurar que la extensión no tiene el punto
if extension.startswith('.'):
if extension.startswith("."):
extension = extension[1:]
# Formatear como v001_doc_name.ext
return f"v{version:03d}_{safe_name}.{extension}"
def create_zip_archive(source_dir, files_to_include, output_path):
"""
Crear un archivo ZIP con los documentos seleccionados.
Args:
source_dir (str): Directorio fuente
files_to_include (list): Lista de archivos a incluir
output_path (str): Ruta de salida para el ZIP
Returns:
str: Ruta al archivo ZIP creado
"""
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for file_info in files_to_include:
zipf.write(
os.path.join(source_dir, file_info['path']),
arcname=file_info['arcname']
os.path.join(source_dir, file_info["path"]),
arcname=file_info["arcname"],
)
return output_path
def get_directory_size(directory):
"""
Calcular el tamaño total de un directorio en bytes.
Args:
directory (str): Ruta al directorio
Returns:
int: Tamaño en bytes
"""
total_size = 0
for dirpath, dirnames, filenames in os.walk(directory):
for filename in filenames:
file_path = os.path.join(dirpath, filename)
total_size += os.path.getsize(file_path)
return total_size
def get_file_info(file_path):
"""
Obtener información básica sobre un archivo.
Args:
file_path (str): Ruta al archivo
Returns:
dict: Información del archivo
"""
stat_info = os.stat(file_path)
return {
'filename': os.path.basename(file_path),
'size': stat_info.st_size,
'created': datetime.fromtimestamp(stat_info.st_ctime, pytz.UTC).isoformat(),
'modified': datetime.fromtimestamp(stat_info.st_mtime, pytz.UTC).isoformat(),
'path': file_path
"filename": os.path.basename(file_path),
"size": stat_info.st_size,
"created": datetime.fromtimestamp(stat_info.st_ctime, pytz.UTC).isoformat(),
"modified": datetime.fromtimestamp(stat_info.st_mtime, pytz.UTC).isoformat(),
"path": file_path,
}
def delete_directory_with_content(directory):
"""
Eliminar un directorio y todo su contenido.
Args:
directory (str): Ruta al directorio a eliminar
"""
if os.path.exists(directory):
shutil.rmtree(directory)
shutil.rmtree(directory)
def detect_file_type(file_path=None, file_data=None):
"""
Detect file type using either libmagic (if available) or file extension.
Args:
file_path: Path to the file
file_data: Binary data of the file (used with magic)
Returns:
tuple: (mime_type, encoding)
"""
if HAS_MAGIC:
# Use libmagic for more accurate file type detection
try:
mime = magic.Magic(mime=True)
if file_data:
mime_type = mime.from_buffer(file_data)
return mime_type, None
elif file_path and os.path.exists(file_path):
mime_type = mime.from_file(file_path)
return mime_type, None
except Exception as e:
current_app.logger.error(f"Error using magic library: {str(e)}")
# Fall back to extension-based detection
# Fallback method using file extensions
if file_path:
mime_type, encoding = mimetypes.guess_type(file_path)
return mime_type or "application/octet-stream", encoding
# If no path or data, return a default
return "application/octet-stream", None
def is_allowed_file(filename, allowed_extensions):
"""Check if a file has an allowed extension"""
if "." not in filename:
return False
ext = filename.rsplit(".", 1)[1].lower()
return ext in allowed_extensions

View File

@ -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

View File

@ -1,122 +1,143 @@
import os
import hashlib
import re
import magic
from flask import current_app
from flask import current_app, request, abort
from functools import wraps
from flask_login import current_user
from werkzeug.utils import secure_filename
# Try to import magic with a fallback for Windows
try:
import magic
HAS_MAGIC = True
except ImportError:
HAS_MAGIC = False
current_app.logger.warning("libmagic not found, using basic file type detection")
from utils.file_utils import detect_file_type
def check_file_type(file_stream, allowed_mime_types):
"""
Verificar el tipo MIME real de un archivo.
Args:
file_stream: Stream del archivo a verificar
allowed_mime_types (list): Lista de tipos MIME permitidos
Returns:
bool: True si el tipo es permitido, False en caso contrario
"""
# Guardar posición actual en el stream
current_position = file_stream.tell()
# Leer los primeros bytes para detectar el tipo
file_head = file_stream.read(2048)
file_stream.seek(current_position) # Restaurar posición
# Detectar tipo MIME
mime = magic.Magic(mime=True)
file_type = mime.from_buffer(file_head)
return file_type in allowed_mime_types
def calculate_checksum(file_path):
"""
Calcular el hash SHA-256 de un archivo.
Args:
file_path (str): Ruta al archivo
Returns:
str: Hash SHA-256 en formato hexadecimal
"""
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
# Leer por bloques para archivos grandes
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def sanitize_filename(filename):
"""
Sanitizar nombre de archivo.
Args:
filename (str): Nombre de archivo original
Returns:
str: Nombre de archivo sanitizado
"""
# Primero usar secure_filename de Werkzeug
safe_name = secure_filename(filename)
# Eliminar caracteres problemáticos adicionales
safe_name = re.sub(r'[^\w\s.-]', '', safe_name)
safe_name = re.sub(r"[^\w\s.-]", "", safe_name)
# Reemplazar espacios por guiones bajos
safe_name = safe_name.replace(' ', '_')
safe_name = safe_name.replace(" ", "_")
return safe_name
def generate_unique_filename(base_dir, filename_pattern):
"""
Generar nombre de archivo único basado en un patrón.
Args:
base_dir (str): Directorio base
filename_pattern (str): Patrón de nombre (puede contener {counter})
Returns:
str: Nombre de archivo único
"""
counter = 1
filename = filename_pattern.format(counter=counter)
while os.path.exists(os.path.join(base_dir, filename)):
counter += 1
filename = filename_pattern.format(counter=counter)
return filename
def permission_required(min_level):
"""
Decorador para verificar nivel de permisos.
Args:
min_level (int): Nivel mínimo requerido
Returns:
function: Decorador configurado
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
if not current_user.has_permission(min_level):
return forbidden_error()
return f(*args, **kwargs)
return decorated_function
return decorator
def forbidden_error():
"""Respuesta para error 403 (acceso denegado)."""
from flask import render_template
return render_template('error.html',
error_code=403,
error_message="Acceso denegado"), 403
return (
render_template("error.html", error_code=403, error_message="Acceso denegado"),
403,
)

View File

@ -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