From 57ad12754f13729fa819975a7b92f6e9cfbd8e2e Mon Sep 17 00:00:00 2001 From: ronalds Date: Wed, 19 Nov 2025 11:33:57 -0300 Subject: [PATCH 01/44] esta todo ok --- ROLES_IMPLEMENTATION_GUIDE.md | 166 ++++++++++++++++++++++++++++++++++ backend/app/main.py | 29 +++++- backend/app/models.py | 23 ++++- backend/app/schemas.py | 45 ++++++++- backend/migrate_roles.py | 128 ++++++++++++++++++++++++++ backend/role_endpoints.txt | 100 ++++++++++++++++++++ backend/update_permissions.py | 67 ++++++++++++++ 7 files changed, 550 insertions(+), 8 deletions(-) create mode 100644 ROLES_IMPLEMENTATION_GUIDE.md create mode 100644 backend/migrate_roles.py create mode 100644 backend/role_endpoints.txt create mode 100644 backend/update_permissions.py diff --git a/ROLES_IMPLEMENTATION_GUIDE.md b/ROLES_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..c48bfc8 --- /dev/null +++ b/ROLES_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,166 @@ +# Guía de Implementación del Sistema de Roles + +## 📋 Resumen +Se implementa un sistema de roles basado en base de datos con 3 roles: +- **Administrador** (id=1): Acceso completo +- **Asesor** (id=2): Solo informes y reportes +- **Mecánico** (id=3): Crear y completar inspecciones + +## 🔄 Pasos de Migración + +### 1. Ejecutar Migración de Base de Datos +```bash +cd backend +python migrate_roles.py +``` + +Esto hará: +- ✅ Crear tabla `roles` +- ✅ Insertar 3 roles predefinidos +- ✅ Migrar usuarios existentes (admin -> administrador, mechanic -> mecanico) +- ✅ Eliminar columna `role` antigua + +### 2. Reemplazar Verificaciones de Permisos en main.py + +**ANTES:** +```python +if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No tienes permisos") +``` + +**DESPUÉS:** +```python +require_permission(current_user, "can_manage_users") +``` + +### 3. Permisos por Rol + +| Permiso | Administrador | Asesor | Mecánico | +|---------|--------------|--------|----------| +| can_manage_users | ✅ | ❌ | ❌ | +| can_manage_roles | ✅ | ❌ | ❌ | +| can_manage_checklists | ✅ | ❌ | ❌ | +| can_create_inspections | ✅ | ❌ | ✅ | +| can_view_all_inspections | ✅ | ✅ | ❌ | +| can_view_reports | ✅ | ✅ | ❌ | +| can_deactivate_inspections | ✅ | ❌ | ❌ | + +### 4. Cambios en Endpoints + +#### Gestión de Usuarios +```python +# Antes +if current_user.role != "admin": + +# Después +require_permission(current_user, "can_manage_users") +``` + +#### Gestión de Checklists +```python +# Antes +if current_user.role != "admin": + +# Después +require_permission(current_user, "can_manage_checklists") +``` + +#### Ver todas las Inspecciones +```python +# Antes +if current_user.role == "mechanic": + query = query.filter(models.Inspection.mechanic_id == current_user.id) + +# Después +if not has_permission(current_user, "can_view_all_inspections"): + query = query.filter(models.Inspection.mechanic_id == current_user.id) +``` + +#### Desactivar Inspecciones +```python +# Antes +if current_user.role != "admin": + +# Después +require_permission(current_user, "can_deactivate_inspections") +``` + +### 5. Actualizar Frontend + +**schemas en App.jsx:** +```javascript +// Antes +role: 'mechanic' + +// Después +role_id: 3, // mecanico +role: { + id: 3, + name: 'mecanico', + display_name: 'Mecánico', + can_create_inspections: true, + can_view_reports: false, + // ...otros permisos +} +``` + +**Verificación de permisos:** +```javascript +// Antes +if (user.role === 'admin') + +// Después +if (user.role.can_manage_users) +``` + +### 6. Agregar Endpoints de Roles + +Ver archivo `role_endpoints.txt` para los 5 nuevos endpoints: +- GET /api/roles - Listar roles +- GET /api/roles/{id} - Ver rol +- POST /api/roles - Crear rol +- PUT /api/roles/{id} - Actualizar rol +- DELETE /api/roles/{id} - Eliminar rol + +### 7. Actualizar Componentes Frontend + +#### UsersTab +- Cambiar selector de rol de texto a dropdown con roles de BD +- Mostrar `role.display_name` en lugar de role +- Enviar `role_id` en lugar de `role` al crear/editar + +#### Sidebar +- Mostrar tab "Informes" solo si `user.role.can_view_reports` +- Mostrar tab "Usuarios" solo si `user.role.can_manage_users` + +## ⚠️ Importante + +1. **Backup de BD**: Hacer backup antes de ejecutar migración +2. **Orden de deployment**: + - Backend primero (migración + código) + - Frontend después +3. **Validar**: Probar login con cada tipo de usuario después de migrar + +## 🧪 Testing + +```bash +# 1. Probar login con admin +curl -X POST http://localhost:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin123"}' + +# 2. Verificar que el response incluye role object +# Debe tener: role: { id: 1, name: 'administrador', ... } + +# 3. Probar permisos +curl -X GET http://localhost:8000/api/users \ + -H "Authorization: Bearer {token}" +# Debe funcionar para admin, fallar para mecanico +``` + +## 📝 Notas + +- Los 3 roles predefinidos (id 1, 2, 3) no se pueden eliminar ni editar +- Nuevos roles personalizados pueden crearse con id > 3 +- La migración es irreversible (elimina columna `role`) +- Usuarios sin `role_id` después de migración se asignan automáticamente a "mecanico" diff --git a/backend/app/main.py b/backend/app/main.py index e1c4f68..babc55c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,6 +27,19 @@ app.add_middleware( security = HTTPBearer() +# ============= PERMISSION HELPERS ============= +def require_permission(user: models.User, permission: str): + """Verifica que el usuario tenga un permiso específico""" + if not hasattr(user.role_obj, permission) or not getattr(user.role_obj, permission): + raise HTTPException( + status_code=403, + detail=f"No tienes permisos para esta acción (requiere: {permission})" + ) + +def has_permission(user: models.User, permission: str) -> bool: + """Verifica si el usuario tiene un permiso específico""" + return hasattr(user.role_obj, permission) and getattr(user.role_obj, permission) + # Dependency para obtener usuario actual def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), @@ -51,8 +64,11 @@ def get_current_user( api_token.last_used_at = datetime.utcnow() db.commit() - # Obtener usuario - user = db.query(models.User).filter(models.User.id == api_token.user_id).first() + # Obtener usuario con rol + user = db.query(models.User).options( + joinedload(models.User.role_obj) + ).filter(models.User.id == api_token.user_id).first() + if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -72,7 +88,10 @@ def get_current_user( user_id = int(payload.get("sub")) print(f"Looking for user ID: {user_id}") # Debug - user = db.query(models.User).filter(models.User.id == user_id).first() + user = db.query(models.User).options( + joinedload(models.User.role_obj) + ).filter(models.User.id == user_id).first() + if user is None: print(f"User not found with ID: {user_id}") # Debug raise HTTPException(status_code=404, detail="Usuario no encontrado") @@ -94,13 +113,15 @@ def register(user: schemas.UserCreate, db: Session = Depends(get_db)): username=user.username, email=user.email, full_name=user.full_name, - role=user.role, + role_id=user.role_id, password_hash=hashed_password ) db.add(db_user) db.commit() db.refresh(db_user) return db_user + db.refresh(db_user) + return db_user @app.post("/api/auth/login", response_model=schemas.Token) diff --git a/backend/app/models.py b/backend/app/models.py index 11bfb4b..e1a9d61 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,6 +3,26 @@ from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.database import Base +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), unique=True, nullable=False) # administrador, asesor, mecanico + display_name = Column(String(100), nullable=False) # Administrador, Asesor, Mecánico + description = Column(String(255)) + # Permisos + can_manage_users = Column(Boolean, default=False) + can_manage_roles = Column(Boolean, default=False) + can_manage_checklists = Column(Boolean, default=False) + can_create_inspections = Column(Boolean, default=False) + can_view_all_inspections = Column(Boolean, default=False) + can_view_reports = Column(Boolean, default=False) + can_deactivate_inspections = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + users = relationship("User", back_populates="role_obj") + class User(Base): __tablename__ = "users" @@ -10,12 +30,13 @@ class User(Base): username = Column(String(50), unique=True, index=True, nullable=False) email = Column(String(100), unique=True, index=True) password_hash = Column(String(255), nullable=False) - role = Column(String(20), nullable=False) # admin, mechanic + role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) full_name = Column(String(100)) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) # Relationships + role_obj = relationship("Role", back_populates="users") checklists_created = relationship("Checklist", back_populates="creator") inspections = relationship("Inspection", back_populates="mechanic") api_tokens = relationship("APIToken", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 72ab026..982aae3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,12 +2,46 @@ from pydantic import BaseModel, EmailStr, Field from typing import Optional, List from datetime import datetime +# Role Schemas +class RoleBase(BaseModel): + name: str + display_name: str + description: Optional[str] = None + can_manage_users: bool = False + can_manage_roles: bool = False + can_manage_checklists: bool = False + can_create_inspections: bool = False + can_view_all_inspections: bool = False + can_view_reports: bool = False + can_deactivate_inspections: bool = False + +class RoleCreate(RoleBase): + pass + +class RoleUpdate(BaseModel): + display_name: Optional[str] = None + description: Optional[str] = None + can_manage_users: Optional[bool] = None + can_manage_roles: Optional[bool] = None + can_manage_checklists: Optional[bool] = None + can_create_inspections: Optional[bool] = None + can_view_all_inspections: Optional[bool] = None + can_view_reports: Optional[bool] = None + can_deactivate_inspections: Optional[bool] = None + +class Role(RoleBase): + id: int + created_at: datetime + + class Config: + from_attributes = True + # User Schemas class UserBase(BaseModel): username: str email: Optional[EmailStr] = None full_name: Optional[str] = None - role: str = "mechanic" + role_id: int = 3 # Default: mecanico class UserCreate(UserBase): password: str @@ -16,7 +50,7 @@ class UserUpdate(BaseModel): username: Optional[str] = None email: Optional[EmailStr] = None full_name: Optional[str] = None - role: Optional[str] = None + role_id: Optional[int] = None class UserPasswordUpdate(BaseModel): current_password: str @@ -29,8 +63,13 @@ class UserLogin(BaseModel): username: str password: str -class User(UserBase): +class User(BaseModel): id: int + username: str + email: Optional[str] = None + full_name: Optional[str] = None + role_id: int + role: Role # Role object is_active: bool created_at: datetime diff --git a/backend/migrate_roles.py b/backend/migrate_roles.py new file mode 100644 index 0000000..de0c139 --- /dev/null +++ b/backend/migrate_roles.py @@ -0,0 +1,128 @@ +""" +Script de migración para implementar sistema de roles +Crea tabla roles y migra usuarios existentes +""" +from sqlalchemy import create_engine, text +from app.core.config import settings + +def migrate(): + engine = create_engine(settings.DATABASE_URL) + + with engine.connect() as conn: + print("🔄 Iniciando migración de roles...") + + # 1. Crear tabla roles + print("📋 Creando tabla roles...") + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + display_name VARCHAR(100) NOT NULL, + description VARCHAR(255), + can_manage_users BOOLEAN DEFAULT FALSE, + can_manage_roles BOOLEAN DEFAULT FALSE, + can_manage_checklists BOOLEAN DEFAULT FALSE, + can_create_inspections BOOLEAN DEFAULT FALSE, + can_view_all_inspections BOOLEAN DEFAULT FALSE, + can_view_reports BOOLEAN DEFAULT FALSE, + can_deactivate_inspections BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) + """)) + conn.commit() + + # 2. Insertar roles predefinidos + print("👥 Insertando roles predefinidos...") + conn.execute(text(""" + INSERT INTO roles (name, display_name, description, + can_manage_users, can_manage_roles, can_manage_checklists, + can_create_inspections, can_view_all_inspections, + can_view_reports, can_deactivate_inspections) + VALUES + ('administrador', 'Administrador', 'Acceso completo al sistema', + TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('asesor', 'Asesor', 'Acceso a reportes e informes', + FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, FALSE), + ('mecanico', 'Mecánico', 'Realizar inspecciones', + FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, FALSE) + ON CONFLICT (name) DO NOTHING + """)) + conn.commit() + + # 3. Agregar columna role_id a users (temporal) + print("🔧 Agregando columna role_id a usuarios...") + conn.execute(text(""" + ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER + """)) + conn.commit() + + # 4. Migrar datos: admin -> administrador (id=1) + print("🔄 Migrando usuarios admin -> administrador...") + conn.execute(text(""" + UPDATE users + SET role_id = (SELECT id FROM roles WHERE name = 'administrador') + WHERE role = 'admin' AND role_id IS NULL + """)) + conn.commit() + + # 5. Migrar datos: mechanic -> mecanico (id=3) + print("🔄 Migrando usuarios mechanic -> mecanico...") + conn.execute(text(""" + UPDATE users + SET role_id = (SELECT id FROM roles WHERE name = 'mecanico') + WHERE role IN ('mechanic', 'mecanico') AND role_id IS NULL + """)) + conn.commit() + + # 6. Verificar que todos tienen role_id + result = conn.execute(text("SELECT COUNT(*) FROM users WHERE role_id IS NULL")) + null_count = result.scalar() + + if null_count > 0: + print(f"⚠️ Advertencia: {null_count} usuarios sin role_id asignado") + print(" Asignando rol de mecánico por defecto...") + conn.execute(text(""" + UPDATE users + SET role_id = (SELECT id FROM roles WHERE name = 'mecanico') + WHERE role_id IS NULL + """)) + conn.commit() + + # 7. Hacer role_id NOT NULL y crear foreign key + print("🔒 Aplicando restricciones...") + conn.execute(text(""" + ALTER TABLE users ALTER COLUMN role_id SET NOT NULL + """)) + conn.execute(text(""" + ALTER TABLE users + ADD CONSTRAINT fk_users_role_id + FOREIGN KEY (role_id) REFERENCES roles(id) + """)) + conn.commit() + + # 8. Eliminar columna role antigua + print("🗑️ Eliminando columna role antigua...") + conn.execute(text(""" + ALTER TABLE users DROP COLUMN IF EXISTS role + """)) + conn.commit() + + # 9. Mostrar resumen + print("\n✅ Migración completada!") + print("\n📊 Resumen de roles:") + + result = conn.execute(text(""" + SELECT r.name, r.display_name, COUNT(u.id) as user_count + FROM roles r + LEFT JOIN users u ON u.role_id = r.id + GROUP BY r.id, r.name, r.display_name + ORDER BY r.id + """)) + + for row in result: + print(f" {row[1]}: {row[2]} usuario(s)") + + print("\n🎉 Sistema de roles implementado correctamente!") + +if __name__ == "__main__": + migrate() diff --git a/backend/role_endpoints.txt b/backend/role_endpoints.txt new file mode 100644 index 0000000..9c6d6e6 --- /dev/null +++ b/backend/role_endpoints.txt @@ -0,0 +1,100 @@ +# Endpoints para gestión de roles - Agregar después de los endpoints de usuarios + +# ============= ROLE ENDPOINTS ============= +@app.get("/api/roles", response_model=List[schemas.Role]) +def get_roles( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Lista todos los roles disponibles (cualquier usuario autenticado)""" + return db.query(models.Role).all() + +@app.get("/api/roles/{role_id}", response_model=schemas.Role) +def get_role( + role_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Obtiene un rol específico""" + require_permission(current_user, "can_manage_roles") + + role = db.query(models.Role).filter(models.Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Rol no encontrado") + return role + +@app.post("/api/roles", response_model=schemas.Role) +def create_role( + role: schemas.RoleCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Crea un nuevo rol (solo administrador)""" + require_permission(current_user, "can_manage_roles") + + # Verificar si el rol ya existe + existing = db.query(models.Role).filter(models.Role.name == role.name).first() + if existing: + raise HTTPException(status_code=400, detail="El rol ya existe") + + db_role = models.Role(**role.dict()) + db.add(db_role) + db.commit() + db.refresh(db_role) + return db_role + +@app.put("/api/roles/{role_id}", response_model=schemas.Role) +def update_role( + role_id: int, + role_update: schemas.RoleUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Actualiza un rol existente (solo administrador)""" + require_permission(current_user, "can_manage_roles") + + db_role = db.query(models.Role).filter(models.Role.id == role_id).first() + if not db_role: + raise HTTPException(status_code=404, detail="Rol no encontrado") + + # No permitir editar roles predefinidos (1, 2, 3) + if role_id in [1, 2, 3]: + raise HTTPException(status_code=403, detail="No se pueden editar roles predefinidos") + + # Actualizar campos + update_data = role_update.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_role, field, value) + + db.commit() + db.refresh(db_role) + return db_role + +@app.delete("/api/roles/{role_id}") +def delete_role( + role_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Elimina un rol (solo administrador, no permite eliminar roles predefinidos)""" + require_permission(current_user, "can_manage_roles") + + # No permitir eliminar roles predefinidos + if role_id in [1, 2, 3]: + raise HTTPException(status_code=403, detail="No se pueden eliminar roles predefinidos") + + db_role = db.query(models.Role).filter(models.Role.id == role_id).first() + if not db_role: + raise HTTPException(status_code=404, detail="Rol no encontrado") + + # Verificar si hay usuarios con este rol + users_count = db.query(models.User).filter(models.User.role_id == role_id).count() + if users_count > 0: + raise HTTPException( + status_code=400, + detail=f"No se puede eliminar el rol porque tiene {users_count} usuario(s) asignado(s)" + ) + + db.delete(db_role) + db.commit() + return {"message": "Rol eliminado correctamente", "role_id": role_id} diff --git a/backend/update_permissions.py b/backend/update_permissions.py new file mode 100644 index 0000000..f82ef56 --- /dev/null +++ b/backend/update_permissions.py @@ -0,0 +1,67 @@ +""" +Script para actualizar automáticamente las verificaciones de permisos en main.py +Reemplaza las verificaciones de role string por verificaciones basadas en permisos +""" + +import re + +def update_permissions(): + with open('app/main.py', 'r', encoding='utf-8') as f: + content = f.read() + + # Mapa de reemplazos: patrón -> reemplazo + replacements = [ + # Gestión de usuarios + ( + r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail="No tienes permisos para ver usuarios"\)', + 'require_permission(current_user, "can_manage_users")' + ), + ( + r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail="No tienes permisos.*usuarios?"\)', + 'require_permission(current_user, "can_manage_users")' + ), + + # Gestión de checklists + ( + r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail=".*checklist.*"\)', + 'require_permission(current_user, "can_manage_checklists")' + ), + + # Desactivar inspecciones + ( + r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail=".*inactivar.*inspecc.*"\)', + 'require_permission(current_user, "can_deactivate_inspections")' + ), + + # Ver todas las inspecciones (mechanic filter) + ( + r'if current_user\.role == "mechanic":\s+query = query\.filter\(models\.Inspection\.mechanic_id == current_user\.id\)', + 'if not has_permission(current_user, "can_view_all_inspections"):\n query = query.filter(models.Inspection.mechanic_id == current_user.id)' + ), + + # Crear inspecciones + ( + r'# Crear usuario\s+hashed_password = get_password_hash\(user\.password\)\s+db_user = models\.User\(\s+username=user\.username,\s+email=user\.email,\s+full_name=user\.full_name,\s+role=user\.role,', + '# Crear usuario\n hashed_password = get_password_hash(user.password)\n db_user = models.User(\n username=user.username,\n email=user.email,\n full_name=user.full_name,\n role_id=user.role_id,' + ), + ] + + # Aplicar reemplazos + for pattern, replacement in replacements: + content = re.sub(pattern, replacement, content, flags=re.MULTILINE | re.DOTALL) + + # Reemplazos específicos adicionales + # Cambiar role por role_id en UserUpdate + content = content.replace( + 'if user_update.role is not None:\n if current_user.role != "admin":\n raise HTTPException(status_code=403, detail="No tienes permisos para cambiar roles")\n db_user.role = user_update.role', + 'if user_update.role_id is not None:\n require_permission(current_user, "can_manage_roles")\n db_user.role_id = user_update.role_id' + ) + + with open('app/main.py', 'w', encoding='utf-8') as f: + f.write(content) + + print("✅ Archivo main.py actualizado con sistema de permisos") + print("⚠️ Revisar manualmente y ajustar según sea necesario") + +if __name__ == "__main__": + update_permissions() -- 2.49.1 From 250758963c8ada8ec9f856b921c26655a3e24f7c Mon Sep 17 00:00:00 2001 From: ronalds Date: Wed, 19 Nov 2025 21:46:22 -0300 Subject: [PATCH 02/44] esta todo ok --- ROLES_IMPLEMENTATION_GUIDE.md | 166 ---------------------------------- backend/app/main.py | 29 +----- backend/app/models.py | 23 +---- backend/app/schemas.py | 45 +-------- backend/migrate_roles.py | 128 -------------------------- backend/role_endpoints.txt | 100 -------------------- backend/update_permissions.py | 67 -------------- 7 files changed, 8 insertions(+), 550 deletions(-) delete mode 100644 ROLES_IMPLEMENTATION_GUIDE.md delete mode 100644 backend/migrate_roles.py delete mode 100644 backend/role_endpoints.txt delete mode 100644 backend/update_permissions.py diff --git a/ROLES_IMPLEMENTATION_GUIDE.md b/ROLES_IMPLEMENTATION_GUIDE.md deleted file mode 100644 index c48bfc8..0000000 --- a/ROLES_IMPLEMENTATION_GUIDE.md +++ /dev/null @@ -1,166 +0,0 @@ -# Guía de Implementación del Sistema de Roles - -## 📋 Resumen -Se implementa un sistema de roles basado en base de datos con 3 roles: -- **Administrador** (id=1): Acceso completo -- **Asesor** (id=2): Solo informes y reportes -- **Mecánico** (id=3): Crear y completar inspecciones - -## 🔄 Pasos de Migración - -### 1. Ejecutar Migración de Base de Datos -```bash -cd backend -python migrate_roles.py -``` - -Esto hará: -- ✅ Crear tabla `roles` -- ✅ Insertar 3 roles predefinidos -- ✅ Migrar usuarios existentes (admin -> administrador, mechanic -> mecanico) -- ✅ Eliminar columna `role` antigua - -### 2. Reemplazar Verificaciones de Permisos en main.py - -**ANTES:** -```python -if current_user.role != "admin": - raise HTTPException(status_code=403, detail="No tienes permisos") -``` - -**DESPUÉS:** -```python -require_permission(current_user, "can_manage_users") -``` - -### 3. Permisos por Rol - -| Permiso | Administrador | Asesor | Mecánico | -|---------|--------------|--------|----------| -| can_manage_users | ✅ | ❌ | ❌ | -| can_manage_roles | ✅ | ❌ | ❌ | -| can_manage_checklists | ✅ | ❌ | ❌ | -| can_create_inspections | ✅ | ❌ | ✅ | -| can_view_all_inspections | ✅ | ✅ | ❌ | -| can_view_reports | ✅ | ✅ | ❌ | -| can_deactivate_inspections | ✅ | ❌ | ❌ | - -### 4. Cambios en Endpoints - -#### Gestión de Usuarios -```python -# Antes -if current_user.role != "admin": - -# Después -require_permission(current_user, "can_manage_users") -``` - -#### Gestión de Checklists -```python -# Antes -if current_user.role != "admin": - -# Después -require_permission(current_user, "can_manage_checklists") -``` - -#### Ver todas las Inspecciones -```python -# Antes -if current_user.role == "mechanic": - query = query.filter(models.Inspection.mechanic_id == current_user.id) - -# Después -if not has_permission(current_user, "can_view_all_inspections"): - query = query.filter(models.Inspection.mechanic_id == current_user.id) -``` - -#### Desactivar Inspecciones -```python -# Antes -if current_user.role != "admin": - -# Después -require_permission(current_user, "can_deactivate_inspections") -``` - -### 5. Actualizar Frontend - -**schemas en App.jsx:** -```javascript -// Antes -role: 'mechanic' - -// Después -role_id: 3, // mecanico -role: { - id: 3, - name: 'mecanico', - display_name: 'Mecánico', - can_create_inspections: true, - can_view_reports: false, - // ...otros permisos -} -``` - -**Verificación de permisos:** -```javascript -// Antes -if (user.role === 'admin') - -// Después -if (user.role.can_manage_users) -``` - -### 6. Agregar Endpoints de Roles - -Ver archivo `role_endpoints.txt` para los 5 nuevos endpoints: -- GET /api/roles - Listar roles -- GET /api/roles/{id} - Ver rol -- POST /api/roles - Crear rol -- PUT /api/roles/{id} - Actualizar rol -- DELETE /api/roles/{id} - Eliminar rol - -### 7. Actualizar Componentes Frontend - -#### UsersTab -- Cambiar selector de rol de texto a dropdown con roles de BD -- Mostrar `role.display_name` en lugar de role -- Enviar `role_id` en lugar de `role` al crear/editar - -#### Sidebar -- Mostrar tab "Informes" solo si `user.role.can_view_reports` -- Mostrar tab "Usuarios" solo si `user.role.can_manage_users` - -## ⚠️ Importante - -1. **Backup de BD**: Hacer backup antes de ejecutar migración -2. **Orden de deployment**: - - Backend primero (migración + código) - - Frontend después -3. **Validar**: Probar login con cada tipo de usuario después de migrar - -## 🧪 Testing - -```bash -# 1. Probar login con admin -curl -X POST http://localhost:8000/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username": "admin", "password": "admin123"}' - -# 2. Verificar que el response incluye role object -# Debe tener: role: { id: 1, name: 'administrador', ... } - -# 3. Probar permisos -curl -X GET http://localhost:8000/api/users \ - -H "Authorization: Bearer {token}" -# Debe funcionar para admin, fallar para mecanico -``` - -## 📝 Notas - -- Los 3 roles predefinidos (id 1, 2, 3) no se pueden eliminar ni editar -- Nuevos roles personalizados pueden crearse con id > 3 -- La migración es irreversible (elimina columna `role`) -- Usuarios sin `role_id` después de migración se asignan automáticamente a "mecanico" diff --git a/backend/app/main.py b/backend/app/main.py index babc55c..e1c4f68 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,19 +27,6 @@ app.add_middleware( security = HTTPBearer() -# ============= PERMISSION HELPERS ============= -def require_permission(user: models.User, permission: str): - """Verifica que el usuario tenga un permiso específico""" - if not hasattr(user.role_obj, permission) or not getattr(user.role_obj, permission): - raise HTTPException( - status_code=403, - detail=f"No tienes permisos para esta acción (requiere: {permission})" - ) - -def has_permission(user: models.User, permission: str) -> bool: - """Verifica si el usuario tiene un permiso específico""" - return hasattr(user.role_obj, permission) and getattr(user.role_obj, permission) - # Dependency para obtener usuario actual def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), @@ -64,11 +51,8 @@ def get_current_user( api_token.last_used_at = datetime.utcnow() db.commit() - # Obtener usuario con rol - user = db.query(models.User).options( - joinedload(models.User.role_obj) - ).filter(models.User.id == api_token.user_id).first() - + # Obtener usuario + user = db.query(models.User).filter(models.User.id == api_token.user_id).first() if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -88,10 +72,7 @@ def get_current_user( user_id = int(payload.get("sub")) print(f"Looking for user ID: {user_id}") # Debug - user = db.query(models.User).options( - joinedload(models.User.role_obj) - ).filter(models.User.id == user_id).first() - + user = db.query(models.User).filter(models.User.id == user_id).first() if user is None: print(f"User not found with ID: {user_id}") # Debug raise HTTPException(status_code=404, detail="Usuario no encontrado") @@ -113,15 +94,13 @@ def register(user: schemas.UserCreate, db: Session = Depends(get_db)): username=user.username, email=user.email, full_name=user.full_name, - role_id=user.role_id, + role=user.role, password_hash=hashed_password ) db.add(db_user) db.commit() db.refresh(db_user) return db_user - db.refresh(db_user) - return db_user @app.post("/api/auth/login", response_model=schemas.Token) diff --git a/backend/app/models.py b/backend/app/models.py index e1a9d61..11bfb4b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,26 +3,6 @@ from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.database import Base -class Role(Base): - __tablename__ = "roles" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(50), unique=True, nullable=False) # administrador, asesor, mecanico - display_name = Column(String(100), nullable=False) # Administrador, Asesor, Mecánico - description = Column(String(255)) - # Permisos - can_manage_users = Column(Boolean, default=False) - can_manage_roles = Column(Boolean, default=False) - can_manage_checklists = Column(Boolean, default=False) - can_create_inspections = Column(Boolean, default=False) - can_view_all_inspections = Column(Boolean, default=False) - can_view_reports = Column(Boolean, default=False) - can_deactivate_inspections = Column(Boolean, default=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - - # Relationships - users = relationship("User", back_populates="role_obj") - class User(Base): __tablename__ = "users" @@ -30,13 +10,12 @@ class User(Base): username = Column(String(50), unique=True, index=True, nullable=False) email = Column(String(100), unique=True, index=True) password_hash = Column(String(255), nullable=False) - role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) + role = Column(String(20), nullable=False) # admin, mechanic full_name = Column(String(100)) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) # Relationships - role_obj = relationship("Role", back_populates="users") checklists_created = relationship("Checklist", back_populates="creator") inspections = relationship("Inspection", back_populates="mechanic") api_tokens = relationship("APIToken", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 982aae3..72ab026 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,46 +2,12 @@ from pydantic import BaseModel, EmailStr, Field from typing import Optional, List from datetime import datetime -# Role Schemas -class RoleBase(BaseModel): - name: str - display_name: str - description: Optional[str] = None - can_manage_users: bool = False - can_manage_roles: bool = False - can_manage_checklists: bool = False - can_create_inspections: bool = False - can_view_all_inspections: bool = False - can_view_reports: bool = False - can_deactivate_inspections: bool = False - -class RoleCreate(RoleBase): - pass - -class RoleUpdate(BaseModel): - display_name: Optional[str] = None - description: Optional[str] = None - can_manage_users: Optional[bool] = None - can_manage_roles: Optional[bool] = None - can_manage_checklists: Optional[bool] = None - can_create_inspections: Optional[bool] = None - can_view_all_inspections: Optional[bool] = None - can_view_reports: Optional[bool] = None - can_deactivate_inspections: Optional[bool] = None - -class Role(RoleBase): - id: int - created_at: datetime - - class Config: - from_attributes = True - # User Schemas class UserBase(BaseModel): username: str email: Optional[EmailStr] = None full_name: Optional[str] = None - role_id: int = 3 # Default: mecanico + role: str = "mechanic" class UserCreate(UserBase): password: str @@ -50,7 +16,7 @@ class UserUpdate(BaseModel): username: Optional[str] = None email: Optional[EmailStr] = None full_name: Optional[str] = None - role_id: Optional[int] = None + role: Optional[str] = None class UserPasswordUpdate(BaseModel): current_password: str @@ -63,13 +29,8 @@ class UserLogin(BaseModel): username: str password: str -class User(BaseModel): +class User(UserBase): id: int - username: str - email: Optional[str] = None - full_name: Optional[str] = None - role_id: int - role: Role # Role object is_active: bool created_at: datetime diff --git a/backend/migrate_roles.py b/backend/migrate_roles.py deleted file mode 100644 index de0c139..0000000 --- a/backend/migrate_roles.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Script de migración para implementar sistema de roles -Crea tabla roles y migra usuarios existentes -""" -from sqlalchemy import create_engine, text -from app.core.config import settings - -def migrate(): - engine = create_engine(settings.DATABASE_URL) - - with engine.connect() as conn: - print("🔄 Iniciando migración de roles...") - - # 1. Crear tabla roles - print("📋 Creando tabla roles...") - conn.execute(text(""" - CREATE TABLE IF NOT EXISTS roles ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) UNIQUE NOT NULL, - display_name VARCHAR(100) NOT NULL, - description VARCHAR(255), - can_manage_users BOOLEAN DEFAULT FALSE, - can_manage_roles BOOLEAN DEFAULT FALSE, - can_manage_checklists BOOLEAN DEFAULT FALSE, - can_create_inspections BOOLEAN DEFAULT FALSE, - can_view_all_inspections BOOLEAN DEFAULT FALSE, - can_view_reports BOOLEAN DEFAULT FALSE, - can_deactivate_inspections BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ) - """)) - conn.commit() - - # 2. Insertar roles predefinidos - print("👥 Insertando roles predefinidos...") - conn.execute(text(""" - INSERT INTO roles (name, display_name, description, - can_manage_users, can_manage_roles, can_manage_checklists, - can_create_inspections, can_view_all_inspections, - can_view_reports, can_deactivate_inspections) - VALUES - ('administrador', 'Administrador', 'Acceso completo al sistema', - TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('asesor', 'Asesor', 'Acceso a reportes e informes', - FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, FALSE), - ('mecanico', 'Mecánico', 'Realizar inspecciones', - FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, FALSE) - ON CONFLICT (name) DO NOTHING - """)) - conn.commit() - - # 3. Agregar columna role_id a users (temporal) - print("🔧 Agregando columna role_id a usuarios...") - conn.execute(text(""" - ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER - """)) - conn.commit() - - # 4. Migrar datos: admin -> administrador (id=1) - print("🔄 Migrando usuarios admin -> administrador...") - conn.execute(text(""" - UPDATE users - SET role_id = (SELECT id FROM roles WHERE name = 'administrador') - WHERE role = 'admin' AND role_id IS NULL - """)) - conn.commit() - - # 5. Migrar datos: mechanic -> mecanico (id=3) - print("🔄 Migrando usuarios mechanic -> mecanico...") - conn.execute(text(""" - UPDATE users - SET role_id = (SELECT id FROM roles WHERE name = 'mecanico') - WHERE role IN ('mechanic', 'mecanico') AND role_id IS NULL - """)) - conn.commit() - - # 6. Verificar que todos tienen role_id - result = conn.execute(text("SELECT COUNT(*) FROM users WHERE role_id IS NULL")) - null_count = result.scalar() - - if null_count > 0: - print(f"⚠️ Advertencia: {null_count} usuarios sin role_id asignado") - print(" Asignando rol de mecánico por defecto...") - conn.execute(text(""" - UPDATE users - SET role_id = (SELECT id FROM roles WHERE name = 'mecanico') - WHERE role_id IS NULL - """)) - conn.commit() - - # 7. Hacer role_id NOT NULL y crear foreign key - print("🔒 Aplicando restricciones...") - conn.execute(text(""" - ALTER TABLE users ALTER COLUMN role_id SET NOT NULL - """)) - conn.execute(text(""" - ALTER TABLE users - ADD CONSTRAINT fk_users_role_id - FOREIGN KEY (role_id) REFERENCES roles(id) - """)) - conn.commit() - - # 8. Eliminar columna role antigua - print("🗑️ Eliminando columna role antigua...") - conn.execute(text(""" - ALTER TABLE users DROP COLUMN IF EXISTS role - """)) - conn.commit() - - # 9. Mostrar resumen - print("\n✅ Migración completada!") - print("\n📊 Resumen de roles:") - - result = conn.execute(text(""" - SELECT r.name, r.display_name, COUNT(u.id) as user_count - FROM roles r - LEFT JOIN users u ON u.role_id = r.id - GROUP BY r.id, r.name, r.display_name - ORDER BY r.id - """)) - - for row in result: - print(f" {row[1]}: {row[2]} usuario(s)") - - print("\n🎉 Sistema de roles implementado correctamente!") - -if __name__ == "__main__": - migrate() diff --git a/backend/role_endpoints.txt b/backend/role_endpoints.txt deleted file mode 100644 index 9c6d6e6..0000000 --- a/backend/role_endpoints.txt +++ /dev/null @@ -1,100 +0,0 @@ -# Endpoints para gestión de roles - Agregar después de los endpoints de usuarios - -# ============= ROLE ENDPOINTS ============= -@app.get("/api/roles", response_model=List[schemas.Role]) -def get_roles( - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) -): - """Lista todos los roles disponibles (cualquier usuario autenticado)""" - return db.query(models.Role).all() - -@app.get("/api/roles/{role_id}", response_model=schemas.Role) -def get_role( - role_id: int, - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) -): - """Obtiene un rol específico""" - require_permission(current_user, "can_manage_roles") - - role = db.query(models.Role).filter(models.Role.id == role_id).first() - if not role: - raise HTTPException(status_code=404, detail="Rol no encontrado") - return role - -@app.post("/api/roles", response_model=schemas.Role) -def create_role( - role: schemas.RoleCreate, - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) -): - """Crea un nuevo rol (solo administrador)""" - require_permission(current_user, "can_manage_roles") - - # Verificar si el rol ya existe - existing = db.query(models.Role).filter(models.Role.name == role.name).first() - if existing: - raise HTTPException(status_code=400, detail="El rol ya existe") - - db_role = models.Role(**role.dict()) - db.add(db_role) - db.commit() - db.refresh(db_role) - return db_role - -@app.put("/api/roles/{role_id}", response_model=schemas.Role) -def update_role( - role_id: int, - role_update: schemas.RoleUpdate, - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) -): - """Actualiza un rol existente (solo administrador)""" - require_permission(current_user, "can_manage_roles") - - db_role = db.query(models.Role).filter(models.Role.id == role_id).first() - if not db_role: - raise HTTPException(status_code=404, detail="Rol no encontrado") - - # No permitir editar roles predefinidos (1, 2, 3) - if role_id in [1, 2, 3]: - raise HTTPException(status_code=403, detail="No se pueden editar roles predefinidos") - - # Actualizar campos - update_data = role_update.dict(exclude_unset=True) - for field, value in update_data.items(): - setattr(db_role, field, value) - - db.commit() - db.refresh(db_role) - return db_role - -@app.delete("/api/roles/{role_id}") -def delete_role( - role_id: int, - db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) -): - """Elimina un rol (solo administrador, no permite eliminar roles predefinidos)""" - require_permission(current_user, "can_manage_roles") - - # No permitir eliminar roles predefinidos - if role_id in [1, 2, 3]: - raise HTTPException(status_code=403, detail="No se pueden eliminar roles predefinidos") - - db_role = db.query(models.Role).filter(models.Role.id == role_id).first() - if not db_role: - raise HTTPException(status_code=404, detail="Rol no encontrado") - - # Verificar si hay usuarios con este rol - users_count = db.query(models.User).filter(models.User.role_id == role_id).count() - if users_count > 0: - raise HTTPException( - status_code=400, - detail=f"No se puede eliminar el rol porque tiene {users_count} usuario(s) asignado(s)" - ) - - db.delete(db_role) - db.commit() - return {"message": "Rol eliminado correctamente", "role_id": role_id} diff --git a/backend/update_permissions.py b/backend/update_permissions.py deleted file mode 100644 index f82ef56..0000000 --- a/backend/update_permissions.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Script para actualizar automáticamente las verificaciones de permisos en main.py -Reemplaza las verificaciones de role string por verificaciones basadas en permisos -""" - -import re - -def update_permissions(): - with open('app/main.py', 'r', encoding='utf-8') as f: - content = f.read() - - # Mapa de reemplazos: patrón -> reemplazo - replacements = [ - # Gestión de usuarios - ( - r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail="No tienes permisos para ver usuarios"\)', - 'require_permission(current_user, "can_manage_users")' - ), - ( - r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail="No tienes permisos.*usuarios?"\)', - 'require_permission(current_user, "can_manage_users")' - ), - - # Gestión de checklists - ( - r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail=".*checklist.*"\)', - 'require_permission(current_user, "can_manage_checklists")' - ), - - # Desactivar inspecciones - ( - r'if current_user\.role != "admin":\s+raise HTTPException\(status_code=403, detail=".*inactivar.*inspecc.*"\)', - 'require_permission(current_user, "can_deactivate_inspections")' - ), - - # Ver todas las inspecciones (mechanic filter) - ( - r'if current_user\.role == "mechanic":\s+query = query\.filter\(models\.Inspection\.mechanic_id == current_user\.id\)', - 'if not has_permission(current_user, "can_view_all_inspections"):\n query = query.filter(models.Inspection.mechanic_id == current_user.id)' - ), - - # Crear inspecciones - ( - r'# Crear usuario\s+hashed_password = get_password_hash\(user\.password\)\s+db_user = models\.User\(\s+username=user\.username,\s+email=user\.email,\s+full_name=user\.full_name,\s+role=user\.role,', - '# Crear usuario\n hashed_password = get_password_hash(user.password)\n db_user = models.User(\n username=user.username,\n email=user.email,\n full_name=user.full_name,\n role_id=user.role_id,' - ), - ] - - # Aplicar reemplazos - for pattern, replacement in replacements: - content = re.sub(pattern, replacement, content, flags=re.MULTILINE | re.DOTALL) - - # Reemplazos específicos adicionales - # Cambiar role por role_id en UserUpdate - content = content.replace( - 'if user_update.role is not None:\n if current_user.role != "admin":\n raise HTTPException(status_code=403, detail="No tienes permisos para cambiar roles")\n db_user.role = user_update.role', - 'if user_update.role_id is not None:\n require_permission(current_user, "can_manage_roles")\n db_user.role_id = user_update.role_id' - ) - - with open('app/main.py', 'w', encoding='utf-8') as f: - f.write(content) - - print("✅ Archivo main.py actualizado con sistema de permisos") - print("⚠️ Revisar manualmente y ajustar según sea necesario") - -if __name__ == "__main__": - update_permissions() -- 2.49.1 From 0917d24029433e85663ebd9a89799af8bbd43933 Mon Sep 17 00:00:00 2001 From: ronalds Date: Wed, 19 Nov 2025 22:25:40 -0300 Subject: [PATCH 03/44] backend actualizado para dashboard --- backend/app/main.py | 283 ++++++++++++++++++++++++++++++++++++++++- backend/app/schemas.py | 41 ++++++ 2 files changed, 323 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index e1c4f68..4d94dce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,8 @@ from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session, joinedload -from typing import List +from sqlalchemy import func, case +from typing import List, Optional import os import shutil from datetime import datetime, timedelta @@ -1273,6 +1274,286 @@ Responde en formato JSON: } +# ============= REPORTS ============= +@app.get("/api/reports/dashboard", response_model=schemas.DashboardData) +def get_dashboard_data( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + mechanic_id: Optional[int] = None, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Obtener datos del dashboard de informes""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden acceder a reportes") + + # Construir query base + query = db.query(models.Inspection) + + # Aplicar filtros de fecha + if start_date: + start = datetime.fromisoformat(start_date) + query = query.filter(models.Inspection.started_at >= start) + if end_date: + end = datetime.fromisoformat(end_date) + query = query.filter(models.Inspection.started_at <= end) + + # Filtro por mecánico + if mechanic_id: + query = query.filter(models.Inspection.mechanic_id == mechanic_id) + + # Solo inspecciones activas + query = query.filter(models.Inspection.is_active == True) + + # ESTADÍSTICAS GENERALES + total = query.count() + completed = query.filter(models.Inspection.status == "completed").count() + pending = total - completed + + # Score promedio + avg_score_result = query.filter( + models.Inspection.score.isnot(None), + models.Inspection.max_score.isnot(None), + models.Inspection.max_score > 0 + ).with_entities( + func.avg(models.Inspection.score * 100.0 / models.Inspection.max_score) + ).scalar() + avg_score = round(avg_score_result, 2) if avg_score_result else 0.0 + + # Items señalados + flagged_items = db.query(func.count(models.Answer.id))\ + .filter(models.Answer.is_flagged == True)\ + .join(models.Inspection)\ + .filter(models.Inspection.is_active == True) + + if start_date: + start = datetime.fromisoformat(start_date) + flagged_items = flagged_items.filter(models.Inspection.started_at >= start) + if end_date: + end = datetime.fromisoformat(end_date) + flagged_items = flagged_items.filter(models.Inspection.started_at <= end) + if mechanic_id: + flagged_items = flagged_items.filter(models.Inspection.mechanic_id == mechanic_id) + + total_flagged = flagged_items.scalar() or 0 + + stats = schemas.InspectionStats( + total_inspections=total, + completed_inspections=completed, + pending_inspections=pending, + completion_rate=round((completed / total * 100) if total > 0 else 0, 2), + avg_score=avg_score, + total_flagged_items=total_flagged + ) + + # RANKING DE MECÁNICOS + mechanic_stats = db.query( + models.User.id, + models.User.full_name, + func.count(models.Inspection.id).label('total'), + func.avg( + case( + (models.Inspection.max_score > 0, models.Inspection.score * 100.0 / models.Inspection.max_score), + else_=None + ) + ).label('avg_score'), + func.count(case((models.Inspection.status == 'completed', 1))).label('completed') + ).join(models.Inspection, models.Inspection.mechanic_id == models.User.id)\ + .filter(models.User.role.in_(['mechanic', 'mecanico']))\ + .filter(models.User.is_active == True)\ + .filter(models.Inspection.is_active == True) + + if start_date: + start = datetime.fromisoformat(start_date) + mechanic_stats = mechanic_stats.filter(models.Inspection.started_at >= start) + if end_date: + end = datetime.fromisoformat(end_date) + mechanic_stats = mechanic_stats.filter(models.Inspection.started_at <= end) + + mechanic_stats = mechanic_stats.group_by(models.User.id, models.User.full_name)\ + .order_by(func.count(models.Inspection.id).desc())\ + .all() + + mechanic_ranking = [ + schemas.MechanicRanking( + mechanic_id=m.id, + mechanic_name=m.full_name, + total_inspections=m.total, + avg_score=round(m.avg_score, 2) if m.avg_score else 0.0, + completion_rate=round((m.completed / m.total * 100) if m.total > 0 else 0, 2) + ) + for m in mechanic_stats + ] + + # ESTADÍSTICAS POR CHECKLIST + checklist_stats_query = db.query( + models.Checklist.id, + models.Checklist.name, + func.count(models.Inspection.id).label('total'), + func.avg( + case( + (models.Inspection.max_score > 0, models.Inspection.score * 100.0 / models.Inspection.max_score), + else_=None + ) + ).label('avg_score') + ).join(models.Inspection)\ + .filter(models.Inspection.is_active == True)\ + .filter(models.Checklist.is_active == True) + + if start_date: + start = datetime.fromisoformat(start_date) + checklist_stats_query = checklist_stats_query.filter(models.Inspection.started_at >= start) + if end_date: + end = datetime.fromisoformat(end_date) + checklist_stats_query = checklist_stats_query.filter(models.Inspection.started_at <= end) + if mechanic_id: + checklist_stats_query = checklist_stats_query.filter(models.Inspection.mechanic_id == mechanic_id) + + checklist_stats_query = checklist_stats_query.group_by(models.Checklist.id, models.Checklist.name) + checklist_stats_data = checklist_stats_query.all() + + checklist_stats = [ + schemas.ChecklistStats( + checklist_id=c.id, + checklist_name=c.name, + total_inspections=c.total, + avg_score=round(c.avg_score, 2) if c.avg_score else 0.0 + ) + for c in checklist_stats_data + ] + + # INSPECCIONES POR FECHA (últimos 30 días) + end_date_obj = datetime.fromisoformat(end_date) if end_date else datetime.now() + start_date_obj = datetime.fromisoformat(start_date) if start_date else end_date_obj - timedelta(days=30) + + inspections_by_date_query = db.query( + func.date(models.Inspection.started_at).label('date'), + func.count(models.Inspection.id).label('count') + ).filter( + models.Inspection.started_at.between(start_date_obj, end_date_obj), + models.Inspection.is_active == True + ) + + if mechanic_id: + inspections_by_date_query = inspections_by_date_query.filter( + models.Inspection.mechanic_id == mechanic_id + ) + + inspections_by_date_data = inspections_by_date_query.group_by( + func.date(models.Inspection.started_at) + ).all() + + inspections_by_date = { + str(d.date): d.count for d in inspections_by_date_data + } + + # RATIO PASS/FAIL + pass_fail_data = db.query( + models.Answer.answer_value, + func.count(models.Answer.id).label('count') + ).join(models.Inspection)\ + .filter(models.Inspection.is_active == True)\ + .filter(models.Answer.answer_value.in_(['pass', 'fail', 'good', 'bad', 'regular'])) + + if start_date: + start = datetime.fromisoformat(start_date) + pass_fail_data = pass_fail_data.filter(models.Inspection.started_at >= start) + if end_date: + end = datetime.fromisoformat(end_date) + pass_fail_data = pass_fail_data.filter(models.Inspection.started_at <= end) + if mechanic_id: + pass_fail_data = pass_fail_data.filter(models.Inspection.mechanic_id == mechanic_id) + + pass_fail_data = pass_fail_data.group_by(models.Answer.answer_value).all() + + pass_fail_ratio = {d.answer_value: d.count for d in pass_fail_data} + + return schemas.DashboardData( + stats=stats, + mechanic_ranking=mechanic_ranking, + checklist_stats=checklist_stats, + inspections_by_date=inspections_by_date, + pass_fail_ratio=pass_fail_ratio + ) + + +@app.get("/api/reports/inspections") +def get_inspections_report( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + mechanic_id: Optional[int] = None, + checklist_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = 100, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Obtener lista de inspecciones con filtros""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden acceder a reportes") + + # Query base con select_from explícito + query = db.query( + models.Inspection.id, + models.Inspection.vehicle_plate, + models.Checklist.name.label('checklist_name'), + models.User.full_name.label('mechanic_name'), + models.Inspection.status, + models.Inspection.score, + models.Inspection.max_score, + models.Inspection.started_at, + models.Inspection.completed_at, + func.coalesce( + func.count(case((models.Answer.is_flagged == True, 1))), + 0 + ).label('flagged_items') + ).select_from(models.Inspection)\ + .join(models.Checklist, models.Inspection.checklist_id == models.Checklist.id)\ + .join(models.User, models.Inspection.mechanic_id == models.User.id)\ + .outerjoin(models.Answer, models.Answer.inspection_id == models.Inspection.id)\ + .filter(models.Inspection.is_active == True) + + # Aplicar filtros + if start_date: + start = datetime.fromisoformat(start_date) + query = query.filter(models.Inspection.started_at >= start) + if end_date: + end = datetime.fromisoformat(end_date) + query = query.filter(models.Inspection.started_at <= end) + if mechanic_id: + query = query.filter(models.Inspection.mechanic_id == mechanic_id) + if checklist_id: + query = query.filter(models.Inspection.checklist_id == checklist_id) + if status: + query = query.filter(models.Inspection.status == status) + + # Group by y order + query = query.group_by( + models.Inspection.id, + models.Checklist.name, + models.User.full_name + ).order_by(models.Inspection.started_at.desc())\ + .limit(limit) + + results = query.all() + + return [ + { + "id": r.id, + "vehicle_plate": r.vehicle_plate, + "checklist_name": r.checklist_name, + "mechanic_name": r.mechanic_name, + "status": r.status, + "score": r.score, + "max_score": r.max_score, + "flagged_items": r.flagged_items, + "started_at": r.started_at.isoformat() if r.started_at else None, + "completed_at": r.completed_at.isoformat() if r.completed_at else None + } + for r in results + ] + + # ============= HEALTH CHECK ============= @app.get("/") def root(): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 72ab026..4b8cd86 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -236,3 +236,44 @@ class AIModelInfo(BaseModel): name: str provider: str description: Optional[str] = None + +# Reports Schemas +class InspectionStats(BaseModel): + total_inspections: int + completed_inspections: int + pending_inspections: int + completion_rate: float + avg_score: float + total_flagged_items: int + +class MechanicRanking(BaseModel): + mechanic_id: int + mechanic_name: str + total_inspections: int + avg_score: float + completion_rate: float + +class ChecklistStats(BaseModel): + checklist_id: int + checklist_name: str + total_inspections: int + avg_score: float + +class DashboardData(BaseModel): + stats: InspectionStats + mechanic_ranking: List[MechanicRanking] + checklist_stats: List[ChecklistStats] + inspections_by_date: dict + pass_fail_ratio: dict + +class InspectionListItem(BaseModel): + id: int + vehicle_plate: str + checklist_name: str + mechanic_name: str + status: str + score: Optional[int] + max_score: Optional[int] + flagged_items: int + started_at: Optional[datetime] + completed_at: Optional[datetime] -- 2.49.1 From cfe49ee0c88f2a1b65c93406bfbce2c227043c29 Mon Sep 17 00:00:00 2001 From: ronalds Date: Wed, 19 Nov 2025 23:49:37 -0300 Subject: [PATCH 04/44] Modulo de Reportes v1 --- backend/app/main.py | 234 +++++++++++++++++++++++++++++++++ frontend/src/App.jsx | 304 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 534 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 4d94dce..83c1789 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1554,6 +1554,240 @@ def get_inspections_report( ] +@app.get("/api/inspections/{inspection_id}/pdf") +def export_inspection_to_pdf( + inspection_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Exportar inspección a PDF con imágenes""" + from fastapi.responses import StreamingResponse + from reportlab.lib.pagesizes import letter, A4 + from reportlab.lib import colors + from reportlab.lib.units import inch + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT + from io import BytesIO + import base64 + + # Obtener inspección + inspection = db.query(models.Inspection).filter( + models.Inspection.id == inspection_id + ).first() + + if not inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + + # Verificar permisos (admin o mecánico dueño) + if current_user.role != "admin" and inspection.mechanic_id != current_user.id: + raise HTTPException(status_code=403, detail="No tienes permisos para ver esta inspección") + + # Obtener datos relacionados + checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first() + mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first() + answers = db.query(models.Answer).options( + joinedload(models.Answer.media_files) + ).join(models.Question).filter( + models.Answer.inspection_id == inspection_id + ).order_by(models.Question.section, models.Question.order).all() + + # Crear PDF en memoria + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30) + + # Contenedor para elementos del PDF + elements = [] + styles = getSampleStyleSheet() + + # Estilos personalizados + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontSize=24, + textColor=colors.HexColor('#4338ca'), + spaceAfter=30, + alignment=TA_CENTER + ) + + heading_style = ParagraphStyle( + 'CustomHeading', + parent=styles['Heading2'], + fontSize=16, + textColor=colors.HexColor('#4338ca'), + spaceAfter=12, + spaceBefore=12 + ) + + # Título + elements.append(Paragraph("REPORTE DE INSPECCIÓN", title_style)) + elements.append(Spacer(1, 20)) + + # Información general + info_data = [ + ['Checklist:', checklist.name if checklist else 'N/A'], + ['Mecánico:', mechanic.full_name if mechanic else 'N/A'], + ['Fecha:', inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'], + ['Estado:', inspection.status.upper()], + ['', ''], + ['Vehículo', ''], + ['Patente:', inspection.vehicle_plate or 'N/A'], + ['Marca:', inspection.vehicle_brand or 'N/A'], + ['Modelo:', inspection.vehicle_model or 'N/A'], + ['Kilometraje:', f"{inspection.vehicle_km:,} km" if inspection.vehicle_km else 'N/A'], + ['Cliente:', inspection.client_name or 'N/A'], + ['OR Número:', inspection.or_number or 'N/A'], + ] + + if inspection.status == 'completed' and inspection.score is not None: + info_data.insert(4, ['Score:', f"{inspection.score}/{inspection.max_score} ({inspection.percentage:.1f}%)"]) + info_data.insert(5, ['Items Señalados:', str(inspection.flagged_items_count)]) + + info_table = Table(info_data, colWidths=[2*inch, 4*inch]) + info_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#e0e7ff')), + ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#4338ca')), + ('ALIGN', (0, 0), (0, -1), 'RIGHT'), + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ])) + + elements.append(info_table) + elements.append(Spacer(1, 30)) + + # Respuestas por sección + if answers: + elements.append(Paragraph("RESULTADOS DE INSPECCIÓN", heading_style)) + elements.append(Spacer(1, 10)) + + current_section = None + + for answer in answers: + question = answer.question + + # Nueva sección + if question.section != current_section: + if current_section is not None: + elements.append(Spacer(1, 20)) + + current_section = question.section + section_style = ParagraphStyle( + 'Section', + parent=styles['Heading3'], + fontSize=14, + textColor=colors.HexColor('#6366f1'), + spaceAfter=10 + ) + elements.append(Paragraph(f"● {current_section}", section_style)) + + # Datos de la pregunta + answer_color = colors.white + if answer.is_flagged: + answer_color = colors.HexColor('#fee2e2') + elif answer.answer_value in ['pass', 'good']: + answer_color = colors.HexColor('#dcfce7') + elif answer.answer_value in ['fail', 'bad']: + answer_color = colors.HexColor('#fee2e2') + + answer_text = answer.answer_value or answer.answer_text or 'N/A' + if answer.points_earned is not None: + answer_text += f" ({answer.points_earned} pts)" + + question_data = [ + [Paragraph(f"{question.text}", styles['Normal']), answer_text.upper()] + ] + + if answer.comment: + question_data.append([Paragraph(f"Comentarios: {answer.comment}", styles['Normal']), '']) + + question_table = Table(question_data, colWidths=[4*inch, 2*inch]) + question_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), answer_color), + ('TEXTCOLOR', (0, 0), (-1, -1), colors.black), + ('ALIGN', (1, 0), (1, 0), 'CENTER'), + ('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ('BOTTOMPADDING', (0, 0), (-1, -1), 6), + ('TOPPADDING', (0, 0), (-1, -1), 6), + ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ])) + + elements.append(question_table) + elements.append(Spacer(1, 5)) + + # Fotos adjuntas + if answer.media_files and len(answer.media_files) > 0: + elements.append(Spacer(1, 5)) + photos_per_row = 2 + photo_width = 2.5 * inch + photo_height = 2 * inch + + for i in range(0, len(answer.media_files), photos_per_row): + photo_row = [] + for media_file in answer.media_files[i:i+photos_per_row]: + try: + photo_path = media_file.file_path + # Si la foto es base64 + if photo_path.startswith('data:image'): + img_data = base64.b64decode(photo_path.split(',')[1]) + img_buffer = BytesIO(img_data) + img = RLImage(img_buffer, width=photo_width, height=photo_height) + else: + # Si es una ruta de archivo + full_path = os.path.join(os.getcwd(), photo_path) + if os.path.exists(full_path): + img = RLImage(full_path, width=photo_width, height=photo_height) + else: + continue + photo_row.append(img) + except Exception as e: + print(f"Error loading image: {e}") + continue + + if photo_row: + photo_table = Table([photo_row]) + photo_table.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ])) + elements.append(photo_table) + elements.append(Spacer(1, 10)) + + else: + elements.append(Paragraph("No hay respuestas registradas", styles['Normal'])) + + # Pie de página + elements.append(Spacer(1, 30)) + footer_style = ParagraphStyle( + 'Footer', + parent=styles['Normal'], + fontSize=8, + textColor=colors.grey, + alignment=TA_CENTER + ) + elements.append(Paragraph(f"Generado por Syntria - {datetime.now().strftime('%d/%m/%Y %H:%M')}", footer_style)) + + # Construir PDF + doc.build(elements) + + # Preparar respuesta + buffer.seek(0) + filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf" + + return StreamingResponse( + buffer, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + + # ============= HEALTH CHECK ============= @app.get("/") def root(): diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a27d3f1..102b560 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -339,10 +339,7 @@ function DashboardPage({ user, setUser }) { ) : activeTab === 'users' ? ( ) : activeTab === 'reports' ? ( -
-
📊
-
Módulo de Reportes en desarrollo...
-
+ ) : null} @@ -1732,6 +1729,38 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) { {/* Footer */}
+ {user?.role === 'admin' && ( <> +
-- 2.49.1 From e3a5fd6686f5fa4143dcac98f8a9df27abdf93f9 Mon Sep 17 00:00:00 2001 From: ronalds Date: Thu, 20 Nov 2025 14:39:57 -0300 Subject: [PATCH 06/44] feat: Add PDF export button to reports dashboard - v1.0.14 --- docker-compose.hub.yml | 2 +- frontend/src/App.jsx | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index aaf0683..d3821b7 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -38,7 +38,7 @@ services: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 frontend: - image: dymai/syntria-frontend:latest + image: dymai/syntria-frontend:1.0.14 container_name: syntria-frontend-prod restart: always depends_on: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 147f08c..bd76217 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3472,6 +3472,7 @@ function ReportsTab({ user }) { Score Estado Alertas + Acciones @@ -3500,6 +3501,40 @@ function ReportsTab({ user }) { )} + + + ))} -- 2.49.1 From 9dd2e2598b8dd8935db19e7e19f661cc372fd6ea Mon Sep 17 00:00:00 2001 From: ronalds Date: Thu, 20 Nov 2025 15:06:58 -0300 Subject: [PATCH 07/44] fix: Date filter now sends both start_date and end_date to filter single day - v1.0.15 --- docker-compose.hub.yml | 2 +- frontend/src/App.jsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index d3821b7..2504547 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -38,7 +38,7 @@ services: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 frontend: - image: dymai/syntria-frontend:1.0.14 + image: dymai/syntria-frontend:1.0.15 container_name: syntria-frontend-prod restart: always depends_on: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bd76217..c31fb4c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3227,7 +3227,9 @@ function ReportsTab({ user }) { const API_URL = import.meta.env.VITE_API_URL || '' let url = `${API_URL}/api/reports/dashboard?` - if (filtersToApply.date) url += `start_date=${filtersToApply.date}&` + if (filtersToApply.date) { + url += `start_date=${filtersToApply.date}&end_date=${filtersToApply.date}&` + } if (filtersToApply.mechanicId) url += `mechanic_id=${filtersToApply.mechanicId}&` console.log('Dashboard URL:', url) @@ -3255,7 +3257,9 @@ function ReportsTab({ user }) { const API_URL = import.meta.env.VITE_API_URL || '' let url = `${API_URL}/api/reports/inspections?` - if (filtersToApply.date) url += `start_date=${filtersToApply.date}&` + if (filtersToApply.date) { + url += `start_date=${filtersToApply.date}&end_date=${filtersToApply.date}&` + } if (filtersToApply.mechanicId) url += `mechanic_id=${filtersToApply.mechanicId}&` console.log('Inspections URL:', url) -- 2.49.1 From 5d2a96553e8c7421f1b841c0a32ef2952e0c039d Mon Sep 17 00:00:00 2001 From: ronalds Date: Thu, 20 Nov 2025 15:20:09 -0300 Subject: [PATCH 08/44] fix: Change timezone from UTC to UTC-3 for date filters to match database - backend v1.0.9 --- backend/app/main.py | 40 ++++++++++++++++++++++++++-------------- docker-compose.hub.yml | 2 +- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index b2453ac..f39cd87 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1292,18 +1292,20 @@ def get_dashboard_data( # Aplicar filtros de fecha if start_date: - # Parsear fecha y establecer al inicio del día en UTC + # Parsear fecha y establecer al inicio del día en UTC-3 from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) + start = start.replace(tzinfo=local_tz) query = query.filter(models.Inspection.started_at >= start) if end_date: - # Parsear fecha y establecer al final del día en UTC + # Parsear fecha y establecer al final del día en UTC-3 from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) if end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=local_tz) query = query.filter(models.Inspection.started_at <= end) # Filtro por mecánico @@ -1336,15 +1338,17 @@ def get_dashboard_data( if start_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) + start = start.replace(tzinfo=local_tz) flagged_items = flagged_items.filter(models.Inspection.started_at >= start) if end_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) if end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=local_tz) flagged_items = flagged_items.filter(models.Inspection.started_at <= end) if mechanic_id: flagged_items = flagged_items.filter(models.Inspection.mechanic_id == mechanic_id) @@ -1379,15 +1383,17 @@ def get_dashboard_data( if start_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) + start = start.replace(tzinfo=local_tz) mechanic_stats = mechanic_stats.filter(models.Inspection.started_at >= start) if end_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) if end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=local_tz) mechanic_stats = mechanic_stats.filter(models.Inspection.started_at <= end) mechanic_stats = mechanic_stats.group_by(models.User.id, models.User.full_name)\ @@ -1422,15 +1428,17 @@ def get_dashboard_data( if start_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) + start = start.replace(tzinfo=local_tz) checklist_stats_query = checklist_stats_query.filter(models.Inspection.started_at >= start) if end_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) if end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=local_tz) checklist_stats_query = checklist_stats_query.filter(models.Inspection.started_at <= end) if mechanic_id: checklist_stats_query = checklist_stats_query.filter(models.Inspection.mechanic_id == mechanic_id) @@ -1483,15 +1491,17 @@ def get_dashboard_data( if start_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) + start = start.replace(tzinfo=local_tz) pass_fail_data = pass_fail_data.filter(models.Inspection.started_at >= start) if end_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) if end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=local_tz) pass_fail_data = pass_fail_data.filter(models.Inspection.started_at <= end) if mechanic_id: pass_fail_data = pass_fail_data.filter(models.Inspection.mechanic_id == mechanic_id) @@ -1549,15 +1559,17 @@ def get_inspections_report( # Aplicar filtros if start_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) if start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) + start = start.replace(tzinfo=local_tz) query = query.filter(models.Inspection.started_at >= start) if end_date: from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) if end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=local_tz) query = query.filter(models.Inspection.started_at <= end) if mechanic_id: query = query.filter(models.Inspection.mechanic_id == mechanic_id) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index 2504547..999dcff 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -20,7 +20,7 @@ services: retries: 5 backend: - image: dymai/syntria-backend:latest + image: dymai/syntria-backend:1.0.9 container_name: syntria-backend-prod restart: always depends_on: -- 2.49.1 From 570cdb6739db08393e296bfc2cb9e4436a2af04a Mon Sep 17 00:00:00 2001 From: ronalds Date: Thu, 20 Nov 2025 16:23:52 -0300 Subject: [PATCH 09/44] feat: Add asesor role with reports-only access - backend v1.0.10, frontend v1.0.16 --- backend/app/main.py | 8 ++++---- backend/app/models.py | 2 +- docker-compose.hub.yml | 4 ++-- frontend/src/Sidebar.jsx | 38 +++++++++++++++++++++++--------------- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index f39cd87..cda47a5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1284,8 +1284,8 @@ def get_dashboard_data( db: Session = Depends(get_db) ): """Obtener datos del dashboard de informes""" - if current_user.role != "admin": - raise HTTPException(status_code=403, detail="Solo administradores pueden acceder a reportes") + if current_user.role not in ["admin", "asesor"]: + raise HTTPException(status_code=403, detail="No tienes permisos para acceder a reportes") # Construir query base query = db.query(models.Inspection) @@ -1531,8 +1531,8 @@ def get_inspections_report( db: Session = Depends(get_db) ): """Obtener lista de inspecciones con filtros""" - if current_user.role != "admin": - raise HTTPException(status_code=403, detail="Solo administradores pueden acceder a reportes") + if current_user.role not in ["admin", "asesor"]: + raise HTTPException(status_code=403, detail="No tienes permisos para acceder a reportes") # Query base con select_from explícito query = db.query( diff --git a/backend/app/models.py b/backend/app/models.py index 11bfb4b..93e3188 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -10,7 +10,7 @@ class User(Base): username = Column(String(50), unique=True, index=True, nullable=False) email = Column(String(100), unique=True, index=True) password_hash = Column(String(255), nullable=False) - role = Column(String(20), nullable=False) # admin, mechanic + role = Column(String(20), nullable=False) # admin, mechanic, asesor full_name = Column(String(100)) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index 999dcff..315a11d 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -20,7 +20,7 @@ services: retries: 5 backend: - image: dymai/syntria-backend:1.0.9 + image: dymai/syntria-backend:1.0.10 container_name: syntria-backend-prod restart: always depends_on: @@ -38,7 +38,7 @@ services: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 frontend: - image: dymai/syntria-frontend:1.0.15 + image: dymai/syntria-frontend:1.0.16 container_name: syntria-frontend-prod restart: always depends_on: diff --git a/frontend/src/Sidebar.jsx b/frontend/src/Sidebar.jsx index c7350c2..be86799 100644 --- a/frontend/src/Sidebar.jsx +++ b/frontend/src/Sidebar.jsx @@ -67,20 +67,26 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se {sidebarOpen && Usuarios} -
  • - -
  • + + )} + {(user.role === 'admin' || user.role === 'asesor') && ( +
  • + +
  • + )} + {user.role === 'admin' && ( + <>
  • - - - ))} + ) + })} ))} -- 2.49.1 From 678a8cd24b27df2e623212d6b35e08a4206e7ffc Mon Sep 17 00:00:00 2001 From: ronalds Date: Fri, 21 Nov 2025 02:28:27 -0300 Subject: [PATCH 16/44] =?UTF-8?q?fix:=20Corregir=20l=C3=B3gica=20de=20preg?= =?UTF-8?q?untas=20condicionales=20-=20frontend=20v1.0.20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sin valor por defecto en respuestas (usuario debe elegir) - setAnswers funcional para evitar condiciones de carrera - visibleQuestions actualizado en tiempo real - Sub-preguntas se muestran/ocultan dinámicamente según respuesta padre --- docker-compose.hub.yml | 2 +- frontend/src/App.jsx | 77 ++++++++++++++++++++++-------------------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index 0f79624..b583c54 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -38,7 +38,7 @@ services: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 frontend: - image: dymai/syntria-frontend:1.0.19 + image: dymai/syntria-frontend:1.0.20 container_name: syntria-frontend-prod restart: always depends_on: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c5c9bd2..25fe85d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2015,11 +2015,11 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { console.log('Questions loaded:', questionsData.length, 'questions') setQuestions(questionsData) - // Initialize answers object + // Initialize answers object - empty values to force user interaction const initialAnswers = {} questionsData.forEach(q => { initialAnswers[q.id] = { - value: q.type === 'pass_fail' ? 'pass' : '', + value: '', // No default value - user must choose observations: '', photos: [] } @@ -2102,7 +2102,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { const question = questions.find(q => q.id === questionId) const answer = answers[questionId] - if (!answer?.value) return // Don't save empty answers + if (!answer?.value && answer?.value !== '') return // Don't save if no value try { const token = localStorage.getItem('token') @@ -2155,10 +2155,10 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { } // Mark as saved - setAnswers({ - ...answers, - [questionId]: { ...answers[questionId], saved: true } - }) + setAnswers(prev => ({ + ...prev, + [questionId]: { ...prev[questionId], saved: true } + })) } } catch (error) { console.error('Error saving answer:', error) @@ -2415,7 +2415,9 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { } } - const currentQuestion = questions[currentQuestionIndex] + // Get visible questions based on conditional logic + const visibleQuestions = getVisibleQuestions() + const currentQuestion = visibleQuestions[currentQuestionIndex] return (
    @@ -2641,10 +2643,11 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { value="pass" checked={answers[currentQuestion.id]?.value === 'pass'} onChange={(e) => { - setAnswers({ - ...answers, - [currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value } - }) + const newValue = e.target.value + setAnswers(prev => ({ + ...prev, + [currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue } + })) setTimeout(() => saveAnswer(currentQuestion.id), 500) }} className="mr-2" @@ -2657,10 +2660,11 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { value="fail" checked={answers[currentQuestion.id]?.value === 'fail'} onChange={(e) => { - setAnswers({ - ...answers, - [currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value } - }) + const newValue = e.target.value + setAnswers(prev => ({ + ...prev, + [currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue } + })) setTimeout(() => saveAnswer(currentQuestion.id), 500) }} className="mr-2" @@ -2674,10 +2678,11 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { setAnswers({ - ...answers, - [currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value } - })} + onChange={(e) => setAnswers(prev => ({ + ...prev, + [currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value } + }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" > @@ -2722,10 +2727,10 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { {currentQuestion.type === 'text' && (