diff --git a/app.py b/app.py new file mode 100644 index 0000000..e574f12 --- /dev/null +++ b/app.py @@ -0,0 +1,117 @@ +import os +from flask import Flask +from flask_login import LoginManager +from flask_bcrypt import Bcrypt +from flask_session import Session +from flask_caching import Cache +from flask_wtf.csrf import CSRFProtect + +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' + +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') + + 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.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(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) + + # Asegurar que existen los directorios de almacenamiento + initialize_storage_structure(app) + + 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 + + @app.errorhandler(403) + def forbidden(e): + 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 + +def initialize_storage_structure(app): + """Crear estructura de almacenamiento si no existe.""" + 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) + + # 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}) + +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: + json.dump(default_content, f, ensure_ascii=False, indent=2) + +if __name__ == '__main__': + from flask import render_template + app = create_app() + app.run(debug=True) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..0ec1306 --- /dev/null +++ b/config.py @@ -0,0 +1,86 @@ +import os +from datetime import timedelta +from dotenv import load_dotenv + +# Cargar variables de entorno desde archivo .env +load_dotenv() + +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') + 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' + CACHE_DEFAULT_TIMEOUT = 300 + + # Configuración de logging + LOG_DIR = os.path.join(STORAGE_PATH, 'logs') + + @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.""" + DEBUG = True + + +class TestingConfig(Config): + """Configuración para entorno de pruebas.""" + TESTING = True + STORAGE_PATH = 'test_storage' + WTF_CSRF_ENABLED = False # Deshabilitar CSRF para pruebas + + +class ProductionConfig(Config): + """Configuración para entorno de producción.""" + DEBUG = False + TESTING = False + + @classmethod + def init_app(cls, 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') + + +# Diccionario con las configuraciones disponibles +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} \ No newline at end of file diff --git a/crear_directorios.bat b/crear_directorios.bat new file mode 100644 index 0000000..82e00d9 --- /dev/null +++ b/crear_directorios.bat @@ -0,0 +1,124 @@ +@echo off +REM Crear estructura de directorios principal +mkdir document_manager +cd document_manager + +REM Archivos principales +type nul > app.py +type nul > config.py +type nul > requirements.txt +type nul > README.md + +REM Estructura de módulos +mkdir services +mkdir utils +mkdir middleware +mkdir routes +mkdir static\css +mkdir static\js\lib +mkdir static\img\icons +mkdir static\img\file-types +mkdir templates\auth +mkdir templates\projects +mkdir templates\documents +mkdir templates\schemas +mkdir templates\users +mkdir templates\admin +mkdir tests +mkdir storage\logs +mkdir storage\schemas +mkdir storage\users +mkdir storage\filetypes +mkdir storage\projects + +REM Archivos vacíos para iniciar los módulos +type nul > services\__init__.py +type nul > services\auth_service.py +type nul > services\user_service.py +type nul > services\project_service.py +type nul > services\document_service.py +type nul > services\schema_service.py +type nul > services\export_service.py +type nul > services\index_service.py + +type nul > utils\__init__.py +type nul > utils\file_utils.py +type nul > utils\security.py +type nul > utils\validators.py +type nul > utils\logger.py + +type nul > middleware\__init__.py +type nul > middleware\auth_middleware.py +type nul > middleware\permission_check.py + +type nul > routes\__init__.py +type nul > routes\auth_routes.py +type nul > routes\user_routes.py +type nul > routes\project_routes.py +type nul > routes\document_routes.py +type nul > routes\schema_routes.py +type nul > routes\admin_routes.py + +REM Crear archivos estáticos básicos +type nul > static\css\bootstrap.min.css +type nul > static\css\main.css +type nul > static\css\login.css +type nul > static\css\projects.css +type nul > static\css\documents.css + +type nul > static\js\auth.js +type nul > static\js\projects.js +type nul > static\js\documents.js +type nul > static\js\schemas.js +type nul > static\js\users.js +type nul > static\js\admin.js + +REM Crear plantillas HTML básicas +type nul > templates\base.html +type nul > templates\error.html + +type nul > templates\auth\login.html +type nul > templates\auth\reset_password.html + +type nul > templates\projects\list.html +type nul > templates\projects\create.html +type nul > templates\projects\edit.html +type nul > templates\projects\view.html + +type nul > templates\documents\list.html +type nul > templates\documents\upload.html +type nul > templates\documents\versions.html +type nul > templates\documents\download.html + +type nul > templates\schemas\list.html +type nul > templates\schemas\create.html +type nul > templates\schemas\edit.html + +type nul > templates\users\list.html +type nul > templates\users\create.html +type nul > templates\users\edit.html + +type nul > templates\admin\dashboard.html +type nul > templates\admin\filetypes.html +type nul > templates\admin\system.html + +REM Crear archivos de prueba +type nul > tests\__init__.py +type nul > tests\conftest.py +type nul > tests\test_auth.py +type nul > tests\test_projects.py +type nul > tests\test_documents.py +type nul > tests\test_schemas.py +type nul > tests\json_reporter.py + +REM Crear archivos de almacenamiento +type nul > storage\indices.json +type nul > storage\logs\access.log +type nul > storage\logs\error.log +type nul > storage\logs\system.log +type nul > storage\schemas\schema.json +type nul > storage\users\users.json +type nul > storage\filetypes\filetypes.json + +echo Estructura de directorios y archivos creada con éxito. +pause diff --git a/descripcion.md b/descripcion.md index 7fc380a..bfd60d1 100644 --- a/descripcion.md +++ b/descripcion.md @@ -1,267 +1,948 @@ # Sistema de Gestión de Documentos para Proyectos de Ingeniería - ARCH -## Descripción General +## 1. Visión General del Proyecto -Un sistema robusto de gestión documental destinado a equipos de ingeniería, diseñado para almacenar, organizar y versionar los archivos críticos de cada proyecto. El sistema utiliza una arquitectura basada en archivos JSON y sistema de ficheros para facilitar el mantenimiento y las copias de seguridad. +ARCH es un sistema de gestión documental diseñado específicamente para equipos de ingeniería, con el propósito de almacenar, organizar y versionar los archivos críticos de cada proyecto. La aplicación proporciona una estructura jerárquica para proyectos, control de versiones para documentos, y esquemas personalizables según el tipo de proyecto. -## Estructura de Usuarios y Permisos +### Objetivos Principales -### Niveles de Usuario -- **Nivel 0**: Usuario básico (solo lectura de documentos autorizados) -- **Nivel 5000**: Gestor de proyectos (creación y gestión de proyectos) -- **Nivel 9000**: Mantenimiento (configuración técnica y tipos de archivo) -- **Nivel 9999**: Administrador (acceso total al sistema) +- Proporcionar un repositorio centralizado para documentación técnica de proyectos +- Mantener un historial completo de versiones de cada documento +- Establecer un sistema de permisos basado en niveles de usuario +- Facilitar la búsqueda y recuperación de información de proyectos +- Implementar una arquitectura simple y robusta basada en archivos para facilitar el mantenimiento y backups -### Atributos de Usuario -- Nombre completo -- Nombre de usuario (username) -- Correo electrónico -- Nivel de permisos -- Idioma preferido -- Fecha de caducidad -- Empresa a la que pertenece +### Principios de Diseño -## Componentes del Sistema +- **Simplicidad**: Enfoque en funcionalidades esenciales con arquitectura sencilla +- **Robustez**: Control de versiones confiable y validación de datos +- **Seguridad**: Sistema de permisos granular y validación de archivos +- **Mantenibilidad**: Estructura basada en archivos JSON para fácil inspección y backup +- **Escalabilidad**: Diseño modular que permite agregar funcionalidades en el futuro -### Gestión de Tipos de Archivo -- Extensiones aceptadas en el sistema -- Descripción de cada tipo de archivo -- Nivel mínimo requerido para administrar: 9000 +## 2. Arquitectura del Sistema -### Gestión de Esquemas -- Código único del esquema -- Descripción detallada -- Lista de documentos asociados con: - - Tipo de archivo permitido - - Nivel mínimo requerido para visualización - - Nivel mínimo requerido para cargar nuevas versiones -- Nivel mínimo requerido para gestionar: 5000 +### 2.1 Enfoque Técnico -### Gestión de Proyectos -- Código alfanumérico único (10 caracteres) -- Referencia a proyecto padre (estructura jerárquica) -- Esquema asociado (define qué documentos contiene) -- Descripción del proyecto -- Cliente -- Destinación/ubicación -- Año de creación -- Nivel mínimo requerido para crear/modificar: 5000 +La aplicación sigue una arquitectura basada en archivos JSON y sistema de ficheros, eliminando la necesidad de una base de datos relacional. Esto proporciona: -## Estructura del Frontend +- Facilidad de backup del sistema completo +- Inspección directa de datos mediante editores de texto +- Estructura de datos transparente +- Almacenamiento jerárquico que refleja la organización de proyectos + +### 2.2 Componentes del Sistema ``` -Frontend Sistema de Gestión de Documentos - ARCH -│ -├── Página de Inicio/Login -│ ├── Formulario de inicio de sesión -│ └── Información básica del sistema -│ -├── Panel Principal -│ │ -│ ├── Cabecera -│ │ ├── Logo del sistema - ARCH -│ │ ├── Barra de búsqueda avanzada -│ │ │ └── Filtros por: tipo de proyecto, tipo de máquina, cliente, año -│ │ └── Menú de usuario (perfil, configuración, salir) -│ │ -│ ├── Menú de Navegación -│ │ ├── Administración Proyectos (nivel ≥ 5000) -│ │ ├── Administración de usuarios (nivel ≥ 9000) -│ │ ├── Administración de esquemas (nivel ≥ 5000) -│ │ ├── Diagnóstico del sistema (nivel = 9999) -│ │ └── Copia de seguridad (nivel ≥ 9000) -│ │ -│ └── Área de Contenido Principal -│ ├── Listado de Proyectos (resultados de búsqueda) -│ │ └── Tabla con columnas: código, descripción, cliente, destinación, año, acciones -│ └── Botón "Nuevo Proyecto" (visible si nivel ≥ 5000) -│ -├── Gestión de Proyectos -│ │ -│ └── Vista de Proyecto -│ ├── Información del proyecto -│ │ ├── Datos básicos (código, descripción, cliente, destinación, año) -│ │ └── Árbol de navegación de proyectos padre/hijo -│ │ -│ └── Listado de Documentos -│ └── Tabla de documentos -│ ├── Columnas: nombre, tipo, última versión, fecha de actualización -│ └── Acciones: ver historial, descargar, subir nueva versión, editar descripción -│ -├── Gestión de Documentos -│ │ -│ ├── Subir Documento -│ │ ├── Selector de archivo (con validación de tipo permitido) -│ │ └── Campos de metadatos (descripción, etiquetas) -│ │ -│ └── Historial de Versiones -│ ├── Información del documento -│ └── Lista cronológica de versiones -│ └── Detalle por versión: fecha, usuario, descripción, acciones -│ -└── Módulos de Administración - │ - ├── Gestión de Usuarios (nivel ≥ 9000) - │ ├── Listado de usuarios - │ ├── Formulario de creación/edición - │ └── Gestión de niveles de acceso - │ - ├── Gestión de Esquemas (nivel ≥ 5000) - │ ├── Listado de esquemas - │ ├── Editor de esquemas - │ └── Asignación de tipos de documentos y niveles - │ - ├── Gestión de Tipos de Archivo (nivel ≥ 9000) - │ ├── Listado de extensiones permitidas - │ └── Configuración de nuevos tipos - │ - └── Diagnóstico del Sistema (nivel = 9999) - ├── Estado del almacenamiento - ├── Logs de actividad - └── Herramientas de mantenimiento ++-------------------+ +-------------------+ +-------------------+ +| Interfaz Web | | Lógica de | | Sistema de | +| (Flask + Jinja) |----->| Negocio |----->| Archivos | +| | | (Servicios) | | (JSON) | ++-------------------+ +-------------------+ +-------------------+ + ^ ^ ^ + | | | + v v v ++-------------------+ +-------------------+ +-------------------+ +| Autenticación | | Validación | | Indexación | +| (Flask-Login) | | (WTForms) | | (Servicio) | ++-------------------+ +-------------------+ +-------------------+ ``` -## Flujos de Trabajo Principales +### 2.3 Flujo de Datos -### Búsqueda y Acceso a Proyectos -1. El usuario inicia sesión con sus credenciales -2. Utiliza la barra de búsqueda con filtros avanzados -3. El sistema muestra los proyectos que cumplen con los criterios -4. El usuario selecciona un proyecto para ver sus detalles y documentos +1. El usuario interactúa con la interfaz web +2. El sistema autentica y verifica permisos +3. Los controladores (rutas) procesan la solicitud +4. Los servicios implementan la lógica de negocio +5. Los datos se almacenan/recuperan del sistema de archivos +6. Se devuelve la respuesta formateada al usuario -### Gestión de Documentos -1. Dentro de un proyecto, el usuario visualiza la lista de documentos según el esquema -2. Puede descargar documentos si tiene el nivel mínimo requerido -3. Si tiene permisos suficientes, puede subir nuevas versiones -4. El sistema mantiene un historial completo de todas las versiones +## 3. Modelo de Datos -### Administración del Sistema -1. Usuarios con nivel ≥ 5000 pueden crear y gestionar proyectos -2. Usuarios con nivel ≥ 9000 pueden administrar usuarios y tipos de archivo -3. Administradores (nivel 9999) tienen acceso completo, incluyendo diagnóstico +### 3.1 Usuarios -## Características Técnicas +**Ubicación**: `/storage/users/users.json` -- Arquitectura basada en archivos JSON para facilitar backups -- Sistema de versionado completo para cada documento -- Control de acceso granular basado en niveles de usuario -- Estructura jerárquica de proyectos con relaciones padre-hijo -- Validación de tipos de archivo según configuración del sistema -- Interfaz intuitiva con búsqueda avanzada y filtros contextuales +```json +{ + "usuario1": { + "nombre": "Nombre Completo", + "username": "usuario1", + "email": "usuario1@ejemplo.com", + "password_hash": "hash_seguro_bcrypt", + "nivel": 5000, + "idioma": "es", + "fecha_caducidad": "2024-12-31", + "empresa": "Empresa A", + "estado": "activo", + "ultimo_acceso": "2023-06-15T14:30:00Z" + } +} +``` -El sistema ARCH proporciona una solución completa para la gestión documental de proyectos de ingeniería, con un enfoque en la facilidad de mantenimiento, control de versiones y seguridad basada en niveles de acceso definidos. +### 3.2 Tipos de Archivo +**Ubicación**: `/storage/filetypes/filetypes.json` -## Estructura de Archivos y Directorios +```json +{ + "pdf": { + "extension": "pdf", + "descripcion": "Documento PDF", + "mime_type": "application/pdf", + "tamano_maximo": 20971520 + } +} +``` +### 3.3 Esquemas + +**Ubicación**: `/storage/schemas/schema.json` + +```json +{ + "ESQ001": { + "codigo": "ESQ001", + "descripcion": "Proyecto estándar", + "fecha_creacion": "2023-05-10T10:00:00Z", + "creado_por": "admin", + "documentos": [ + { + "tipo": "pdf", + "nombre": "Manual de Usuario", + "nivel_ver": 0, + "nivel_editar": 5000 + }, + { + "tipo": "dwg", + "nombre": "Planos Técnicos", + "nivel_ver": 0, + "nivel_editar": 5000 + } + ] + } +} +``` + +### 3.4 Proyectos + +**Ubicación**: `/storage/projects/@id_num_@project_name_dir/project_meta.json` + +```json +{ + "codigo": "PROJ001", + "proyecto_padre": null, + "esquema": "ESQ001", + "descripcion": "Proyecto de ejemplo", + "cliente": "Cliente A", + "destinacion": "Planta Norte", + "ano_creacion": 2023, + "fecha_creacion": "2023-06-01T09:30:00Z", + "creado_por": "usuario1", + "estado": "activo", + "ultima_modificacion": "2023-06-15T14:30:00Z", + "modificado_por": "usuario2" +} +``` + +### 3.5 Documentos + +**Ubicación**: `/storage/projects/@id_num_@project_name_dir/documents/@id_num_@doc_name/meta.json` + +```json +{ + "document_id": "001_manual_usuario", + "original_filename": "manual_v1.pdf", + "versions": [ + { + "version": 1, + "filename": "v001_manual_usuario.pdf", + "created_at": "2023-06-10T11:30:00Z", + "created_by": "usuario1", + "description": "Versión inicial del manual", + "file_size": 1234567, + "mime_type": "application/pdf", + "checksum": "7b9fc3fc8438d11a52d8ec5e4d274c6dfa2520c5ac3afeb5d9a46d3b67c8bdc9", + "downloads": [ + { + "user_id": "usuario2", + "downloaded_at": "2023-06-11T15:45:00Z" + } + ] + } + ] +} +``` + +## 4. Estructura de Archivos y Directorios + +``` document_manager/ +│ ├── app.py # Punto de entrada de la aplicación +├── config.py # Configuración global (entornos, rutas, etc.) ├── requirements.txt # Dependencias del proyecto -├── services/ # Servicios del sistema -├── routes/ # Rutas de la aplicación -├── static/ # Archivos estáticos -├── templates/ # Plantillas HTML -│ ├── projects/ # Plantillas de proyectos -│ └── documents/ # Plantillas de documentos -└── storage/ # Almacenamiento de datos - ├── indices.json # Índices centralizados +├── README.md # Documentación básica +│ +├── services/ # Lógica de negocio +│ ├── __init__.py +│ ├── auth_service.py # Servicio de autenticación +│ ├── user_service.py # Gestión de usuarios +│ ├── project_service.py # Gestión de proyectos +│ ├── document_service.py # Gestión de documentos +│ ├── schema_service.py # Gestión de esquemas +│ ├── export_service.py # Exportación de proyectos +│ └── index_service.py # Servicio de indexación +│ +├── utils/ # Utilidades generales +│ ├── __init__.py +│ ├── file_utils.py # Utilidades para manejo de archivos +│ ├── security.py # Funciones de seguridad (hash, validación) +│ ├── validators.py # Validadores de datos +│ └── logger.py # Configuración de logs +│ +├── middleware/ # Middleware para Flask +│ ├── __init__.py +│ ├── auth_middleware.py # Verificación de autenticación +│ └── permission_check.py # Verificación de permisos +│ +├── routes/ # Endpoints de la API +│ ├── __init__.py +│ ├── auth_routes.py # Rutas de autenticación (login/logout) +│ ├── user_routes.py # Rutas de gestión de usuarios +│ ├── project_routes.py # Rutas de gestión de proyectos +│ ├── document_routes.py # Rutas de gestión de documentos +│ ├── schema_routes.py # Rutas de gestión de esquemas +│ └── admin_routes.py # Rutas administrativas +│ +├── static/ # Archivos estáticos +│ ├── css/ +│ │ ├── bootstrap.min.css # Framework CSS +│ │ ├── main.css # Estilos generales +│ │ ├── login.css # Estilos de login +│ │ ├── projects.css # Estilos para proyectos +│ │ └── documents.css # Estilos para documentos +│ │ +│ ├── js/ +│ │ ├── lib/ # Librerías JavaScript externas +│ │ │ ├── bootstrap.bundle.min.js +│ │ │ └── jquery.min.js +│ │ │ +│ │ ├── auth.js # Funciones de autenticación +│ │ ├── projects.js # Funciones para proyectos +│ │ ├── documents.js # Funciones para documentos +│ │ ├── schemas.js # Funciones para esquemas +│ │ ├── users.js # Funciones para gestión de usuarios +│ │ └── admin.js # Funciones administrativas +│ │ +│ └── img/ +│ ├── logo.png # Logo del sistema +│ ├── icons/ # Iconos de la interfaz +│ └── file-types/ # Iconos para tipos de archivo +│ +├── templates/ # Plantillas HTML +│ ├── base.html # Plantilla base +│ ├── error.html # Páginas de error +│ │ +│ ├── auth/ # Plantillas de autenticación +│ │ ├── login.html +│ │ └── reset_password.html +│ │ +│ ├── projects/ # Plantillas de proyectos +│ │ ├── list.html # Lista de proyectos +│ │ ├── create.html # Crear proyecto +│ │ ├── edit.html # Editar proyecto +│ │ └── view.html # Ver proyecto +│ │ +│ ├── documents/ # Plantillas de documentos +│ │ ├── list.html # Lista de documentos +│ │ ├── upload.html # Subir documento +│ │ ├── versions.html # Ver versiones +│ │ └── download.html # Descargar documento +│ │ +│ ├── schemas/ # Plantillas de esquemas +│ │ ├── list.html # Lista de esquemas +│ │ ├── create.html # Crear esquema +│ │ └── edit.html # Editar esquema +│ │ +│ ├── users/ # Plantillas de usuarios +│ │ ├── list.html # Lista de usuarios +│ │ ├── create.html # Crear usuario +│ │ └── edit.html # Editar usuario +│ │ +│ └── admin/ # Plantillas administrativas +│ ├── dashboard.html # Panel de administración +│ ├── filetypes.html # Gestión de tipos de archivo +│ └── system.html # Diagnóstico del sistema +│ +├── tests/ # Pruebas unitarias y de integración +│ ├── __init__.py +│ ├── conftest.py # Configuración y fixtures para pytest +│ ├── test_auth.py # Pruebas de autenticación +│ ├── test_projects.py # Pruebas de proyectos +│ ├── test_documents.py # Pruebas de documentos +│ ├── test_schemas.py # Pruebas de esquemas +│ └── json_reporter.py # Reportero personalizado para JSON +│ +└── storage/ # Almacenamiento de datos + ├── indices.json # Índices centralizados + ├── logs/ # Directorio para logs + │ ├── access.log # Registro de accesos + │ ├── error.log # Registro de errores + │ └── system.log # Registro del sistema + │ ├── schemas/ - │ ├── schema.json # Lista de Esquemas + │ ├── schema.json # Lista de Esquemas + │ ├── users/ - │ ├── users.json # Usuarios regulares + │ ├── users.json # Usuarios del sistema + │ + ├── filetypes/ + │ ├── filetypes.json # Tipos de archivo permitidos + │ └── projects/ ├── @id_num_@project_name_dir/ │ ├── project_meta.json # Metadatos del proyecto - │ ├── schema.json # Esquema usado en particular - │ ├── permissions.json # Permisos de acceso - │ └── documents/ # Documentos del proyecto + │ ├── schema.json # Esquema usado en particular + │ ├── permissions.json # Permisos de acceso + │ └── documents/ # Documentos del proyecto │ ├── @id_num_@doc_name/ - │ │ ├── v001_doc_name # Archivo versión 1 - │ │ ├── v002_doc_name # Archivo versión 2 - │ │ ├── meta.json # Lista de metadatos del documento y descripcion cada version - │ ├── id_num_doc_name/ - │ │ ├── v001_doc_name # Archivo versión 1 - │ │ ├── meta.json # Lista de metadatos del documento y descripcion cada version + │ │ ├── v001_doc_name # Archivo versión 1 + │ │ ├── v002_doc_name # Archivo versión 2 + │ │ ├── meta.json # Metadatos y versiones │ └── ... └── ... +``` -@id_num : numero consecutivo de proyecto o documento. Cada vez que se inicia la aplicación se busca el numero mas alto para usar el numero siguiente en un proyecto nuevo. -@project_name : Codigo + "_" + Descripcion ( hasta 30 letras - filtrado de letras admitidas por el filesystem ) -@doc_name : Descripcion ( hasta 30 letras - filtrado de letras admitidas por el filesystem ) +## 5. Librerías y Dependencias +### 5.1 Lista de Dependencias -# Sistema de Gestión de Documentos para Proyectos de Ingeniería +``` +# Componentes principales +Flask==2.3.3 +Werkzeug==2.3.7 +Jinja2==3.1.2 +itsdangerous==2.1.2 +click==8.1.3 -## Descripción General +# Gestión de formularios y validación +Flask-WTF==1.2.1 +WTForms==3.0.1 -El Sistema de Gestión de Documentos es una aplicación web desarrollada con Flask que permite almacenar y gestionar backups de archivos y documentos de proyectos de ingeniería. El sistema proporciona una organización jerárquica basada en proyectos, control de versiones de documentos, y esquemas dinámicos personalizables para diferentes tipos de proyectos. +# Autenticación y seguridad +Flask-Login==0.6.2 +Flask-Bcrypt==1.0.1 -## Enfoque Técnico +# Manejo de fechas y zonas horarias +python-dateutil==2.8.2 +pytz==2023.3 -Hemos optado por un enfoque simple y robusto basado completamente en JSON y sistema de archivos, eliminando la necesidad de una base de datos relacional tradicional. Esto simplifica la arquitectura, facilita los backups y permite una fácil inspección de datos. Para mantener un rendimiento óptimo, implementamos un sistema de caché e índices. +# Configuración y entorno +python-dotenv==1.0.0 -### Decisiones Técnicas Clave +# Gestión de sesiones +Flask-Session==0.5.0 -**Almacenamiento basado en archivos**: Toda la información se almacena en archivos JSON dentro de una estructura de directorios. -**Front-end con Bootstrap**: Interface sencilla y responsive utilizando Bootstrap para una rápida implementación. +# HTTP y comunicaciones +requests==2.31.0 -## Estructura de Datos +# Validación de archivos +python-magic==0.4.27 -### Proyectos -- Cada proyecto tiene un directorio único identificado @id_num_@project_name_dir basado es un numero unico incremental. El nombre fisico en disco se establece como un numero consecutivo rellenado con 6 ceros a la izquierda segudido de un punto y luego el nombre del proyecto definido como @project_name. El numero consecutivo es irrepetible. El sistema al iniciar debe comprobar cual es el numero maximo actual. Este numero se incrementa cada vez que se crea un nuevo proyecto. -- Los proyectos contienen metadatos, permisos, un esquema y documentos. -- Al crear un proyecto se asocia a un esquema. Este esquema se copia en el directorio del proyecto. Luego se puede modificar dentro del proyecto. -- Los proyectos pueden tener proyectos padres. +# Mejoras de desarrollo y mantenimiento +loguru==0.7.0 +pytest==7.4.0 +pytest-cov==4.1.0 +pytest-html==3.2.0 -### Usuarios -- Almacenados en archivos JSON. +# Servidor de producción +gunicorn==21.2.0 -### Documentos -- Organizados en directorios por proyecto. El nombre del directorio sigue el mismo formato que los nombres de directorios, inicia con un numero consecutivo y luego el nombre del documento hasta 30 caracteres segun definido en @doc_name. -- Sistema de versionado que mantiene todas las versiones anteriores. -- Cada versión incluye metadatos como autor, fecha y descripcion de la version en un archivo json. +# Programación de tareas +APScheduler==3.10.1 +# Caché +Flask-Caching==2.0.2 +``` -## Funcionalidades Principales +### 5.2 Propósito de Cada Librería -### 1. Gestión de Usuarios -- Registro y autenticación de usuarios -- Gestión de roles (administrador, usuario regular, mantenimiento) -- Perfiles de usuario con información personalizada +#### Componentes Esenciales +- **Flask**: Framework web principal para toda la aplicación +- **Werkzeug, Jinja2, itsdangerous, click**: Dependencias de Flask -### 2. Gestión de Proyectos +#### Formularios y Validación +- **Flask-WTF, WTForms**: Manejo de formularios con validación (creación/edición de proyectos, documentos) + +#### Autenticación y Seguridad +- **Flask-Login**: Gestión de autenticación de usuarios +- **Flask-Bcrypt**: Hash seguro de contraseñas + +#### Fechas y Zonas Horarias +- **python-dateutil, pytz**: Manipulación avanzada de fechas en metadatos + +#### Configuración +- **python-dotenv**: Manejo de variables de entorno para diferentes ambientes + +#### Sesiones +- **Flask-Session**: Gestión avanzada de sesiones (timeout, backend personalizable) + +#### Comunicaciones +- **requests**: Para comunicaciones HTTP (si se requieren integraciones futuras) + +#### Validación de Archivos +- **python-magic**: Detección del tipo real de archivos (seguridad) + +#### Desarrollo y Mantenimiento +- **loguru**: Sistema de logging mejorado +- **pytest, pytest-cov, pytest-html**: Framework de pruebas con generación de reportes +- **gunicorn**: Servidor WSGI para producción +- **APScheduler**: Tareas programadas (mantenimiento, backups) +- **Flask-Caching**: Sistema de caché para optimizar rendimiento + +## 6. Funcionalidades del Sistema + +### 6.1 Gestión de Usuarios +- Registro de nuevos usuarios (solo por administradores) +- Autenticación mediante usuario y contraseña +- Gestión de niveles de acceso +- Perfiles de usuario +- Reseteo de contraseñas +- Tiempo de expiración de sesión (timeout) + +### 6.2 Gestión de Proyectos - Creación y configuración de proyectos -- Asignación de usuarios con permisos granulares -- Búsqueda y filtrado de proyectos -- Esquemas personalizables por proyecto una vez creado el proyecto +- Estructura jerárquica (proyectos padre/hijo) +- Asignación de esquemas +- Búsqueda avanzada de proyectos +- Filtrado por múltiples criterios -### 3. Gestión de Documentos -- Sistema de Carga y Descarga para archivos. Cada documento es un unico archivo. -- Versionado completo de documentos -- Historial de cambios por documento -- Metadatos y comentarios por versión +### 6.3 Gestión de Documentos +- Carga y descarga de documentos +- Versionado completo con historial +- Validación de tipos de archivo +- Registro de descargas para auditoría +- Metadatos y descripciones por versión +- Exportación de proyectos con la última versión de documentos -### 4. Control de Acceso -- Permisos granulares por proyecto -- Registro de actividad (logs) -- Restricción de acceso a nivel de proyecto y documento +### 6.4 Gestión de Esquemas +- Definición de esquemas de proyecto +- Asignación de tipos de documentos +- Configuración de permisos por tipo de documento +- Personalización de esquemas para proyectos individuales -## Flujos de Trabajo Principales +### 6.5 Administración del Sistema +- Configuración de tipos de archivo permitidos +- Diagnóstico del estado del sistema +- Logs de actividad para auditoría +- Gestión de usuarios y permisos -### Creación de un Proyecto -1. Usuario se autentica -2. Crea nuevo proyecto y define metadatos básicos -3. Opcionalmente personaliza el esquema del proyecto o eligue un esquema ya definido -4. Completa la información requerida según el esquema -5. Sistema asigna permisos iniciales +## 7. Flujos de Trabajo Principales -### Gestión de Documentos -1. Usuario accede a un proyecto -2. Realiza Carga/Descarga de un documento (opcional) -3. Sistema registra nueva versión manteniendo historial +### 7.1 Autenticación de Usuario +``` ++-------------+ +-------------+ +--------------+ +----------------+ +| Acceso a | | Formulario | | Verificación | | Redirección a | +| sistema |---->| de login |---->| credenciales |---->| panel principal| ++-------------+ +-------------+ +--------------+ +----------------+ + | + | [Error] + v + +-------------+ + | Mensaje de | + | error | + +-------------+ +``` -### Actualización de Datos del Proyecto -1. Usuario accede a un proyecto con permisos de escritura -2. Edita la información a través de formularios -3. Sistema valida los datos según las reglas del esquema -4. Se guardan los cambios manteniendo registro de modificaciones \ No newline at end of file +### 7.2 Creación de Proyecto +``` ++----------------+ +---------------+ +---------------+ +----------------+ +| Usuario accede | | Formulario de | | Validación de | | Asignación de | +| a "Crear |---->| nuevo proyecto|---->| datos |---->| ID y creación | +| Proyecto" | | | | | | de estructura | ++----------------+ +---------------+ +---------------+ +----------------+ + | + v + +----------------+ + | Redirección a | + | vista de | + | proyecto | + +----------------+ +``` + +### 7.3 Gestión de Documentos +``` ++----------------+ +----------------+ +----------------+ +----------------+ +| Usuario accede | | Visualización | | Carga de | | Creación de | +| a proyecto |---->| de documentos |---->| documento o |---->| nueva versión | +| | | del proyecto | | nueva versión | | con metadatos | ++----------------+ +----------------+ +----------------+ +----------------+ + | + | [Ver historial] + v + +----------------+ +----------------+ + | Visualización | | Opción de | + | de versiones |---->| descarga de | + | del documento | | versión | + +----------------+ +----------------+ +``` + +### 7.4 Exportación de Proyecto +``` ++----------------+ +----------------+ +----------------+ +----------------+ +| Usuario | | Selección de | | Sistema | | Descarga del | +| selecciona |---->| documentos a |---->| empaqueta |---->| archivo ZIP | +| "Exportar" | | exportar | | documentos | | con documentos | ++----------------+ +----------------+ +----------------+ +----------------+ +``` + +## 8. Seguridad del Sistema + +### 8.1 Autenticación +- Contraseñas almacenadas con hash seguro (bcrypt) +- Timeout de sesión por inactividad +- Protección contra fuerza bruta (límite de intentos) + +### 8.2 Autorización +- Sistema de niveles de acceso granular +- Verificación de permisos en cada operación +- Separación de responsabilidades por nivel + +### 8.3 Seguridad de Archivos +- Validación de tipos de archivo mediante contenido (python-magic) +- Almacenamiento de checksums para verificar integridad +- Limitación de tamaño por tipo de archivo + +### 8.4 Logging y Auditoría +- Registro de todas las acciones importantes +- Log de accesos para auditoría +- Tracking de descargas de documentos + +## 9. Estrategia de Pruebas + +### 9.1 Framework de Pruebas +- Uso de pytest como framework principal +- Cobertura de código con pytest-cov +- Generación de reportes en JSON para análisis + +### 9.2 Estructura de Pruebas +``` +tests/ +├── conftest.py # Configuración y fixtures compartidos +├── test_auth.py # Pruebas de autenticación +├── test_projects.py # Pruebas de gestión de proyectos +├── test_documents.py # Pruebas de gestión de documentos +├── test_schemas.py # Pruebas de esquemas +└── json_reporter.py # Plugin para generar reportes JSON +``` + +### 9.3 Casos de Prueba Principales +- Autenticación de usuarios +- Creación y gestión de proyectos +- Carga, versión y descarga de documentos +- Validación de esquemas +- Verificación de permisos +- Exportación de proyectos + +### 9.4 Generación de Reportes +Se implementará un plugin personalizado para pytest que generará reportes en formato JSON, incluyendo: +- Cantidad de pruebas ejecutadas +- Resultados (éxito/fallo) +- Duración de cada prueba +- Cobertura de código +- Detalles de errores encontrados + +### 9.5 Ejemplo de Configuración de Pytest + +```python +# conftest.py + +import pytest +import os +import json +import shutil +from app import create_app + +@pytest.fixture +def app(): + """Crear una instancia de la aplicación para pruebas.""" + # Configuración de prueba + test_config = { + 'TESTING': True, + 'STORAGE_PATH': 'test_storage', + 'SECRET_KEY': 'test_key' + } + + # Crear directorio de almacenamiento para pruebas + if not os.path.exists('test_storage'): + os.makedirs('test_storage') + + # Crear estructura básica + for dir in ['users', 'schemas', 'filetypes', 'projects', 'logs']: + if not os.path.exists(f'test_storage/{dir}'): + os.makedirs(f'test_storage/{dir}') + + # Crear app con configuración de prueba + app = create_app(test_config) + + yield app + + # Limpiar después de las pruebas + shutil.rmtree('test_storage') + +@pytest.fixture +def client(app): + """Cliente de prueba para la aplicación.""" + return app.test_client() + +@pytest.fixture +def auth(client): + """Helper para pruebas de autenticación.""" + class AuthActions: + def login(self, username='admin', password='password'): + return client.post('/login', data={ + 'username': username, + 'password': password + }, follow_redirects=True) + + def logout(self): + return client.get('/logout', follow_redirects=True) + + return AuthActions() +``` + +```python +# json_reporter.py + +import json +import pytest +import datetime +import os + +class JSONReporter: + def __init__(self, config): + self.config = config + self.results = { + 'summary': { + 'total': 0, + 'passed': 0, + 'failed': 0, + 'skipped': 0, + 'duration': 0, + 'timestamp': datetime.datetime.now().isoformat() + }, + 'tests': [] + } + + def pytest_runtest_logreport(self, report): + 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: + result = 'failed' + self.results['summary']['failed'] += 1 + else: + result = 'skipped' + self.results['summary']['skipped'] += 1 + + self.results['tests'].append({ + 'name': report.nodeid, + 'result': result, + 'duration': report.duration, + 'error': str(report.longrepr) if hasattr(report, 'longrepr') and report.longrepr else None + }) + + def pytest_sessionfinish(self, session): + self.results['summary']['duration'] = session.config.hook.pytest_report_teststatus.get_duration() + + with open('test_results.json', 'w') as f: + json.dump(self.results, f, indent=2) + +@pytest.hookimpl(trylast=True) +def pytest_configure(config): + config.pluginmanager.register(JSONReporter(config), 'json_reporter') +``` + +## 10. Guía de Implementación + +### 10.1 Configuración del Entorno + +1. Crear entorno virtual: +```bash +python -m venv venv +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows +``` + +2. Instalar dependencias: +```bash +pip install -r requirements.txt +``` + +3. Configurar variables de entorno (crear archivo `.env`): +``` +FLASK_APP=app.py +FLASK_ENV=development +SECRET_KEY=clave_secreta_generada +STORAGE_PATH=storage +``` + +### 10.2 Inicialización del Sistema + +1. Crear estructura de directorios: +```bash +mkdir -p storage/{logs,schemas,users,filetypes,projects} +``` + +2. Inicializar archivos base: +```python +# Script para inicializar archivos base +import json +import os +import bcrypt + +# Crear usuarios iniciales +admin_password = bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8') +users = { + "admin": { + "nombre": "Administrador", + "username": "admin", + "email": "admin@ejemplo.com", + "password_hash": admin_password, + "nivel": 9999, + "idioma": "es", + "fecha_caducidad": None, + "empresa": "ARCH", + "estado": "activo" + } +} + +# Crear tipos de archivo iniciales +filetypes = { + "pdf": { + "extension": "pdf", + "descripcion": "Documento PDF", + "mime_type": "application/pdf", + "tamano_maximo": 20971520 # 20MB + }, + "txt": { + "extension": "txt", + "descripcion": "Documento de texto", + "mime_type": "text/plain", + "tamano_maximo": 5242880 # 5MB + }, + "zip": { + "extension": "zip", + "descripcion": "Archivo comprimido", + "mime_type": "application/zip", + "tamano_maximo": 104857600 # 100MB + } +} + +# Crear esquema inicial +schemas = { + "ESQ001": { + "codigo": "ESQ001", + "descripcion": "Proyecto estándar", + "fecha_creacion": "2023-05-10T10:00:00Z", + "creado_por": "admin", + "documentos": [ + { + "tipo": "pdf", + "nombre": "Manual de Usuario", + "nivel_ver": 0, + "nivel_editar": 5000 + }, + { + "tipo": "zip", + "nombre": "Archivos Fuente", + "nivel_ver": 5000, + "nivel_editar": 5000 + } + ] + } +} + +# Guardar archivos +with open('storage/users/users.json', 'w') as f: + json.dump(users, f, indent=2) + +with open('storage/filetypes/filetypes.json', 'w') as f: + json.dump(filetypes, f, indent=2) + +with open('storage/schemas/schema.json', 'w') as f: + json.dump(schemas, f, indent=2) + +with open('storage/indices.json', 'w') as f: + json.dump({"max_project_id": 0, "max_document_id": 0}, f, indent=2) + +print("Sistema inicializado correctamente.") +``` + +### 10.3 Orden de Implementación + +1. **Fase 1: Estructura básica y autenticación** + - Configuración del proyecto + - Sistema de autenticación + - Plantillas base + - Gestión de usuarios + +2. **Fase 2: Gestión de proyectos** + - Creación y edición de proyectos + - Búsqueda y filtrado + - Estructura jerárquica + +3. **Fase 3: Gestión de documentos** + - Carga y descarga de documentos + - Sistema de versionado + - Visualización de historiales + +4. **Fase 4: Esquemas y personalización** + - Gestión de esquemas + - Asignación a proyectos + - Configuración de tipos de documentos + +5. **Fase 5: Funciones avanzadas** + - Exportación de proyectos + - Sistema de logs + - Diagnóstico del sistema + +### 10.4 Ejecución de Pruebas + +Durante el desarrollo, ejecutar pruebas regularmente: + +```bash +pytest -v --cov=app tests/ +``` + +Para generar el reporte JSON: + +```bash +pytest -v --cov=app tests/ --json-report +``` + +## 11. Operación y Mantenimiento + +### 11.1 Backups + +Se recomienda realizar backups regulares del directorio `storage/` completo: + +```bash +# Script simple de backup +tar -czf backup-$(date +%Y%m%d).tar.gz storage/ +``` + +Para automatización, se puede configurar APScheduler: + +```python +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +import subprocess +import os +from datetime import datetime + +def backup_storage(): + """Realizar backup del directorio storage.""" + backup_dir = "backups" + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + + backup_file = f"{backup_dir}/backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.tar.gz" + subprocess.run(["tar", "-czf", backup_file, "storage/"]) + + # Eliminar backups antiguos (mantener solo los últimos 5) + backups = sorted([f for f in os.listdir(backup_dir) if f.startswith("backup-")]) + if len(backups) > 5: + for old_backup in backups[:-5]: + os.remove(os.path.join(backup_dir, old_backup)) + +# Configurar scheduler +scheduler = BackgroundScheduler() +scheduler.add_job( + backup_storage, + CronTrigger(hour=2, minute=0), # Ejecutar a las 2:00 AM + id='backup_job', + replace_existing=True +) +scheduler.start() +``` + +### 11.2 Monitoreo del Sistema + +Implementar endpoints para monitoreo: + +```python +@admin_routes.route('/system/status') +@login_required +@permission_required(9999) +def system_status(): + """Mostrar estado del sistema.""" + storage_info = { + 'projects': len(os.listdir('storage/projects')), + 'users': len(json.load(open('storage/users/users.json', 'r'))), + 'schemas': len(json.load(open('storage/schemas/schema.json', 'r'))), + 'disk_usage': get_directory_size('storage') // (1024 * 1024) # MB + } + + return render_template('admin/system.html', storage_info=storage_info) +``` + +### 11.3 Rotación de Logs + +```python +import logging +from logging.handlers import RotatingFileHandler + +# Configurar logger para accesos +access_logger = logging.getLogger('access') +access_handler = RotatingFileHandler( + 'storage/logs/access.log', + maxBytes=10485760, # 10MB + backupCount=5 +) +access_logger.addHandler(access_handler) + +# Configurar logger para errores +error_logger = logging.getLogger('error') +error_handler = RotatingFileHandler( + 'storage/logs/error.log', + maxBytes=10485760, # 10MB + backupCount=5 +) +error_logger.addHandler(error_handler) +``` + +## 12. Consideraciones Adicionales + +### 12.1 Rendimiento + +- Para proyectos con muchos documentos, implementar paginación +- Utilizar Flask-Caching para mejorar tiempos de respuesta +- Considerar indexación avanzada para búsquedas en proyectos grandes + +### 12.2 Escalabilidad + +- La arquitectura basada en archivos es adecuada para equipos pequeños/medianos +- Para volúmenes muy grandes, considerar migración a base de datos +- Separar almacenamiento de documentos en un sistema dedicado si crecen significativamente + +### 12.3 Interfaz de Usuario + +- Diseño responsive para acceso desde diferentes dispositivos +- Interacciones AJAX para operaciones frecuentes sin recargar la página +- Feedback visual claro durante operaciones de carga/descarga + +### 12.4 Futuras Extensiones + +- Integración con servicios de almacenamiento en la nube +- Sistema de notificaciones para cambios en documentos +- Vista previa de documentos en el navegador +- Anotaciones y comentarios en documentos + +--- + +Este documento proporciona una guía completa para la implementación del Sistema de Gestión de Documentos ARCH. Siguiendo esta estructura y consideraciones, se puede desarrollar un sistema robusto y funcional que cumpla con los requisitos establecidos, manteniendo la simplicidad y eficiencia como prioridad. \ No newline at end of file diff --git a/init_app.py b/init_app.py new file mode 100644 index 0000000..c201e70 --- /dev/null +++ b/init_app.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python +""" +Script de inicialización para el Sistema de Gestión de Documentos ARCH. +Crea la estructura inicial de directorios y archivos necesarios. +""" + +import os +import json +import bcrypt +from datetime import datetime +import pytz + +def main(): + """Función principal de inicialización.""" + print("Inicializando Sistema de Gestión de Documentos ARCH...") + + # Crear estructura de directorios + print("Creando estructura de directorios...") + create_directory_structure() + + # Inicializar archivos JSON + print("Inicializando archivos de datos...") + initialize_json_files() + + print("\nSistema inicializado correctamente!") + print("\nPuede iniciar la aplicación con el comando:") + print(" flask run") + print("\nCredenciales de acceso por defecto:") + print(" Usuario: admin") + print(" Contraseña: admin123") + print("\nNOTA: Cambie la contraseña después del primer inicio de sesión.") + +def create_directory_structure(): + """Crear la estructura de directorios necesaria.""" + # Directorios principales + dirs = [ + 'storage', + 'storage/logs', + 'storage/schemas', + 'storage/users', + 'storage/filetypes', + 'storage/projects', + ] + + for directory in dirs: + os.makedirs(directory, exist_ok=True) + print(f" Creado: {directory}") + +def initialize_json_files(): + """Inicializar archivos JSON con datos por defecto.""" + # Índices + indices = { + "max_project_id": 0, + "max_document_id": 0 + } + save_json('storage/indices.json', indices) + + # Usuarios + admin_password = bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + users = { + "admin": { + "nombre": "Administrador", + "username": "admin", + "email": "admin@ejemplo.com", + "password_hash": admin_password, + "nivel": 9999, + "idioma": "es", + "fecha_caducidad": None, + "empresa": "ARCH", + "estado": "activo", + "ultimo_acceso": datetime.now(pytz.UTC).isoformat() + } + } + save_json('storage/users/users.json', users) + + # Tipos de archivo + filetypes = { + "pdf": { + "extension": "pdf", + "descripcion": "Documento PDF", + "mime_type": "application/pdf", + "tamano_maximo": 20971520 # 20MB + }, + "txt": { + "extension": "txt", + "descripcion": "Documento de texto", + "mime_type": "text/plain", + "tamano_maximo": 5242880 # 5MB + }, + "zip": { + "extension": "zip", + "descripcion": "Archivo comprimido", + "mime_type": "application/zip", + "tamano_maximo": 104857600 # 100MB + }, + "docx": { + "extension": "docx", + "descripcion": "Documento Word", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "tamano_maximo": 20971520 # 20MB + }, + "xlsx": { + "extension": "xlsx", + "descripcion": "Hoja de cálculo Excel", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "tamano_maximo": 20971520 # 20MB + }, + "dwg": { + "extension": "dwg", + "descripcion": "Dibujo AutoCAD", + "mime_type": "application/acad", + "tamano_maximo": 52428800 # 50MB + } + } + save_json('storage/filetypes/filetypes.json', filetypes) + + # Esquemas + schemas = { + "ESQ001": { + "codigo": "ESQ001", + "descripcion": "Proyecto estándar", + "fecha_creacion": datetime.now(pytz.UTC).isoformat(), + "creado_por": "admin", + "documentos": [ + { + "tipo": "pdf", + "nombre": "Manual de Usuario", + "nivel_ver": 0, + "nivel_editar": 5000 + }, + { + "tipo": "dwg", + "nombre": "Planos Técnicos", + "nivel_ver": 0, + "nivel_editar": 5000 + }, + { + "tipo": "zip", + "nombre": "Archivos Fuente", + "nivel_ver": 1000, + "nivel_editar": 5000 + }, + { + "tipo": "xlsx", + "nombre": "Datos y Cálculos", + "nivel_ver": 0, + "nivel_editar": 1000 + } + ] + }, + "ESQ002": { + "codigo": "ESQ002", + "descripcion": "Documentación técnica", + "fecha_creacion": datetime.now(pytz.UTC).isoformat(), + "creado_por": "admin", + "documentos": [ + { + "tipo": "pdf", + "nombre": "Especificaciones Técnicas", + "nivel_ver": 0, + "nivel_editar": 5000 + }, + { + "tipo": "pdf", + "nombre": "Manual de Mantenimiento", + "nivel_ver": 0, + "nivel_editar": 5000 + }, + { + "tipo": "pdf", + "nombre": "Certificaciones", + "nivel_ver": 0, + "nivel_editar": 9000 + } + ] + } + } + save_json('storage/schemas/schema.json', schemas) + +def save_json(file_path, data): + """Guardar datos en archivo JSON con formato.""" + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f" Inicializado: {file_path}") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/middleware/auth_middleware.py b/middleware/auth_middleware.py new file mode 100644 index 0000000..e69de29 diff --git a/middleware/permission_check.py b/middleware/permission_check.py new file mode 100644 index 0000000..e69de29 diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f8108a2 --- /dev/null +++ b/readme.md @@ -0,0 +1,118 @@ +# ARCH - Sistema de Gestión de Documentos para Proyectos de Ingeniería + +ARCH es un sistema de gestión documental diseñado específicamente para equipos de ingeniería, con el propósito de almacenar, organizar y versionar los archivos críticos de cada proyecto. La aplicación proporciona una estructura jerárquica para proyectos, control de versiones para documentos, y esquemas personalizables según el tipo de proyecto. + +## Características Principales + +- **Gestión de Proyectos**: Creación y organización jerárquica de proyectos +- **Control de Versiones**: Histórico completo de todas las versiones de documentos +- **Esquemas Personalizables**: Definición de tipos de documentos por proyecto +- **Sistema de Permisos**: Control de acceso granular basado en niveles de usuario +- **Arquitectura Simple**: Basada en archivos JSON para facilitar el mantenimiento y backups +- **Seguridad**: Validación de archivos y autenticación de usuarios + +## Requisitos del Sistema + +- Python 3.8 o superior +- Dependencias listadas en `requirements.txt` +- 500MB de espacio en disco para la instalación inicial + +## Instalación + +1. Clonar el repositorio: + ``` + git clone https://github.com/tu-usuario/arch-document-manager.git + cd arch-document-manager + ``` + +2. Crear y activar un entorno virtual: + ``` + python -m venv venv + source venv/bin/activate # Linux/Mac + venv\Scripts\activate # Windows + ``` + +3. Instalar dependencias: + ``` + pip install -r requirements.txt + ``` + +4. Inicializar el sistema: + ``` + python init_app.py + ``` + +5. Crear archivo `.env` con la configuración: + ``` + FLASK_APP=app.py + FLASK_ENV=development + SECRET_KEY=clave_secreta_generada_aleatoriamente + STORAGE_PATH=storage + ``` + +## Ejecución + +1. Iniciar la aplicación: + ``` + flask run + ``` + +2. Acceder a la aplicación en el navegador: + ``` + http://localhost:5000 + ``` + +3. Iniciar sesión con las credenciales por defecto: + - Usuario: `admin` + - Contraseña: `admin123` + + **IMPORTANTE**: Cambiar la contraseña del administrador después del primer inicio de sesión. + +## Estructura de Directorios + +``` +document_manager/ +│ +├── app.py # Punto de entrada de la aplicación +├── config.py # Configuración global +├── requirements.txt # Dependencias del proyecto +├── README.md # Este archivo +│ +├── services/ # Lógica de negocio +├── utils/ # Utilidades generales +├── middleware/ # Middleware para Flask +├── routes/ # Endpoints de la API +├── static/ # Archivos estáticos (CSS, JS) +├── templates/ # Plantillas HTML +├── tests/ # Pruebas unitarias +└── storage/ # Almacenamiento de datos +``` + +## Niveles de Usuario + +- **Nivel 0-999**: Usuario básico (solo lectura) +- **Nivel 1000-4999**: Editor (puede subir documentos) +- **Nivel 5000-8999**: Gestor (puede crear y editar proyectos) +- **Nivel 9000-9999**: Administrador (acceso completo al sistema) + +## Backups + +Se recomienda realizar backups regulares del directorio `storage/`: + +```bash +# Script simple de backup +tar -czf backup-$(date +%Y%m%d).tar.gz storage/ +``` + +## Soporte + +Si encuentra algún problema o tiene alguna sugerencia, por favor abra un issue en el repositorio del proyecto. + +## Licencia + +Este proyecto está licenciado bajo los términos de la licencia MIT. + + +Credenciales de acceso por defecto: + Usuario: admin + Contraseña: admin123 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 50ced2e..dcd2413 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,45 @@ +# Componentes principales Flask==2.3.3 Werkzeug==2.3.7 Jinja2==3.1.2 itsdangerous==2.1.2 click==8.1.3 -python-dateutil==2.8.2 -python-dotenv==1.0.0 + +# Gestión de formularios y validación Flask-WTF==1.2.1 WTForms==3.0.1 + +# Autenticación y seguridad +Flask-Login==0.6.2 +Flask-Bcrypt==1.0.1 + +# Manejo de fechas y zonas horarias +python-dateutil==2.8.2 +pytz==2023.3 + +# Configuración y entorno +python-dotenv==1.0.0 + +# Gestión de sesiones Flask-Session==0.5.0 -urllib3==2.0.4 + +# HTTP y comunicaciones requests==2.31.0 -boto3==1.28.16 -pytz==2023.3 \ No newline at end of file + +# Validación de archivos +python-magic==0.4.27 + +# Mejoras de desarrollo y mantenimiento +loguru==0.7.0 +pytest==7.4.0 +pytest-cov==4.1.0 +pytest-html==3.2.0 + +# Servidor de producción +gunicorn==21.2.0 + +# Programación de tareas +APScheduler==3.10.1 + +# Caché +Flask-Caching==2.0.2 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin_routes.py b/routes/admin_routes.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/auth_routes.py b/routes/auth_routes.py new file mode 100644 index 0000000..6d43771 --- /dev/null +++ b/routes/auth_routes.py @@ -0,0 +1,109 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, session +from flask_login import login_user, logout_user, login_required, current_user +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Length + +from services.auth_service import authenticate_user + +# Definir Blueprint +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + +# Formularios +class LoginForm(FlaskForm): + """Formulario de inicio de sesión.""" + username = StringField('Usuario', validators=[DataRequired(), Length(1, 64)]) + password = PasswordField('Contraseña', validators=[DataRequired()]) + remember_me = BooleanField('Mantener sesión iniciada') + submit = SubmitField('Iniciar sesión') + +class PasswordResetRequestForm(FlaskForm): + """Formulario de solicitud de reinicio de contraseña.""" + email = StringField('Email', validators=[DataRequired(), Length(1, 64)]) + submit = SubmitField('Enviar solicitud') + +class PasswordResetForm(FlaskForm): + """Formulario de reinicio de contraseña.""" + password = PasswordField('Nueva contraseña', validators=[DataRequired(), Length(8, 64)]) + password2 = PasswordField('Confirmar contraseña', validators=[DataRequired()]) + submit = SubmitField('Restablecer contraseña') + +# Rutas +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """Página de inicio de sesión.""" + # Redirigir si ya está autenticado + if current_user.is_authenticated: + return redirect(url_for('projects.list')) + + form = LoginForm() + + if form.validate_on_submit(): + user = authenticate_user(form.username.data, form.password.data) + + if user: + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + + # Registrar en la sesión el idioma del usuario + session['language'] = user.idioma + + if next_page: + return redirect(next_page) + return redirect(url_for('projects.list')) + + flash('Usuario o contraseña incorrectos.', 'danger') + + return render_template('auth/login.html', form=form) + +@auth_bp.route('/logout') +@login_required +def logout(): + """Cerrar sesión.""" + logout_user() + flash('Has cerrado sesión correctamente.', 'success') + return redirect(url_for('auth.login')) + +@auth_bp.route('/reset_password_request', methods=['GET', 'POST']) +def reset_password_request(): + """Solicitar reinicio de contraseña.""" + if current_user.is_authenticated: + return redirect(url_for('projects.list')) + + form = PasswordResetRequestForm() + + if form.validate_on_submit(): + # En un sistema real, aquí se enviaría un correo + # Por ahora, solo mostramos un mensaje + flash('Se ha enviado un correo con instrucciones para restablecer tu contraseña.', 'info') + return redirect(url_for('auth.login')) + + return render_template('auth/reset_password.html', form=form) + +@auth_bp.route('/reset_password/', methods=['GET', 'POST']) +def reset_password(token): + """Restablecer contraseña con token.""" + if current_user.is_authenticated: + return redirect(url_for('projects.list')) + + # En un sistema real, aquí se verificaría el token + # Por ahora, siempre mostramos el formulario + + form = PasswordResetForm() + + if form.validate_on_submit(): + if form.password.data != form.password2.data: + flash('Las contraseñas no coinciden.', 'danger') + return render_template('auth/reset_password.html', form=form) + + # En un sistema real, aquí se cambiaría la contraseña + flash('Tu contraseña ha sido restablecida.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/reset_password.html', form=form) + +@auth_bp.route('/profile') +@login_required +def profile(): + """Ver perfil de usuario.""" + return render_template('auth/profile.html') \ No newline at end of file diff --git a/routes/document_routes.py b/routes/document_routes.py new file mode 100644 index 0000000..1866cb0 --- /dev/null +++ b/routes/document_routes.py @@ -0,0 +1,271 @@ +import os +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file, abort +from flask_login import login_required, current_user +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired +from wtforms import StringField, TextAreaField, SubmitField, HiddenField +from wtforms.validators import DataRequired, Length +from werkzeug.utils import secure_filename + +from services.document_service import ( + add_document, add_version, get_document, get_project_documents, + get_document_version, get_latest_version, register_download, + delete_document +) +from services.project_service import get_project +from services.schema_service import get_schema_document_types +from utils.security import permission_required + +# Definir Blueprint +documents_bp = Blueprint('documents', __name__, url_prefix='/documents') + +# Formularios +class DocumentUploadForm(FlaskForm): + """Formulario para subir documento.""" + nombre = StringField('Nombre del documento', validators=[DataRequired(), Length(1, 100)]) + description = TextAreaField('Descripción', validators=[Length(0, 500)]) + file = FileField('Archivo', validators=[FileRequired()]) + submit = SubmitField('Subir documento') + +class DocumentVersionForm(FlaskForm): + """Formulario para nueva versión de documento.""" + description = TextAreaField('Descripción de la versión', validators=[Length(0, 500)]) + file = FileField('Archivo', validators=[FileRequired()]) + document_id = HiddenField('ID de documento', validators=[DataRequired()]) + submit = SubmitField('Subir nueva versión') + +# Rutas +@documents_bp.route('/') +@login_required +def list(project_id): + """Listar documentos de un proyecto.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + documents = get_project_documents(project_id) + + return render_template('documents/list.html', + project=project, + documents=documents) + +@documents_bp.route('//upload', methods=['GET', 'POST']) +@login_required +@permission_required(1000) # Nivel mínimo para subir documentos +def upload(project_id): + """Subir un nuevo documento.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + # Verificar que el proyecto esté activo + if project['estado'] != 'activo': + flash('No se pueden añadir documentos a un proyecto inactivo.', 'warning') + return redirect(url_for('projects.view', project_id=project_id)) + + form = DocumentUploadForm() + + if form.validate_on_submit(): + # Añadir documento + success, message, document_id = add_document( + project_id, + { + 'nombre': form.nombre.data, + 'description': form.description.data + }, + form.file.data, + current_user.username + ) + + if success: + flash(message, 'success') + return redirect(url_for('documents.versions', project_id=project_id, document_id=document_id)) + else: + flash(message, 'danger') + + return render_template('documents/upload.html', + form=form, + project=project) + +@documents_bp.route('//') +@login_required +def versions(project_id, document_id): + """Ver versiones de un documento.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + document = get_document(project_id, document_id) + + if not document: + flash('Documento no encontrado.', 'danger') + return redirect(url_for('projects.view', project_id=project_id)) + + form = DocumentVersionForm() + form.document_id.data = document_id + + return render_template('documents/versions.html', + project=project, + document=document, + form=form) + +@documents_bp.route('///upload', methods=['POST']) +@login_required +@permission_required(1000) # Nivel mínimo para subir versiones +def upload_version(project_id, document_id): + """Subir una nueva versión de documento.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + # Verificar que el proyecto esté activo + if project['estado'] != 'activo': + flash('No se pueden añadir versiones a un proyecto inactivo.', 'warning') + return redirect(url_for('projects.view', project_id=project_id)) + + document = get_document(project_id, document_id) + + if not document: + flash('Documento no encontrado.', 'danger') + return redirect(url_for('projects.view', project_id=project_id)) + + form = DocumentVersionForm() + + if form.validate_on_submit(): + # Añadir versión + success, message, version = add_version( + project_id, + document_id, + { + 'description': form.description.data + }, + form.file.data, + current_user.username + ) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + 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('documents.versions', project_id=project_id, document_id=document_id)) + +@documents_bp.route('///download/') +@login_required +def download(project_id, document_id, version): + """Descargar una versión específica de un documento.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + # Obtener versión solicitada + version_meta, file_path = get_document_version(project_id, document_id, version) + + if not version_meta or not file_path: + flash('Versión de documento no encontrada.', 'danger') + return redirect(url_for('projects.view', project_id=project_id)) + + # Registrar descarga + register_download(project_id, document_id, version, current_user.username) + + # Enviar archivo + try: + return send_file( + file_path, + mimetype=version_meta['mime_type'], + as_attachment=True, + download_name=os.path.basename(file_path) + ) + except Exception as e: + flash(f'Error al descargar el archivo: {str(e)}', 'danger') + return redirect(url_for('documents.versions', project_id=project_id, document_id=document_id)) + +@documents_bp.route('///latest') +@login_required +def download_latest(project_id, document_id): + """Descargar la última versión de un documento.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + # Obtener última versión + version_meta, file_path = get_latest_version(project_id, document_id) + + if not version_meta or not file_path: + flash('Documento no encontrado.', 'danger') + return redirect(url_for('projects.view', project_id=project_id)) + + # Registrar descarga + register_download(project_id, document_id, version_meta['version'], current_user.username) + + # Enviar archivo + try: + return send_file( + file_path, + mimetype=version_meta['mime_type'], + as_attachment=True, + download_name=os.path.basename(file_path) + ) + except Exception as e: + flash(f'Error al descargar el archivo: {str(e)}', 'danger') + return redirect(url_for('documents.versions', project_id=project_id, document_id=document_id)) + +@documents_bp.route('///delete', methods=['POST']) +@login_required +@permission_required(9000) # Nivel alto para eliminar documentos +def delete(project_id, document_id): + """Eliminar un documento.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + success, message = delete_document(project_id, document_id) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + + return redirect(url_for('projects.view', project_id=project_id)) + +@documents_bp.route('//export') +@login_required +def export(project_id): + """Exportar documentos de un proyecto.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + documents = get_project_documents(project_id) + + return render_template('documents/export.html', + project=project, + documents=documents) + +@documents_bp.route('//api/list') +@login_required +def api_list(project_id): + """API para listar documentos de un proyecto.""" + documents = get_project_documents(project_id) + + return jsonify(documents) \ No newline at end of file diff --git a/routes/project_routes.py b/routes/project_routes.py new file mode 100644 index 0000000..605e9ff --- /dev/null +++ b/routes/project_routes.py @@ -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, SelectField, TextAreaField, SubmitField +from wtforms.validators import DataRequired, Length + +from services.project_service import ( + create_project, update_project, get_project, delete_project, + get_all_projects, get_project_children, get_project_document_count, + filter_projects +) +from services.schema_service import get_all_schemas +from utils.security import permission_required + +# Definir Blueprint +projects_bp = Blueprint('projects', __name__, url_prefix='/projects') + +# Formularios +class ProjectForm(FlaskForm): + """Formulario de proyecto.""" + descripcion = StringField('Descripción', validators=[DataRequired(), Length(1, 100)]) + cliente = StringField('Cliente', validators=[DataRequired(), Length(1, 100)]) + destinacion = StringField('Destinación', validators=[Length(0, 100)]) + esquema = SelectField('Esquema', validators=[DataRequired()]) + proyecto_padre = SelectField('Proyecto Padre', validators=[]) + submit = SubmitField('Guardar') + +class ProjectFilterForm(FlaskForm): + """Formulario de filtrado de proyectos.""" + cliente = StringField('Cliente') + estado = SelectField('Estado', choices=[('', 'Todos'), ('activo', 'Activo'), ('inactivo', 'Inactivo')]) + ano_inicio = StringField('Año Inicio') + ano_fin = StringField('Año Fin') + descripcion = StringField('Descripción') + submit = SubmitField('Filtrar') + +# Rutas +@projects_bp.route('/') +@login_required +def list(): + """Listar proyectos.""" + filter_form = ProjectFilterForm(request.args) + + # Obtener proyectos según filtros + if request.args: + projects = filter_projects(request.args) + else: + projects = get_all_projects() + + return render_template('projects/list.html', + projects=projects, + filter_form=filter_form) + +@projects_bp.route('/create', methods=['GET', 'POST']) +@login_required +@permission_required(1000) # Nivel mínimo para crear proyectos +def create(): + """Crear nuevo proyecto.""" + form = ProjectForm() + + # Cargar opciones para esquemas + schemas = get_all_schemas() + form.esquema.choices = [(code, schema['descripcion']) for code, schema in schemas.items()] + + # Cargar opciones para proyectos padre + projects = [(p['codigo'], p['descripcion']) for p in get_all_projects()] + form.proyecto_padre.choices = [('', 'Ninguno')] + projects + + if form.validate_on_submit(): + # Preparar datos del proyecto + project_data = { + 'descripcion': form.descripcion.data, + 'cliente': form.cliente.data, + 'destinacion': form.destinacion.data, + 'esquema': form.esquema.data, + 'proyecto_padre': form.proyecto_padre.data if form.proyecto_padre.data else None + } + + # Crear proyecto + success, message, project_id = create_project(project_data, current_user.username) + + if success: + flash(message, 'success') + return redirect(url_for('projects.view', project_id=project_id)) + else: + flash(message, 'danger') + + return render_template('projects/create.html', form=form) + +@projects_bp.route('/') +@login_required +def view(project_id): + """Ver detalles de un proyecto.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + # Obtener información adicional + children = get_project_children(project_id) + document_count = get_project_document_count(project_id) + + return render_template('projects/view.html', + project=project, + children=children, + document_count=document_count) + +@projects_bp.route('//edit', methods=['GET', 'POST']) +@login_required +@permission_required(5000) # Nivel mínimo para editar proyectos +def edit(project_id): + """Editar un proyecto.""" + project = get_project(project_id) + + if not project: + flash('Proyecto no encontrado.', 'danger') + return redirect(url_for('projects.list')) + + form = ProjectForm() + + # Cargar opciones para esquemas + schemas = get_all_schemas() + form.esquema.choices = [(code, schema['descripcion']) for code, schema in schemas.items()] + + # Cargar opciones para proyectos padre (excluyendo este proyecto y sus hijos) + all_projects = get_all_projects() + children_codes = [child['codigo'] for child in get_project_children(project_id)] + available_projects = [(p['codigo'], p['descripcion']) for p in all_projects + if p['codigo'] != project['codigo'] and p['codigo'] not in children_codes] + + form.proyecto_padre.choices = [('', 'Ninguno')] + available_projects + + if request.method == 'GET': + # Cargar datos actuales + form.descripcion.data = project['descripcion'] + form.cliente.data = project['cliente'] + form.destinacion.data = project.get('destinacion', '') + form.esquema.data = project['esquema'] + form.proyecto_padre.data = project.get('proyecto_padre', '') + + if form.validate_on_submit(): + # Preparar datos actualizados + project_data = { + 'descripcion': form.descripcion.data, + 'cliente': form.cliente.data, + 'destinacion': form.destinacion.data, + 'esquema': form.esquema.data, + 'proyecto_padre': form.proyecto_padre.data if form.proyecto_padre.data else None + } + + # Actualizar proyecto + success, message = update_project(project_id, project_data, current_user.username) + + if success: + flash(message, 'success') + return redirect(url_for('projects.view', project_id=project_id)) + else: + flash(message, 'danger') + + return render_template('projects/edit.html', form=form, project=project) + +@projects_bp.route('//delete', methods=['POST']) +@login_required +@permission_required(9000) # Nivel alto para eliminar proyectos +def delete(project_id): + """Eliminar un proyecto (marcar como inactivo).""" + success, message = delete_project(project_id) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + + return redirect(url_for('projects.list')) + +@projects_bp.route('/api/list') +@login_required +def api_list(): + """API para listar proyectos (para selects dinámicos).""" + projects = get_all_projects() + return jsonify([{ + 'id': p['codigo'], + 'text': p['descripcion'] + } for p in projects]) \ No newline at end of file diff --git a/routes/schema_routes.py b/routes/schema_routes.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/user_routes.py b/routes/user_routes.py new file mode 100644 index 0000000..e69de29 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/auth_service.py b/services/auth_service.py new file mode 100644 index 0000000..a096250 --- /dev/null +++ b/services/auth_service.py @@ -0,0 +1,200 @@ +from flask import current_app +from flask_login import UserMixin +import json +import os +from datetime import datetime +import pytz +from app import bcrypt + +class User(UserMixin): + """Clase de usuario para Flask-Login.""" + + def __init__(self, username, data): + self.id = username + self.username = username + self.nombre = data.get('nombre', '') + self.email = data.get('email', '') + self.password_hash = data.get('password_hash', '') + self.nivel = data.get('nivel', 0) + self.idioma = data.get('idioma', 'es') + self.fecha_caducidad = data.get('fecha_caducidad') + self.empresa = data.get('empresa', '') + self.estado = data.get('estado', 'inactivo') + self.ultimo_acceso = data.get('ultimo_acceso') + + def verify_password(self, password): + """Verificar contraseña.""" + return bcrypt.check_password_hash(self.password_hash, password) + + def is_active(self): + """Verificar si el usuario está activo.""" + return self.estado == 'activo' + + def is_expired(self): + """Verificar si la cuenta del usuario ha expirado.""" + if not self.fecha_caducidad: + return False + + now = datetime.now(pytz.UTC) + expiry_date = datetime.fromisoformat(self.fecha_caducidad.replace('Z', '+00:00')) + return now > expiry_date + + def has_permission(self, required_level): + """Verificar si el usuario tiene el nivel de permiso requerido.""" + return self.nivel >= required_level + + def to_dict(self): + """Convertir usuario a diccionario.""" + return { + 'nombre': self.nombre, + 'username': self.username, + 'email': self.email, + 'password_hash': self.password_hash, + 'nivel': self.nivel, + 'idioma': self.idioma, + 'fecha_caducidad': self.fecha_caducidad, + 'empresa': self.empresa, + 'estado': self.estado, + 'ultimo_acceso': self.ultimo_acceso + } + +def authenticate_user(username, password): + """Autenticar usuario con nombre de usuario y contraseña.""" + user = get_user_by_username(username) + + if user and user.verify_password(password) and user.is_active() and not user.is_expired(): + # Actualizar último acceso + update_last_access(username) + return user + + return None + +def get_user_by_username(username): + """Obtener usuario por nombre de usuario.""" + users = load_users() + + if username in users: + return User(username, users[username]) + + return None + +def get_user_by_id(user_id): + """Cargar usuario por ID (username) para Flask-Login.""" + return get_user_by_username(user_id) + +def update_last_access(username): + """Actualizar el timestamp de último acceso.""" + users = load_users() + + if username in users: + users[username]['ultimo_acceso'] = datetime.now(pytz.UTC).isoformat() + save_users(users) + +def create_user(username, nombre, email, password, nivel=0, idioma='es', + fecha_caducidad=None, empresa='', estado='activo'): + """Crear un nuevo usuario.""" + users = load_users() + + # Verificar si ya existe el usuario + if username in users: + return False, "El nombre de usuario ya existe." + + # Generar hash para la contraseña + password_hash = bcrypt.generate_password_hash(password).decode('utf-8') + + # Crear entrada de usuario + users[username] = { + 'nombre': nombre, + 'username': username, + 'email': email, + 'password_hash': password_hash, + 'nivel': nivel, + 'idioma': idioma, + 'fecha_caducidad': fecha_caducidad, + 'empresa': empresa, + 'estado': estado, + 'ultimo_acceso': datetime.now(pytz.UTC).isoformat() + } + + # Guardar usuarios + save_users(users) + + return True, "Usuario creado correctamente." + +def update_user(username, data): + """Actualizar datos de usuario.""" + users = load_users() + + # Verificar si existe el usuario + if username not in users: + return False, "El usuario no existe." + + # Actualizar campos (excepto password si no se especifica) + for key, value in data.items(): + if key != 'password': # Contraseña se maneja separado + users[username][key] = value + + # Actualizar contraseña si se proporciona + if 'password' in data and data['password']: + users[username]['password_hash'] = bcrypt.generate_password_hash( + data['password']).decode('utf-8') + + # Guardar usuarios + save_users(users) + + return True, "Usuario actualizado correctamente." + +def delete_user(username): + """Eliminar usuario.""" + users = load_users() + + # Verificar si existe el usuario + if username not in users: + return False, "El usuario no existe." + + # Eliminar usuario + del users[username] + + # Guardar usuarios + save_users(users) + + return True, "Usuario eliminado correctamente." + +def load_users(): + """Cargar usuarios desde archivo JSON.""" + storage_path = current_app.config['STORAGE_PATH'] + users_file = os.path.join(storage_path, 'users', 'users.json') + + try: + with open(users_file, 'r', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + +def save_users(users): + """Guardar usuarios en archivo JSON.""" + storage_path = current_app.config['STORAGE_PATH'] + users_file = os.path.join(storage_path, 'users', 'users.json') + + with open(users_file, 'w', encoding='utf-8') as f: + json.dump(users, f, ensure_ascii=False, indent=2) + +def get_all_users(): + """Obtener lista de todos los usuarios.""" + users = load_users() + return [User(username, data) for username, data in users.items()] + +def initialize_admin_user(): + """Crear usuario administrador si no existe.""" + users = load_users() + + if 'admin' not in users: + create_user( + username='admin', + nombre='Administrador', + email='admin@example.com', + password='admin123', # Cambiar en producción + nivel=9999, + estado='activo' + ) + current_app.logger.info('Usuario administrador creado.') \ No newline at end of file diff --git a/services/document_service.py b/services/document_service.py new file mode 100644 index 0000000..c760d1d --- /dev/null +++ b/services/document_service.py @@ -0,0 +1,440 @@ +import os +import json +from datetime import datetime +import pytz +import mimetypes +from werkzeug.utils import secure_filename +from flask import current_app + +from utils.file_utils import ( + load_json_file, save_json_file, ensure_dir_exists, + get_next_id, format_document_directory_name, + format_version_filename +) +from utils.security import calculate_checksum, check_file_type +from services.project_service import find_project_directory + +def get_allowed_filetypes(): + """ + Obtener los tipos de archivo permitidos. + + Returns: + dict: Diccionario de tipos de archivo + """ + storage_path = current_app.config['STORAGE_PATH'] + filetypes_file = os.path.join(storage_path, 'filetypes', 'filetypes.json') + + return load_json_file(filetypes_file, {}) + +def add_document(project_id, document_data, file, creator_username): + """ + Añadir un nuevo documento a un proyecto. + + Args: + project_id (int): ID del proyecto + document_data (dict): Datos del documento + file: Objeto de archivo (de Flask) + creator_username (str): Usuario que crea el documento + + Returns: + tuple: (success, message, document_id) + """ + # Buscar directorio del proyecto + project_dir = find_project_directory(project_id) + + if not project_dir: + return False, f"No se encontró el proyecto con ID {project_id}.", None + + # Validar datos obligatorios + if 'nombre' not in document_data or not document_data['nombre']: + return False, "El nombre del documento es obligatorio.", None + + # Validar tipo de archivo + allowed_filetypes = get_allowed_filetypes() + filename = secure_filename(file.filename) + extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + + if extension not in allowed_filetypes: + return False, f"Tipo de archivo no permitido: {extension}", None + + # Verificar MIME type + if not check_file_type(file.stream, [allowed_filetypes[extension]['mime_type']]): + return False, "El tipo de archivo no coincide con su extensión.", None + + # Obtener siguiente ID de documento + document_id = get_next_id('document') + + # Preparar directorio del documento + documents_dir = os.path.join(project_dir, 'documents') + dir_name = format_document_directory_name(document_id, document_data['nombre']) + document_dir = os.path.join(documents_dir, dir_name) + + # Verificar si ya existe + if os.path.exists(document_dir): + return False, "Ya existe un documento con ese nombre en este proyecto.", None + + # Crear directorio + ensure_dir_exists(document_dir) + + # Preparar primera versión + version = 1 + version_filename = format_version_filename(version, document_data['nombre'], extension) + version_path = os.path.join(document_dir, version_filename) + + # Guardar archivo + file.seek(0) + file.save(version_path) + + # Calcular checksum + checksum = calculate_checksum(version_path) + + # Obtener tamaño del archivo + file_size = os.path.getsize(version_path) + + # Preparar metadatos del documento + document_meta = { + 'document_id': f"{document_id:03d}_{document_data['nombre'].lower().replace(' ', '_')}", + 'original_filename': filename, + 'versions': [ + { + 'version': version, + 'filename': version_filename, + 'created_at': datetime.now(pytz.UTC).isoformat(), + 'created_by': creator_username, + 'description': document_data.get('description', 'Versión inicial'), + 'file_size': file_size, + 'mime_type': allowed_filetypes[extension]['mime_type'], + 'checksum': checksum, + 'downloads': [] + } + ] + } + + # Guardar metadatos + meta_file = os.path.join(document_dir, 'meta.json') + save_json_file(meta_file, document_meta) + + return True, "Documento añadido correctamente.", document_id + +def add_version(project_id, document_id, version_data, file, creator_username): + """ + Añadir una nueva versión a un documento existente. + + Args: + project_id (int): ID del proyecto + document_id (int): ID del documento + version_data (dict): Datos de la versión + file: Objeto de archivo (de Flask) + creator_username (str): Usuario que crea la versión + + Returns: + tuple: (success, message, version_number) + """ + # Buscar directorio del proyecto + project_dir = find_project_directory(project_id) + + if not project_dir: + return False, f"No se encontró el proyecto con ID {project_id}.", None + + # Buscar documento + document_dir = find_document_directory(project_dir, document_id) + + if not document_dir: + return False, f"No se encontró el documento con ID {document_id}.", None + + # Cargar metadatos del documento + meta_file = os.path.join(document_dir, 'meta.json') + document_meta = load_json_file(meta_file) + + if not document_meta: + return False, "Error al cargar metadatos del documento.", None + + # Validar tipo de archivo + allowed_filetypes = get_allowed_filetypes() + filename = secure_filename(file.filename) + extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + + if extension not in allowed_filetypes: + return False, f"Tipo de archivo no permitido: {extension}", None + + # Verificar MIME type + if not check_file_type(file.stream, [allowed_filetypes[extension]['mime_type']]): + return False, "El tipo de archivo no coincide con su extensión.", None + + # Determinar número de versión + last_version = max([v['version'] for v in document_meta['versions']]) + new_version = last_version + 1 + + # Preparar nombre de archivo + doc_name = document_meta['document_id'].split('_', 1)[1] if '_' in document_meta['document_id'] else 'document' + version_filename = format_version_filename(new_version, doc_name, extension) + version_path = os.path.join(document_dir, version_filename) + + # Guardar archivo + file.seek(0) + file.save(version_path) + + # Calcular checksum + checksum = calculate_checksum(version_path) + + # Obtener tamaño del archivo + file_size = os.path.getsize(version_path) + + # Preparar metadatos de la versión + version_meta = { + 'version': new_version, + 'filename': version_filename, + 'created_at': datetime.now(pytz.UTC).isoformat(), + 'created_by': creator_username, + 'description': version_data.get('description', f'Versión {new_version}'), + 'file_size': file_size, + 'mime_type': allowed_filetypes[extension]['mime_type'], + 'checksum': checksum, + 'downloads': [] + } + + # Añadir versión a metadatos + document_meta['versions'].append(version_meta) + + # Guardar metadatos actualizados + save_json_file(meta_file, document_meta) + + return True, "Nueva versión añadida correctamente.", new_version + +def get_document(project_id, document_id): + """ + Obtener información de un documento. + + Args: + project_id (int): ID del proyecto + document_id (int): ID del documento + + Returns: + dict: Datos del documento o None si no existe + """ + # Buscar directorio del proyecto + project_dir = find_project_directory(project_id) + + if not project_dir: + return None + + # Buscar documento + document_dir = find_document_directory(project_dir, document_id) + + if not document_dir: + return None + + # Cargar metadatos + meta_file = os.path.join(document_dir, 'meta.json') + document_meta = load_json_file(meta_file) + + # Agregar ruta del directorio + if document_meta: + document_meta['directory'] = os.path.basename(document_dir) + + return document_meta + +def get_document_version(project_id, document_id, version): + """ + Obtener información de una versión específica de un documento. + + Args: + project_id (int): ID del proyecto + document_id (int): ID del documento + version (int): Número de versión + + Returns: + tuple: (dict, str) - (Metadatos de la versión, ruta al archivo) + """ + document = get_document(project_id, document_id) + + if not document: + return None, None + + # Buscar versión específica + version_meta = None + for v in document['versions']: + if v['version'] == int(version): + version_meta = v + break + + if not version_meta: + return None, None + + # Preparar ruta al archivo + project_dir = find_project_directory(project_id) + document_dir = find_document_directory(project_dir, document_id) + file_path = os.path.join(document_dir, version_meta['filename']) + + return version_meta, file_path + +def get_latest_version(project_id, document_id): + """ + Obtener la última versión de un documento. + + Args: + project_id (int): ID del proyecto + document_id (int): ID del documento + + Returns: + tuple: (dict, str) - (Metadatos de la versión, ruta al archivo) + """ + document = get_document(project_id, document_id) + + if not document or not document['versions']: + return None, None + + # Encontrar la versión más reciente + latest_version = max(document['versions'], key=lambda v: v['version']) + + # Preparar ruta al archivo + project_dir = find_project_directory(project_id) + document_dir = find_document_directory(project_dir, document_id) + file_path = os.path.join(document_dir, latest_version['filename']) + + return latest_version, file_path + +def register_download(project_id, document_id, version, username): + """ + Registrar una descarga de documento. + + Args: + project_id (int): ID del proyecto + document_id (int): ID del documento + version (int): Número de versión + username (str): Usuario que descarga + + Returns: + bool: True si se registró correctamente, False en caso contrario + """ + # Buscar documento + project_dir = find_project_directory(project_id) + + if not project_dir: + return False + + document_dir = find_document_directory(project_dir, document_id) + + if not document_dir: + return False + + # Cargar metadatos + meta_file = os.path.join(document_dir, 'meta.json') + document_meta = load_json_file(meta_file) + + if not document_meta: + return False + + # Buscar versión + for v in document_meta['versions']: + if v['version'] == int(version): + # Registrar descarga + download_info = { + 'user_id': username, + 'downloaded_at': datetime.now(pytz.UTC).isoformat() + } + v['downloads'].append(download_info) + + # Guardar metadatos actualizados + save_json_file(meta_file, document_meta) + return True + + return False + +def find_document_directory(project_dir, document_id): + """ + Encontrar el directorio de un documento por su ID. + + Args: + project_dir (str): Ruta al directorio del proyecto + document_id (int): ID del documento + + Returns: + str: Ruta al directorio o None si no se encuentra + """ + documents_dir = os.path.join(project_dir, 'documents') + + if not os.path.exists(documents_dir): + return None + + # Prefijo a buscar en nombres de directorios + prefix = f"@{int(document_id):03d}_@" + + for dir_name in os.listdir(documents_dir): + if dir_name.startswith(prefix): + return os.path.join(documents_dir, dir_name) + + return None + +def get_project_documents(project_id): + """ + Obtener todos los documentos de un proyecto. + + Args: + project_id (int): ID del proyecto + + Returns: + list: Lista de documentos + """ + project_dir = find_project_directory(project_id) + + if not project_dir: + return [] + + documents_dir = os.path.join(project_dir, 'documents') + + if not os.path.exists(documents_dir): + return [] + + documents = [] + + # Iterar sobre directorios de documentos + for dir_name in os.listdir(documents_dir): + if dir_name.startswith('@') and os.path.isdir(os.path.join(documents_dir, dir_name)): + document_dir = os.path.join(documents_dir, dir_name) + meta_file = os.path.join(document_dir, 'meta.json') + + if os.path.exists(meta_file): + document_meta = load_json_file(meta_file) + + if document_meta: + # Extraer ID del documento del nombre del directorio + try: + doc_id = int(dir_name.split('_', 1)[0].replace('@', '')) + document_meta['id'] = doc_id + document_meta['directory'] = dir_name + documents.append(document_meta) + except (ValueError, IndexError): + pass + + return documents + +def delete_document(project_id, document_id): + """ + Eliminar un documento. + + Args: + project_id (int): ID del proyecto + document_id (int): ID del documento + + Returns: + tuple: (success, message) + """ + # Buscar documento + project_dir = find_project_directory(project_id) + + if not project_dir: + return False, f"No se encontró el proyecto con ID {project_id}." + + document_dir = find_document_directory(project_dir, document_id) + + if not document_dir: + return False, f"No se encontró el documento con ID {document_id}." + + # Eliminar directorio y contenido + try: + import shutil + shutil.rmtree(document_dir) + return True, "Documento eliminado correctamente." + except Exception as e: + return False, f"Error al eliminar el documento: {str(e)}" \ No newline at end of file diff --git a/services/export_service.py b/services/export_service.py new file mode 100644 index 0000000..e69de29 diff --git a/services/index_service.py b/services/index_service.py new file mode 100644 index 0000000..e69de29 diff --git a/services/project_service.py b/services/project_service.py new file mode 100644 index 0000000..7932c24 --- /dev/null +++ b/services/project_service.py @@ -0,0 +1,318 @@ +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, ensure_dir_exists, + get_next_id, format_project_directory_name +) + +def create_project(project_data, creator_username): + """ + Crear un nuevo proyecto. + + Args: + project_data (dict): Datos del proyecto + creator_username (str): Usuario que crea el proyecto + + Returns: + tuple: (success, message, project_id) + """ + # Validar datos obligatorios + required_fields = ['descripcion', 'cliente', 'esquema'] + for field in required_fields: + if field not in project_data or not project_data[field]: + return False, f"El campo '{field}' es obligatorio.", None + + # Obtener siguiente ID de proyecto + project_id = get_next_id('project') + + # Crear código de proyecto (PROJ001, etc.) + project_code = f"PROJ{project_id:03d}" + + # Preparar directorio del proyecto + storage_path = current_app.config['STORAGE_PATH'] + dir_name = format_project_directory_name(project_id, project_data['descripcion']) + project_dir = os.path.join(storage_path, 'projects', dir_name) + + # Verificar si ya existe + if os.path.exists(project_dir): + return False, "Ya existe un proyecto con esa descripción.", None + + # Crear directorios + ensure_dir_exists(project_dir) + ensure_dir_exists(os.path.join(project_dir, 'documents')) + + # Crear metadatos del proyecto + project_meta = { + 'codigo': project_code, + 'proyecto_padre': project_data.get('proyecto_padre'), + 'esquema': project_data['esquema'], + 'descripcion': project_data['descripcion'], + 'cliente': project_data['cliente'], + 'destinacion': project_data.get('destinacion', ''), + 'ano_creacion': datetime.now().year, + 'fecha_creacion': datetime.now(pytz.UTC).isoformat(), + 'creado_por': creator_username, + 'estado': 'activo', + 'ultima_modificacion': datetime.now(pytz.UTC).isoformat(), + 'modificado_por': creator_username + } + + # Guardar metadatos + meta_file = os.path.join(project_dir, 'project_meta.json') + save_json_file(meta_file, project_meta) + + # Guardar permisos del proyecto (inicialmente vacío) + permissions_file = os.path.join(project_dir, 'permissions.json') + save_json_file(permissions_file, {}) + + # Copiar el esquema seleccionado + schema_file = os.path.join(storage_path, 'schemas', 'schema.json') + schemas = load_json_file(schema_file, {}) + + if project_data['esquema'] in schemas: + project_schema_file = os.path.join(project_dir, 'schema.json') + save_json_file(project_schema_file, schemas[project_data['esquema']]) + + return True, "Proyecto creado correctamente.", project_id + +def update_project(project_id, project_data, modifier_username): + """ + Actualizar un proyecto existente. + + Args: + project_id (int): ID del proyecto + project_data (dict): Datos actualizados + modifier_username (str): Usuario que modifica + + Returns: + tuple: (success, message) + """ + # Buscar el proyecto + project_dir = find_project_directory(project_id) + + if not project_dir: + return False, f"No se encontró el proyecto con ID {project_id}." + + # Cargar metadatos actuales + meta_file = os.path.join(project_dir, 'project_meta.json') + current_meta = load_json_file(meta_file) + + # Actualizar campos + for key, value in project_data.items(): + if key in current_meta and key not in ['codigo', 'fecha_creacion', 'creado_por', 'ano_creacion']: + current_meta[key] = value + + # Actualizar metadatos de modificación + current_meta['ultima_modificacion'] = datetime.now(pytz.UTC).isoformat() + current_meta['modificado_por'] = modifier_username + + # Guardar metadatos actualizados + save_json_file(meta_file, current_meta) + + return True, "Proyecto actualizado correctamente." + +def get_project(project_id): + """ + Obtener información de un proyecto. + + Args: + project_id (int): ID del proyecto + + Returns: + dict: Datos del proyecto o None si no existe + """ + project_dir = find_project_directory(project_id) + + if not project_dir: + return None + + # Cargar metadatos + meta_file = os.path.join(project_dir, 'project_meta.json') + project_meta = load_json_file(meta_file) + + # Agregar la ruta del directorio + project_meta['directory'] = os.path.basename(project_dir) + + return project_meta + +def delete_project(project_id): + """ + Eliminar un proyecto (marcar como inactivo). + + Args: + project_id (int): ID del proyecto + + Returns: + tuple: (success, message) + """ + project_dir = find_project_directory(project_id) + + if not project_dir: + return False, f"No se encontró el proyecto con ID {project_id}." + + # Cargar metadatos + meta_file = os.path.join(project_dir, 'project_meta.json') + project_meta = load_json_file(meta_file) + + # Marcar como inactivo (no eliminar físicamente) + project_meta['estado'] = 'inactivo' + project_meta['ultima_modificacion'] = datetime.now(pytz.UTC).isoformat() + + # Guardar metadatos actualizados + save_json_file(meta_file, project_meta) + + return True, "Proyecto marcado como inactivo." + +def get_all_projects(include_inactive=False): + """ + Obtener todos los proyectos. + + Args: + include_inactive (bool): Incluir proyectos inactivos + + Returns: + list: Lista de proyectos + """ + storage_path = current_app.config['STORAGE_PATH'] + projects_dir = os.path.join(storage_path, 'projects') + projects = [] + + # Iterar sobre directorios de proyectos + for dir_name in os.listdir(projects_dir): + if dir_name.startswith('@'): # Formato de directorio de proyecto + project_dir = os.path.join(projects_dir, dir_name) + + if os.path.isdir(project_dir): + meta_file = os.path.join(project_dir, 'project_meta.json') + + if os.path.exists(meta_file): + project_meta = load_json_file(meta_file) + + # Incluir solo si está activo o se solicitan inactivos + if include_inactive or project_meta.get('estado') == 'activo': + # Agregar la ruta del directorio + project_meta['directory'] = dir_name + projects.append(project_meta) + + return projects + +def find_project_directory(project_id): + """ + Encontrar el directorio de un proyecto por su ID. + + Args: + project_id (int): ID del proyecto + + Returns: + str: Ruta al directorio o None si no se encuentra + """ + storage_path = current_app.config['STORAGE_PATH'] + projects_dir = os.path.join(storage_path, 'projects') + + # Prefijo a buscar en nombres de directorios + prefix = f"@{int(project_id):03d}_@" + + for dir_name in os.listdir(projects_dir): + if dir_name.startswith(prefix): + return os.path.join(projects_dir, dir_name) + + return None + +def get_project_children(project_id): + """ + Obtener proyectos hijos de un proyecto. + + Args: + project_id (int): ID del proyecto padre + + Returns: + list: Lista de proyectos hijos + """ + all_projects = get_all_projects() + project_code = f"PROJ{int(project_id):03d}" + + # Filtrar proyectos con este padre + children = [p for p in all_projects if p.get('proyecto_padre') == project_code] + + return children + +def get_project_document_count(project_id): + """ + Contar documentos en un proyecto. + + Args: + project_id (int): ID del proyecto + + Returns: + int: Número de documentos + """ + project_dir = find_project_directory(project_id) + + if not project_dir: + return 0 + + documents_dir = os.path.join(project_dir, 'documents') + + if not os.path.exists(documents_dir): + return 0 + + # Contar directorios de documentos + count = 0 + for item in os.listdir(documents_dir): + if os.path.isdir(os.path.join(documents_dir, item)) and item.startswith('@'): + count += 1 + + return count + +def filter_projects(filter_params): + """ + Filtrar proyectos según los parámetros proporcionados. + + Args: + filter_params (dict): Diccionario con parámetros de filtrado + - cliente: Nombre de cliente + - estado: Estado del proyecto ('activo', 'inactivo') + - ano_inicio: Año de inicio para filtrar + - ano_fin: Año final para filtrar + - descripcion: Término de búsqueda en descripción + + Returns: + list: Lista de proyectos que cumplen los criterios + """ + # Obtener todos los proyectos (incluyendo inactivos si se solicitan) + include_inactive = filter_params.get('estado') == 'inactivo' + all_projects = get_all_projects(include_inactive) + filtered_projects = [] + + for project in all_projects: + # Filtrar por cliente + if 'cliente' in filter_params and filter_params['cliente']: + if project['cliente'] != filter_params['cliente']: + continue + + # Filtrar por estado + if 'estado' in filter_params and filter_params['estado']: + if project['estado'] != filter_params['estado']: + continue + + # Filtrar por año de creación (rango) + if 'ano_inicio' in filter_params and filter_params['ano_inicio']: + if project['ano_creacion'] < int(filter_params['ano_inicio']): + continue + + if 'ano_fin' in filter_params and filter_params['ano_fin']: + if project['ano_creacion'] > int(filter_params['ano_fin']): + continue + + # Filtrar por término en descripción + if 'descripcion' in filter_params and filter_params['descripcion']: + if filter_params['descripcion'].lower() not in project['descripcion'].lower(): + continue + + # Si pasó todos los filtros, agregar a la lista + filtered_projects.append(project) + + return filtered_projects \ No newline at end of file diff --git a/services/schema_service.py b/services/schema_service.py new file mode 100644 index 0000000..fed4156 --- /dev/null +++ b/services/schema_service.py @@ -0,0 +1,212 @@ +import os +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): + """ + Obtener un esquema específico. + + Args: + schema_code (str): Código del esquema + + Returns: + dict: Datos del esquema o None si no existe + """ + schemas = get_all_schemas() + + return schemas.get(schema_code) + +def create_schema(schema_data, creator_username): + """ + 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 + +def update_schema(schema_code, schema_data): + """ + Actualizar un esquema existente. + + Args: + schema_code (str): Código del esquema + schema_data (dict): Datos actualizados + + Returns: + tuple: (success, message) + """ + 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): + """ + Eliminar un esquema. + + Args: + schema_code (str): Código del esquema + + Returns: + tuple: (success, message) + """ + 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}." + + # 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." + +def get_schema_document_types(schema_code): + """ + Obtener los tipos de documento definidos en un esquema. + + Args: + schema_code (str): Código del esquema + + Returns: + list: Lista de tipos de documento + """ + schema = get_schema(schema_code) + + if not schema: + return [] + + return schema.get('documentos', []) + +def validate_document_for_schema(schema_code, document_type): + """ + Validar si un tipo de documento está permitido en un esquema. + + Args: + schema_code (str): Código del esquema + document_type (str): Tipo de documento + + Returns: + tuple: (is_valid, required_level) - Validez y nivel requerido + """ + schema = get_schema(schema_code) + + if not schema: + return False, None + + for doc in schema.get('documentos', []): + if doc.get('tipo') == document_type: + return True, doc.get('nivel_editar', 0) + + return False, None + +def initialize_default_schemas(): + """ + Inicializar esquemas predeterminados si no existen. + """ + schemas = get_all_schemas() + + if not schemas: + # Crear esquema estándar + create_schema({ + 'descripcion': 'Proyecto estándar', + 'documentos': [ + { + 'tipo': 'pdf', + 'nombre': 'Manual de Usuario', + 'nivel_ver': 0, + 'nivel_editar': 5000 + }, + { + 'tipo': 'txt', + 'nombre': 'Notas del Proyecto', + 'nivel_ver': 0, + 'nivel_editar': 1000 + }, + { + 'tipo': 'zip', + 'nombre': 'Archivos Fuente', + 'nivel_ver': 1000, + 'nivel_editar': 5000 + } + ] + }, 'admin') + + current_app.logger.info('Esquema predeterminado creado.') \ No newline at end of file diff --git a/services/user_service.py b/services/user_service.py new file mode 100644 index 0000000..07931cd --- /dev/null +++ b/services/user_service.py @@ -0,0 +1,116 @@ +from services.auth_service import ( + get_user_by_username, get_user_by_id, get_all_users, + create_user, update_user, delete_user +) + +# 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']: + continue + + # Filtrar por 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']): + 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']): + 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 + } + } + + # Calcular estadísticas + for user in users: + # Estado + if user.estado == 'activo': + stats['activos'] += 1 + else: + stats['inactivos'] += 1 + + # Expirados + if user.is_expired(): + stats['expirados'] += 1 + + # Por empresa + 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 + elif user.nivel >= 5000: + stats['por_nivel']['gestor'] += 1 + elif user.nivel >= 1000: + stats['por_nivel']['editor'] += 1 + else: + 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 \ No newline at end of file diff --git a/static/css/bootstrap.min.css b/static/css/bootstrap.min.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/documents.css b/static/css/documents.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..e69de29 diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..8b5b067 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,177 @@ +/* Estilos generales del sistema ARCH */ + +/* Estructura básica */ +html, body { + height: 100%; +} + +body { + display: flex; + flex-direction: column; + background-color: #f8f9fa; +} + +.container { + flex: 1 0 auto; + padding-bottom: 2rem; +} + +.footer { + flex-shrink: 0; + margin-top: auto; +} + +/* Encabezados */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + color: #212529; +} + +/* Tarjetas */ +.card { + border-radius: 0.5rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + margin-bottom: 1.5rem; +} + +.card-header { + background-color: #f8f9fa; + border-bottom: 1px solid rgba(0, 0, 0, 0.125); + padding: 0.75rem 1.25rem; +} + +.card-title { + margin-bottom: 0; + color: #495057; +} + +/* Tablas */ +.table th { + font-weight: 600; + color: #495057; +} + +.table-hover tbody tr:hover { + background-color: rgba(0, 123, 255, 0.05); +} + +/* Botones */ +.btn { + border-radius: 0.25rem; + font-weight: 500; +} + +/* Navegación */ +.navbar { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.navbar-brand { + font-weight: 700; +} + +.breadcrumb { + background-color: transparent; + padding: 0.75rem 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid #e9ecef; +} + +/* Iconos */ +.btn i { + margin-right: 0.25rem; +} + +/* Formularios */ +.form-label { + font-weight: 500; + color: #495057; +} + +.form-control:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +/* Alertas */ +.alert { + border-radius: 0.25rem; + border-left-width: 4px; +} + +.alert-success { + border-left-color: #28a745; +} + +.alert-warning { + border-left-color: #ffc107; +} + +.alert-danger { + border-left-color: #dc3545; +} + +.alert-info { + border-left-color: #17a2b8; +} + +/* Distintivos */ +.badge { + font-weight: 500; +} + +/* Paginación */ +.pagination .page-item.active .page-link { + background-color: #007bff; + border-color: #007bff; +} + +.pagination .page-link { + color: #007bff; +} + +/* Animaciones */ +.fade-in { + animation: fadeIn 0.5s; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Elementos de carga */ +.spinner-border { + color: #007bff; +} + +/* Tooltip y popovers */ +.tooltip-inner { + max-width: 200px; + padding: 0.25rem 0.5rem; + color: #fff; + background-color: #000; + border-radius: 0.25rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .card-title { + font-size: 1.1rem; + } + + .btn-sm-block { + display: block; + width: 100%; + margin-bottom: 0.5rem; + } +} + +/* Colores personalizados */ +.bg-light-blue { + background-color: #e6f2ff; +} + +.text-primary-dark { + color: #0056b3; +} \ No newline at end of file diff --git a/static/css/projects.css b/static/css/projects.css new file mode 100644 index 0000000..e69de29 diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/documents.js b/static/js/documents.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/projects.js b/static/js/projects.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/schemas.js b/static/js/schemas.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/users.js b/static/js/users.js new file mode 100644 index 0000000..e69de29 diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/admin/filetypes.html b/templates/admin/filetypes.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/admin/system.html b/templates/admin/system.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..a333aa2 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block title %}Iniciar sesión - ARCH{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Iniciar sesión

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control", placeholder="Ingrese su nombre de usuario") }} + {% if form.username.errors %} +
+ {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control", placeholder="Ingrese su contraseña") }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.remember_me(class="form-check-input") }} + {{ form.remember_me.label(class="form-check-label") }} +
+ +
+ {{ form.submit(class="btn btn-primary btn-lg") }} +
+
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..584ecf3 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,116 @@ + + + + + + {% block title %}ARCH - Sistema de Gestión de Documentos{% endblock %} + + + + + + + + + {% block styles %}{% endblock %} + + + + + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} + + +

{% block page_title %}{% endblock %}

+ + + {% block content %}{% endblock %} +
+ + +
+
+
+ ARCH - Sistema de Gestión de Documentos © {{ now.year }} +
+
+
+ + + + + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/templates/documents/download.html b/templates/documents/download.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/documents/list.html b/templates/documents/list.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/documents/upload.html b/templates/documents/upload.html new file mode 100644 index 0000000..7fa6d8d --- /dev/null +++ b/templates/documents/upload.html @@ -0,0 +1,167 @@ +{% extends "base.html" %} + +{% block title %}Subir documento - ARCH{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block page_title %}Subir nuevo documento{% endblock %} + +{% block content %} +
+
+ + +
+
+
Subir documento a proyecto: {{ project.descripcion }}
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.nombre.label(class="form-label") }} + {{ form.nombre(class="form-control") }} + {% if form.nombre.errors %} +
+ {% for error in form.nombre.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ Nombre descriptivo para identificar el documento en el sistema. +
+
+ +
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=3) }} + {% if form.description.errors %} +
+ {% for error in form.description.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.file.label(class="form-label") }} + {{ form.file(class="form-control", accept=".pdf,.doc,.docx,.txt,.zip,.rar,.dwg,.xlsx,.pptx") }} + {% if form.file.errors %} +
+ {% for error in form.file.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ + Volver al proyecto + + + {{ form.submit(class="btn btn-primary") }} +
+
+
+
+
+ +
+
+
+
Tipos de Archivo Permitidos
+
+
+
+
+
+ +
+
PDF
+ Documentos, manuales, informes +
+
+
+
+
+ +
+
Word
+ Documentos editables (.doc, .docx) +
+
+
+
+
+ +
+
Excel
+ Hojas de cálculo (.xlsx) +
+
+
+
+
+ +
+
Archivos comprimidos
+ ZIP, RAR +
+
+
+
+
+ +
+
Otros formatos
+ DWG, TXT, etc. +
+
+
+
+ +
+ El tamaño máximo de archivo permitido es de 100MB. +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/documents/versions.html b/templates/documents/versions.html new file mode 100644 index 0000000..68655c3 --- /dev/null +++ b/templates/documents/versions.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} + +{% block title %}Versiones de documento - ARCH{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block page_title %}Versiones de documento{% endblock %} + +{% block content %} +
+
+ + +
+
+
Información del Documento
+
+
+
+
+
+
Archivo original:
+
{{ document.original_filename }}
+ +
ID:
+
{{ document.document_id }}
+
+
+
+
+
Versiones:
+
{{ document.versions|length }}
+ +
Última versión:
+
+ v{{ document.versions|map(attribute='version')|list|max }} + ({{ document.versions|selectattr('version', 'eq', document.versions|map(attribute='version')|list|max)|map(attribute='created_at')|first|replace('T', ' ')|replace('Z', '') }}) +
+
+
+
+
+
+
+ +
+
+
+
Acciones
+
+
+
+ + Descargar última versión + + + + Volver al proyecto + + + {% if current_user.has_permission(9000) and project.estado == 'activo' %} + + {% endif %} +
+
+
+ + {% if project.estado == 'activo' and current_user.has_permission(1000) %} +
+
+
Nueva Versión
+
+
+
+ {{ form.hidden_tag() }} + {{ form.document_id }} + +
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=3) }} + {% if form.description.errors %} +
+ {% for error in form.description.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.file.label(class="form-label") }} + {{ form.file(class="form-control") }} + {% if form.file.errors %} +
+ {% for error in form.file.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ El archivo debe ser del mismo tipo que el original. +
+
+ +
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+
+ {% endif %} +
+
+ +
+
+
+
+
Historial de Versiones
+
+
+
+ + + + + + + + + + + + + + + {% for version in document.versions|sort(attribute='version', reverse=true) %} + + + + + + + + + + + {% endfor %} + +
VersiónNombre de archivoFecha de creaciónUsuarioTamañoDescripciónDescargas
+ v{{ version.version }} + {% if loop.first %} + Última + {% endif %} + {{ version.filename }}{{ version.created_at|replace('T', ' ')|replace('Z', '') }}{{ version.created_by }}{{ (version.file_size / 1024)|round(1) }} KB{{ version.description }}{{ version.downloads|length }} + + + +
+
+
+
+
+
+ + +{% if current_user.has_permission(9000) and project.estado == 'activo' %} + +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..ab3de6f --- /dev/null +++ b/templates/error.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %}Error {{ error_code }} - ARCH{% endblock %} + +{% block page_title %}Error {{ error_code }}{% endblock %} + +{% block content %} +
+
+
+

{{ error_code }}

+

{{ error_message }}

+ +

Lo sentimos, ha ocurrido un error.

+ + {% if error_code == 404 %} +

La página o recurso que está buscando no se ha encontrado.

+ {% elif error_code == 403 %} +

No tiene permisos para acceder a este recurso.

+ {% elif error_code == 500 %} +

Ha ocurrido un error interno del servidor. Por favor, inténtelo más tarde.

+ {% endif %} + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/projects/create.html b/templates/projects/create.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/projects/edit.html b/templates/projects/edit.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/projects/list.html b/templates/projects/list.html new file mode 100644 index 0000000..fc0c8c5 --- /dev/null +++ b/templates/projects/list.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} + +{% block title %}Proyectos - ARCH{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block page_title %}Proyectos{% endblock %} + +{% block content %} +
+
+
+
+
Filtrar proyectos
+
+
+
+
+ {{ filter_form.cliente.label(class="form-label") }} + {{ filter_form.cliente(class="form-control") }} +
+
+ {{ filter_form.estado.label(class="form-label") }} + {{ filter_form.estado(class="form-select") }} +
+
+ {{ filter_form.ano_inicio.label(class="form-label") }} + {{ filter_form.ano_inicio(class="form-control", type="number") }} +
+
+ {{ filter_form.ano_fin.label(class="form-label") }} + {{ filter_form.ano_fin(class="form-control", type="number") }} +
+
+ {{ filter_form.descripcion.label(class="form-label") }} + {{ filter_form.descripcion(class="form-control") }} +
+
+ + + Limpiar filtros + +
+
+
+
+
+ +
+ {% if current_user.has_permission(1000) %} + + {% endif %} +
+
+ +
+
+
+
+
+
Lista de Proyectos
+ {{ projects|length }} proyectos +
+
+
+
+ + + + + + + + + + + + + + {% if projects %} + {% for project in projects %} + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
CódigoDescripciónClienteAñoCreado porEstadoAcciones
{{ project.codigo }} + {% if project.proyecto_padre %} + Subproyecto + {% endif %} + {{ project.descripcion }} + {{ project.cliente }}{{ project.ano_creacion }}{{ project.creado_por }} + {% if project.estado == 'activo' %} + Activo + {% else %} + Inactivo + {% endif %} + +
+ + + + + {% if current_user.has_permission(5000) %} + + + + {% endif %} + + {% if current_user.has_permission(9000) and project.estado == 'activo' %} + + {% endif %} +
+ + + {% if current_user.has_permission(9000) and project.estado == 'activo' %} + + {% endif %} +
+ No se encontraron proyectos. + {% if request.args %} + Quitar filtros + {% endif %} +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/projects/view.html b/templates/projects/view.html new file mode 100644 index 0000000..796bf97 --- /dev/null +++ b/templates/projects/view.html @@ -0,0 +1,270 @@ +{% extends "base.html" %} + +{% block title %}{{ project.descripcion }} - ARCH{% endblock %} + +{% block styles %} + + +{% endblock %} + +{% block page_title %}Proyecto: {{ project.descripcion }}{% endblock %} + +{% block content %} +
+
+
+
+
Detalles del Proyecto
+ + {{ project.estado|capitalize }} + +
+
+
+
+
+
Código:
+
{{ project.codigo }}
+ +
Cliente:
+
{{ project.cliente }}
+ +
Destinación:
+
{{ project.destinacion or 'No especificada' }}
+
+
+
+
+
Año:
+
{{ project.ano_creacion }}
+ +
Creación:
+
{{ project.fecha_creacion|replace('T', ' ')|replace('Z', '') }}
+ +
Creado por:
+
{{ project.creado_por }}
+
+
+
+ +
+ +
+
+
+
Proyecto padre:
+
+ {% if project.proyecto_padre %} + + {{ project.proyecto_padre }} + + {% else %} + Ninguno + {% endif %} +
+ +
Esquema:
+
{{ project.esquema }}
+
+
+
+
+
Última modificación:
+
{{ project.ultima_modificacion|replace('T', ' ')|replace('Z', '') }}
+ +
Modificado por:
+
{{ project.modificado_por }}
+
+
+
+
+ +
+
+ +
+
+
+
Información
+
+
+

+ Documentos: + {{ document_count }} +

+ +

+ Subproyectos: + {{ children|length }} +

+ + {% if project.estado == 'activo' and current_user.has_permission(1000) %} +
+ + + {% endif %} +
+
+ + {% if children %} +
+
+
Subproyectos
+
+
+
+ {% for child in children %} + + {{ child.descripcion }} + + {{ child.estado|capitalize }} + + + {% endfor %} +
+
+
+ {% endif %} +
+
+ + +
+
+
+
+
Documentos del Proyecto
+ + Ver todos + +
+
+
+
+
+ Cargando... +
+

Cargando documentos...

+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/schemas/create.html b/templates/schemas/create.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/schemas/edit.html b/templates/schemas/edit.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/schemas/list.html b/templates/schemas/list.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/users/create.html b/templates/users/create.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/users/edit.html b/templates/users/edit.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/users/list.html b/templates/users/list.html new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/json_reporter.py b/tests/json_reporter.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/file_utils.py b/utils/file_utils.py new file mode 100644 index 0000000..f937a34 --- /dev/null +++ b/utils/file_utils.py @@ -0,0 +1,209 @@ +import os +import json +import shutil +import zipfile +from datetime import datetime +import pytz +from flask import current_app +from werkzeug.utils import secure_filename + +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. + + Args: + file_path (str): Ruta al archivo JSON + default: Valor por defecto si el archivo no existe o no es válido + + Returns: + dict: Contenido del archivo JSON o valor por defecto + """ + try: + 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 + +def save_json_file(file_path, data): + """ + Guardar datos en un archivo JSON. + + Args: + file_path (str): Ruta al archivo JSON + data: Datos a guardar (deben ser serializables a JSON) + """ + # Asegurar que el directorio existe + directory = os.path.dirname(file_path) + ensure_dir_exists(directory) + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +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') + + # 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'] + 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(' ', '_') + + # 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(' ', '_') + + # 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(' ', '_') + + # Asegurar que la extensión no tiene el punto + 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: + for file_info in files_to_include: + zipf.write( + 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 + } + +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) \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/security.py b/utils/security.py new file mode 100644 index 0000000..7adcd1a --- /dev/null +++ b/utils/security.py @@ -0,0 +1,122 @@ +import os +import hashlib +import re +import magic +from flask import current_app +from functools import wraps +from flask_login import current_user +from werkzeug.utils import secure_filename + +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) + + # Reemplazar espacios por guiones bajos + 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 \ No newline at end of file diff --git a/utils/validators.py b/utils/validators.py new file mode 100644 index 0000000..e69de29