Version creada por Claude 3.7

This commit is contained in:
Miguel 2025-03-03 19:35:24 +01:00
parent 70de427e7e
commit 1a931474b0
69 changed files with 4951 additions and 213 deletions

117
app.py Normal file
View File

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

86
config.py Normal file
View File

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

124
crear_directorios.bat Normal file
View File

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

File diff suppressed because it is too large Load Diff

187
init_app.py Normal file
View File

@ -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
middleware/__init__.py Normal file
View File

View File

View File

118
readme.md Normal file
View File

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

View File

@ -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
routes/__init__.py Normal file
View File

0
routes/admin_routes.py Normal file
View File

109
routes/auth_routes.py Normal file
View File

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

271
routes/document_routes.py Normal file
View File

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

185
routes/project_routes.py Normal file
View File

@ -0,0 +1,185 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, 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
routes/schema_routes.py Normal file
View File

0
routes/user_routes.py Normal file
View File

0
services/__init__.py Normal file
View File

200
services/auth_service.py Normal file
View File

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

View File

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

View File

View File

318
services/project_service.py Normal file
View File

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

212
services/schema_service.py Normal file
View File

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

116
services/user_service.py Normal file
View File

@ -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
static/css/bootstrap.min.css vendored Normal file
View File

0
static/css/documents.css Normal file
View File

0
static/css/login.css Normal file
View File

177
static/css/main.css Normal file
View File

@ -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
static/css/projects.css Normal file
View File

0
static/js/admin.js Normal file
View File

0
static/js/auth.js Normal file
View File

0
static/js/documents.js Normal file
View File

0
static/js/projects.js Normal file
View File

0
static/js/schemas.js Normal file
View File

0
static/js/users.js Normal file
View File

View File

View File

View File

65
templates/auth/login.html Normal file
View File

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

View File

116
templates/base.html Normal file
View File

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

View File

View File

View File

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

View File

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

32
templates/error.html Normal file
View File

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

View File

View File

View File

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

View File

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

View File

View File

View File

View File

View File

View File

0
tests/__init__.py Normal file
View File

0
tests/conftest.py Normal file
View File

0
tests/json_reporter.py Normal file
View File

0
tests/test_auth.py Normal file
View File

0
tests/test_documents.py Normal file
View File

0
tests/test_projects.py Normal file
View File

0
tests/test_schemas.py Normal file
View File

0
utils/__init__.py Normal file
View File

209
utils/file_utils.py Normal file
View File

@ -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
utils/logger.py Normal file
View File

122
utils/security.py Normal file
View File

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

0
utils/validators.py Normal file
View File