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
|
1097
descripcion.md
1097
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
|
||||
Werkzeug==2.3.7
|
||||
Jinja2==3.1.2
|
||||
itsdangerous==2.1.2
|
||||
click==8.1.3
|
||||
python-dateutil==2.8.2
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Gestión de formularios y validación
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.0.1
|
||||
|
||||
# Autenticación y seguridad
|
||||
Flask-Login==0.6.2
|
||||
Flask-Bcrypt==1.0.1
|
||||
|
||||
# Manejo de fechas y zonas horarias
|
||||
python-dateutil==2.8.2
|
||||
pytz==2023.3
|
||||
|
||||
# Configuración y entorno
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Gestión de sesiones
|
||||
Flask-Session==0.5.0
|
||||
urllib3==2.0.4
|
||||
|
||||
# HTTP y comunicaciones
|
||||
requests==2.31.0
|
||||
boto3==1.28.16
|
||||
pytz==2023.3
|
||||
|
||||
# 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