Version creada por Claude 3.7
This commit is contained in:
parent
70de427e7e
commit
1a931474b0
|
@ -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)
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
1075
descripcion.md
1075
descripcion.md
File diff suppressed because it is too large
Load Diff
|
@ -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()
|
|
@ -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
|
|
@ -1,14 +1,45 @@
|
||||||
|
# Componentes principales
|
||||||
Flask==2.3.3
|
Flask==2.3.3
|
||||||
Werkzeug==2.3.7
|
Werkzeug==2.3.7
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
click==8.1.3
|
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
|
Flask-WTF==1.2.1
|
||||||
WTForms==3.0.1
|
WTForms==3.0.1
|
||||||
Flask-Session==0.5.0
|
|
||||||
urllib3==2.0.4
|
# Autenticación y seguridad
|
||||||
requests==2.31.0
|
Flask-Login==0.6.2
|
||||||
boto3==1.28.16
|
Flask-Bcrypt==1.0.1
|
||||||
|
|
||||||
|
# Manejo de fechas y zonas horarias
|
||||||
|
python-dateutil==2.8.2
|
||||||
pytz==2023.3
|
pytz==2023.3
|
||||||
|
|
||||||
|
# Configuración y entorno
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
# Gestión de sesiones
|
||||||
|
Flask-Session==0.5.0
|
||||||
|
|
||||||
|
# HTTP y comunicaciones
|
||||||
|
requests==2.31.0
|
||||||
|
|
||||||
|
# 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
|
|
@ -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/<token>', 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')
|
|
@ -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('/<int:project_id>')
|
||||||
|
@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('/<int:project_id>/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('/<int:project_id>/<int:document_id>')
|
||||||
|
@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('/<int:project_id>/<int:document_id>/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('/<int:project_id>/<int:document_id>/download/<int:version>')
|
||||||
|
@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('/<int:project_id>/<int:document_id>/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('/<int:project_id>/<int:document_id>/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('/<int:project_id>/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('/<int:project_id>/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)
|
|
@ -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('/<int:project_id>')
|
||||||
|
@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('/<int:project_id>/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('/<int:project_id>/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])
|
|
@ -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.')
|
|
@ -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)}"
|
|
@ -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
|
|
@ -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.')
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Iniciar sesión - ARCH{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-5">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white text-center">
|
||||||
|
<h4 class="my-2">Iniciar sesión</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.username.label(class="form-label") }}
|
||||||
|
{{ form.username(class="form-control", placeholder="Ingrese su nombre de usuario") }}
|
||||||
|
{% if form.username.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.password.label(class="form-label") }}
|
||||||
|
{{ form.password(class="form-control", placeholder="Ingrese su contraseña") }}
|
||||||
|
{% if form.password.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
{{ form.remember_me(class="form-check-input") }}
|
||||||
|
{{ form.remember_me.label(class="form-check-label") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
{{ form.submit(class="btn btn-primary btn-lg") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<a href="{{ url_for('auth.reset_password_request') }}">¿Olvidó su contraseña?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,116 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}ARCH - Sistema de Gestión de Documentos{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
|
||||||
|
|
||||||
|
<!-- Estilos principales -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||||
|
|
||||||
|
<!-- Estilos específicos -->
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Barra de navegación -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('projects.list') }}">
|
||||||
|
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="ARCH" height="30">
|
||||||
|
ARCH
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('projects.list') }}">Proyectos</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if current_user.has_permission(5000) %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('schemas.list') }}">Esquemas</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user.has_permission(9000) %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="adminDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
Administración
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('users.list') }}">Usuarios</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.filetypes') }}">Tipos de archivo</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.system') }}">Estado del sistema</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
{{ current_user.nombre }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Mi perfil</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Cerrar sesión</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.login') }}">Iniciar sesión</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Contenido principal -->
|
||||||
|
<div class="container mt-4">
|
||||||
|
<!-- Mensajes flash -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Título de la página -->
|
||||||
|
<h1 class="mb-4">{% block page_title %}{% endblock %}</h1>
|
||||||
|
|
||||||
|
<!-- Contenido -->
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer mt-5 py-3 bg-light">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="text-muted">ARCH - Sistema de Gestión de Documentos © {{ now.year }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="{{ url_for('static', filename='js/lib/jquery.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/lib/bootstrap.bundle.min.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- JavaScript específico -->
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,167 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Subir documento - ARCH{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/documents.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}Subir nuevo documento{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('projects.list') }}">Proyectos</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('projects.view', project_id=project.codigo|replace('PROJ', '')|int) }}">{{ project.descripcion }}</a></li>
|
||||||
|
<li class="breadcrumb-item active">Subir documento</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Subir documento a proyecto: {{ project.descripcion }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('documents.upload', project_id=project.codigo|replace('PROJ', '')|int) }}" enctype="multipart/form-data">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.nombre.label(class="form-label") }}
|
||||||
|
{{ form.nombre(class="form-control") }}
|
||||||
|
{% if form.nombre.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.nombre.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
Nombre descriptivo para identificar el documento en el sistema.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.description.label(class="form-label") }}
|
||||||
|
{{ form.description(class="form-control", rows=3) }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.description.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ 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 %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.file.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.codigo|replace('PROJ', '')|int) }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Volver al proyecto
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{ form.submit(class="btn btn-primary") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Tipos de Archivo Permitidos</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="list-group">
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-file-earmark-pdf text-danger me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">PDF</h6>
|
||||||
|
<small class="text-muted">Documentos, manuales, informes</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-file-earmark-word text-primary me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Word</h6>
|
||||||
|
<small class="text-muted">Documentos editables (.doc, .docx)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-file-earmark-excel text-success me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Excel</h6>
|
||||||
|
<small class="text-muted">Hojas de cálculo (.xlsx)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-file-earmark-zip text-warning me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Archivos comprimidos</h6>
|
||||||
|
<small class="text-muted">ZIP, RAR</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-file-earmark-text text-secondary me-2 fs-4"></i>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Otros formatos</h6>
|
||||||
|
<small class="text-muted">DWG, TXT, etc.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="bi bi-info-circle"></i> El tamaño máximo de archivo permitido es de 100MB.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/documents.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// Validación adicional del formulario
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('form').on('submit', function(e) {
|
||||||
|
var fileInput = $('#file')[0];
|
||||||
|
|
||||||
|
if (fileInput.files.length > 0) {
|
||||||
|
var fileSize = fileInput.files[0].size; // bytes
|
||||||
|
var maxSize = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
|
if (fileSize > maxSize) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('El archivo es demasiado grande. El tamaño máximo permitido es 100MB.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,207 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Versiones de documento - ARCH{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/documents.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}Versiones de documento{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('projects.list') }}">Proyectos</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('projects.view', project_id=project.codigo|replace('PROJ', '')|int) }}">{{ project.descripcion }}</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ document.original_filename }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Información del Documento</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Archivo original:</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.original_filename }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">ID:</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.document_id }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Versiones:</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.versions|length }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Última versión:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
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', '') }})
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Acciones</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ url_for('documents.download_latest', project_id=project.codigo|replace('PROJ', '')|int, document_id=document.document_id|replace(document.document_id.split('_')[0] + '_', '')|int) }}"
|
||||||
|
class="btn btn-success">
|
||||||
|
<i class="bi bi-download"></i> Descargar última versión
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Volver al proyecto
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if current_user.has_permission(9000) and project.estado == 'activo' %}
|
||||||
|
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||||
|
<i class="bi bi-trash"></i> Eliminar documento
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if project.estado == 'activo' and current_user.has_permission(1000) %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Nueva Versión</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('documents.upload_version', project_id=project.codigo|replace('PROJ', '')|int, document_id=document.document_id|replace(document.document_id.split('_')[0] + '_', '')|int) }}" enctype="multipart/form-data">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{{ form.document_id }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.description.label(class="form-label") }}
|
||||||
|
{{ form.description(class="form-control", rows=3) }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.description.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.file.label(class="form-label") }}
|
||||||
|
{{ form.file(class="form-control") }}
|
||||||
|
{% if form.file.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.file.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
El archivo debe ser del mismo tipo que el original.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
{{ form.submit(class="btn btn-primary") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Historial de Versiones</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-striped">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Versión</th>
|
||||||
|
<th>Nombre de archivo</th>
|
||||||
|
<th>Fecha de creación</th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Tamaño</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Descargas</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for version in document.versions|sort(attribute='version', reverse=true) %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">v{{ version.version }}</span>
|
||||||
|
{% if loop.first %}
|
||||||
|
<span class="badge bg-success">Última</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ version.filename }}</td>
|
||||||
|
<td>{{ version.created_at|replace('T', ' ')|replace('Z', '') }}</td>
|
||||||
|
<td>{{ version.created_by }}</td>
|
||||||
|
<td>{{ (version.file_size / 1024)|round(1) }} KB</td>
|
||||||
|
<td>{{ version.description }}</td>
|
||||||
|
<td>{{ version.downloads|length }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('documents.download', project_id=project.codigo|replace('PROJ', '')|int, document_id=document.document_id|replace(document.document_id.split('_')[0] + '_', '')|int, version=version.version) }}"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal para eliminar documento -->
|
||||||
|
{% if current_user.has_permission(9000) and project.estado == 'activo' %}
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirmar eliminación</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>¿Está seguro de que desea eliminar el documento <strong>{{ document.original_filename }}</strong>?</p>
|
||||||
|
<p class="text-danger">Esta acción no se puede deshacer y eliminará todas las versiones del documento.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form action="{{ url_for('documents.delete', project_id=project.codigo|replace('PROJ', '')|int, document_id=document.document_id|replace(document.document_id.split('_')[0] + '_', '')|int) }}" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-danger">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/documents.js') }}"></script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,32 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Error {{ error_code }} - ARCH{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}Error {{ error_code }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 text-center">
|
||||||
|
<div class="error-container py-5">
|
||||||
|
<h1 class="display-1 text-danger">{{ error_code }}</h1>
|
||||||
|
<h2 class="mb-4">{{ error_message }}</h2>
|
||||||
|
|
||||||
|
<p class="lead">Lo sentimos, ha ocurrido un error.</p>
|
||||||
|
|
||||||
|
{% if error_code == 404 %}
|
||||||
|
<p>La página o recurso que está buscando no se ha encontrado.</p>
|
||||||
|
{% elif error_code == 403 %}
|
||||||
|
<p>No tiene permisos para acceder a este recurso.</p>
|
||||||
|
{% elif error_code == 500 %}
|
||||||
|
<p>Ha ocurrido un error interno del servidor. Por favor, inténtelo más tarde.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<a href="{{ url_for('projects.list') }}" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-house"></i> Ir a la página principal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,178 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Proyectos - ARCH{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/projects.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}Proyectos{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Filtrar proyectos</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="filter-form" method="GET" action="{{ url_for('projects.list') }}" class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
{{ filter_form.cliente.label(class="form-label") }}
|
||||||
|
{{ filter_form.cliente(class="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
{{ filter_form.estado.label(class="form-label") }}
|
||||||
|
{{ filter_form.estado(class="form-select") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
{{ filter_form.ano_inicio.label(class="form-label") }}
|
||||||
|
{{ filter_form.ano_inicio(class="form-control", type="number") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
{{ filter_form.ano_fin.label(class="form-label") }}
|
||||||
|
{{ filter_form.ano_fin(class="form-control", type="number") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
{{ filter_form.descripcion.label(class="form-label") }}
|
||||||
|
{{ filter_form.descripcion(class="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-search"></i> Filtrar
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('projects.list') }}" class="btn btn-outline-secondary">
|
||||||
|
Limpiar filtros
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 d-flex align-items-center">
|
||||||
|
{% if current_user.has_permission(1000) %}
|
||||||
|
<div class="text-center w-100">
|
||||||
|
<a href="{{ url_for('projects.create') }}" class="btn btn-success btn-lg">
|
||||||
|
<i class="bi bi-plus-circle"></i> Nuevo Proyecto
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">Lista de Proyectos</h5>
|
||||||
|
<span class="badge bg-primary">{{ projects|length }} proyectos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-striped mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Código</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Año</th>
|
||||||
|
<th>Creado por</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if projects %}
|
||||||
|
{% for project in projects %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ project.codigo }}</td>
|
||||||
|
<td>
|
||||||
|
{% if project.proyecto_padre %}
|
||||||
|
<span class="badge bg-secondary">Subproyecto</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ project.descripcion }}
|
||||||
|
</td>
|
||||||
|
<td>{{ project.cliente }}</td>
|
||||||
|
<td>{{ project.ano_creacion }}</td>
|
||||||
|
<td>{{ project.creado_por }}</td>
|
||||||
|
<td>
|
||||||
|
{% if project.estado == 'activo' %}
|
||||||
|
<span class="badge bg-success">Activo</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Inactivo</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-primary" title="Ver">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if current_user.has_permission(5000) %}
|
||||||
|
<a href="{{ url_for('projects.edit', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-warning" title="Editar">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user.has_permission(9000) and project.estado == 'activo' %}
|
||||||
|
<button type="button" class="btn btn-danger" title="Eliminar"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#deleteModal{{ project.codigo|replace('PROJ', '')|int }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de confirmación para eliminar -->
|
||||||
|
{% if current_user.has_permission(9000) and project.estado == 'activo' %}
|
||||||
|
<div class="modal fade" id="deleteModal{{ project.codigo|replace('PROJ', '')|int }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirmar eliminación</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>¿Está seguro de que desea eliminar el proyecto <strong>{{ project.descripcion }}</strong>?</p>
|
||||||
|
<p class="text-danger">Esta acción marcará el proyecto como inactivo.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form action="{{ url_for('projects.delete', project_id=project.codigo|replace('PROJ', '')|int) }}" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-danger">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-4">
|
||||||
|
No se encontraron proyectos.
|
||||||
|
{% if request.args %}
|
||||||
|
<a href="{{ url_for('projects.list') }}">Quitar filtros</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/projects.js') }}"></script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,270 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ project.descripcion }} - ARCH{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/projects.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/documents.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}Proyecto: {{ project.descripcion }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">Detalles del Proyecto</h5>
|
||||||
|
<span class="badge bg-{{ 'success' if project.estado == 'activo' else 'danger' }}">
|
||||||
|
{{ project.estado|capitalize }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Código:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.codigo }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Cliente:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.cliente }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Destinación:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.destinacion or 'No especificada' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Año:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.ano_creacion }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Creación:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.fecha_creacion|replace('T', ' ')|replace('Z', '') }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Creado por:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.creado_por }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Proyecto padre:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% if project.proyecto_padre %}
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.proyecto_padre|replace('PROJ', '')|int) }}">
|
||||||
|
{{ project.proyecto_padre }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<em>Ninguno</em>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Esquema:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.esquema }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Última modificación:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.ultima_modificacion|replace('T', ' ')|replace('Z', '') }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Modificado por:</dt>
|
||||||
|
<dd class="col-sm-8">{{ project.modificado_por }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('projects.list') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Volver a la lista
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
{% if current_user.has_permission(5000) and project.estado == 'activo' %}
|
||||||
|
<a href="{{ url_for('projects.edit', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-warning">
|
||||||
|
<i class="bi bi-pencil"></i> Editar Proyecto
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user.has_permission(1000) and project.estado == 'activo' %}
|
||||||
|
<a href="{{ url_for('projects.create') }}?padre={{ project.codigo }}"
|
||||||
|
class="btn btn-info">
|
||||||
|
<i class="bi bi-diagram-3"></i> Crear Subproyecto
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Información</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
<strong>Documentos:</strong>
|
||||||
|
<span class="badge bg-info">{{ document_count }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Subproyectos:</strong>
|
||||||
|
<span class="badge bg-info">{{ children|length }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if project.estado == 'activo' and current_user.has_permission(1000) %}
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{{ url_for('documents.upload', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-success">
|
||||||
|
<i class="bi bi-upload"></i> Subir Documento
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('documents.export', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-primary">
|
||||||
|
<i class="bi bi-box-arrow-down"></i> Exportar Proyecto
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if children %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Subproyectos</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for child in children %}
|
||||||
|
<a href="{{ url_for('projects.view', project_id=child.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
|
{{ child.descripcion }}
|
||||||
|
<span class="badge bg-{{ 'success' if child.estado == 'activo' else 'danger' }} rounded-pill">
|
||||||
|
{{ child.estado|capitalize }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sección de documentos -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">Documentos del Proyecto</h5>
|
||||||
|
<a href="{{ url_for('documents.list', project_id=project.codigo|replace('PROJ', '')|int) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
Ver todos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="documents-container">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2">Cargando documentos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/projects.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// Cargar documentos del proyecto mediante AJAX
|
||||||
|
$(document).ready(function() {
|
||||||
|
$.ajax({
|
||||||
|
url: "{{ url_for('documents.api_list', project_id=project.codigo|replace('PROJ', '')|int) }}",
|
||||||
|
type: "GET",
|
||||||
|
dataType: "json",
|
||||||
|
success: function(data) {
|
||||||
|
displayDocuments(data);
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
$("#documents-container").html(
|
||||||
|
'<div class="alert alert-danger">Error al cargar los documentos.</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function displayDocuments(documents) {
|
||||||
|
if (documents.length === 0) {
|
||||||
|
$("#documents-container").html(
|
||||||
|
'<div class="alert alert-info">No hay documentos en este proyecto.</div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="table-responsive">' +
|
||||||
|
'<table class="table table-hover table-striped">' +
|
||||||
|
'<thead class="table-light">' +
|
||||||
|
'<tr>' +
|
||||||
|
'<th>Nombre</th>' +
|
||||||
|
'<th>Tipo</th>' +
|
||||||
|
'<th>Última versión</th>' +
|
||||||
|
'<th>Creado por</th>' +
|
||||||
|
'<th>Acciones</th>' +
|
||||||
|
'</tr>' +
|
||||||
|
'</thead>' +
|
||||||
|
'<tbody>';
|
||||||
|
|
||||||
|
documents.forEach(function(doc) {
|
||||||
|
// Obtener la última versión
|
||||||
|
let latestVersion = doc.versions.reduce((prev, current) =>
|
||||||
|
(prev.version > current.version) ? prev : current
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determinar ícono según tipo MIME
|
||||||
|
let icon = '<i class="bi bi-file-earmark"></i>';
|
||||||
|
if (latestVersion.mime_type.includes('pdf')) {
|
||||||
|
icon = '<i class="bi bi-file-earmark-pdf"></i>';
|
||||||
|
} else if (latestVersion.mime_type.includes('image')) {
|
||||||
|
icon = '<i class="bi bi-file-earmark-image"></i>';
|
||||||
|
} else if (latestVersion.mime_type.includes('zip')) {
|
||||||
|
icon = '<i class="bi bi-file-earmark-zip"></i>';
|
||||||
|
} else if (latestVersion.mime_type.includes('text')) {
|
||||||
|
icon = '<i class="bi bi-file-earmark-text"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + icon + ' ' + doc.original_filename + '</td>' +
|
||||||
|
'<td>' + latestVersion.mime_type + '</td>' +
|
||||||
|
'<td>v' + latestVersion.version + ' (' + latestVersion.created_at.replace('T', ' ').replace('Z', '') + ')</td>' +
|
||||||
|
'<td>' + latestVersion.created_by + '</td>' +
|
||||||
|
'<td>' +
|
||||||
|
'<div class="btn-group btn-group-sm">' +
|
||||||
|
'<a href="/documents/' + {{ project.codigo|replace('PROJ', '')|int }} + '/' + doc.id + '" class="btn btn-primary" title="Ver versiones">' +
|
||||||
|
'<i class="bi bi-eye"></i>' +
|
||||||
|
'</a>' +
|
||||||
|
'<a href="/documents/' + {{ project.codigo|replace('PROJ', '')|int }} + '/' + doc.id + '/download/' + latestVersion.version + '" class="btn btn-success" title="Descargar última versión">' +
|
||||||
|
'<i class="bi bi-download"></i>' +
|
||||||
|
'</a>' +
|
||||||
|
'</div>' +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
$("#documents-container").html(html);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue