Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 103 additions & 29 deletions api/callcentersite/callcentersite/apps/dashboard/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@

from __future__ import annotations

from typing import Dict, List, Optional
from typing import Dict, List, Optional, Union

import csv
from io import BytesIO, StringIO

from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied, ValidationError
from django.utils import timezone
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas

from callcentersite.apps.users.models_permisos_granular import AuditoriaPermiso
from callcentersite.apps.users.service_helpers import (
auditar_accion_exitosa,
validar_usuario_existe,
verificar_permiso_y_auditar,
)
from callcentersite.apps.users.services_permisos_granular import UserManagementService

from .models import DashboardConfiguracion
Expand All @@ -27,6 +23,27 @@
class DashboardService:
"""Orquesta la construcción de respuestas para el dashboard."""

@staticmethod
def ver_dashboard(usuario_id: int) -> Dict[str, object]:
"""Retorna la configuración del dashboard del usuario.

Si el usuario no tiene configuración personalizada se usan los widgets
por defecto registrados en ``WIDGET_REGISTRY``.
"""
configuracion = DashboardConfiguracion.objects.filter(
usuario_id=usuario_id
).first()

widget_keys = configuracion.configuracion.get("widgets", []) if configuracion else list(WIDGET_REGISTRY.keys())
widgets = [WIDGET_REGISTRY[widget].__dict__ for widget in widget_keys if widget in WIDGET_REGISTRY]

# No fallback to defaults if user's config exists but is invalid; respect explicit selection.

return {
"widgets": widgets,
"last_update": timezone.now().isoformat(),
}

@staticmethod
def overview() -> Dict[str, object]:
"""Retorna overview general del dashboard (legacy)."""
Expand Down Expand Up @@ -67,14 +84,12 @@ def exportar(

Referencia: docs/PLAN_MAESTRO_PRIORIDAD_02.md (Tarea 26)
"""
# Verificar permiso y auditar
verificar_permiso_y_auditar(
tiene_permiso = UserManagementService.usuario_tiene_permiso(
usuario_id=usuario_id,
capacidad_codigo='sistema.vistas.dashboards.exportar',
recurso_tipo='dashboard',
accion='exportar',
mensaje_error='No tiene permiso para exportar dashboards',
)
if not tiene_permiso:
raise PermissionDenied('No tiene permiso para exportar dashboards')

# Validar formato
if formato not in ['pdf', 'excel']:
Expand All @@ -84,12 +99,12 @@ def exportar(
# Por ahora retornamos un placeholder
archivo = f'/tmp/dashboard_{usuario_id}_{timezone.now().timestamp()}.{formato}'

# Auditar acción exitosa
auditar_accion_exitosa(
AuditoriaPermiso.objects.create(
usuario_id=usuario_id,
capacidad_codigo='sistema.vistas.dashboards.exportar',
recurso_tipo='dashboard',
accion='exportar',
resultado='permitido',
detalles=f'Dashboard exportado a {formato}',
)

Expand All @@ -99,6 +114,69 @@ def exportar(
'timestamp': timezone.now().isoformat(),
}

@staticmethod
def personalizar_dashboard(usuario_id: int, widgets: List[str]) -> DashboardConfiguracion:
"""Guarda la lista de widgets habilitados para el usuario.

Args:
usuario_id: ID del usuario dueño de la configuración.
widgets: Lista de identificadores de widgets.

Raises:
ValidationError: Si la lista está vacía o contiene widgets inexistentes.
"""
if not widgets:
raise ValidationError('Debe proporcionar al menos un widget para personalizar el dashboard')

widgets_invalidos = [widget for widget in widgets if widget not in WIDGET_REGISTRY]
if widgets_invalidos:
raise ValidationError(f"Widget invalido: {', '.join(widgets_invalidos)}")
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message contains a typo: "invalido" should be "inválido" (with accent). This is inconsistent with other error messages in the codebase that use proper Spanish spelling.

Suggested change
raise ValidationError(f"Widget invalido: {', '.join(widgets_invalidos)}")
raise ValidationError(f"Widget inválido: {', '.join(widgets_invalidos)}")

Copilot uses AI. Check for mistakes.

configuracion, _ = DashboardConfiguracion.objects.update_or_create(
usuario_id=usuario_id,
defaults={"configuracion": {"widgets": widgets}},
)

return configuracion
Comment on lines +118 to +140
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new method personalizar_dashboard bypasses permission checks entirely, while the existing personalizar method (line 202-207) includes explicit permission validation. This creates a security vulnerability where users can personalize dashboards without proper authorization. Add the same permission check using UserManagementService.usuario_tiene_permiso with capability code 'sistema.vistas.dashboards.personalizar'.

Copilot uses AI. Check for mistakes.

@staticmethod
def exportar_dashboard(usuario_id: int, formato: str = 'csv') -> Union[str, bytes]:
"""Exporta el dashboard del usuario en formato CSV o PDF.

Args:
usuario_id: ID del usuario.
formato: Formato solicitado (``csv`` o ``pdf``).

Returns:
Cadena CSV cuando ``formato`` es ``csv`` o bytes que representan un
PDF cuando ``formato`` es ``pdf``.

Raises:
ValidationError: Si el formato es inválido.
"""
if formato not in {"csv", "pdf"}:
raise ValidationError("Formato inválido. Use csv o pdf")

dashboard = DashboardService.ver_dashboard(usuario_id=usuario_id)
widgets = dashboard.get("widgets", [])

if formato == "csv":
output = StringIO()
writer = csv.DictWriter(output, fieldnames=["type", "title", "value", "change", "period"])
writer.writeheader()
for widget in widgets:
writer.writerow(widget)
Comment on lines +165 to +168
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSV export may fail if any widget dictionary doesn't contain all the expected fields ("type", "title", "value", "change", "period"). The writer.writerow(widget) call will raise a ValueError if keys are missing. Add validation to ensure widgets have all required fields, or use extrasaction='ignore' and handle missing keys gracefully.

Suggested change
writer = csv.DictWriter(output, fieldnames=["type", "title", "value", "change", "period"])
writer.writeheader()
for widget in widgets:
writer.writerow(widget)
fieldnames = ["type", "title", "value", "change", "period"]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
for widget in widgets:
# Fill missing keys with empty string
row = {key: widget.get(key, "") for key in fieldnames}
writer.writerow(row)

Copilot uses AI. Check for mistakes.
return output.getvalue()

buffer = BytesIO()
buffer.write(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
buffer.write(b"1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n")
buffer.write(b"2 0 obj<< /Type /Pages /Kids[3 0 R] /Count 1 >>endobj\n")
buffer.write(b"3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox[0 0 300 144] /Contents 4 0 R >>endobj\n")
buffer.write(b"4 0 obj<< /Length 44 >>stream\nBT /F1 12 Tf 72 700 Td (Dashboard Export) Tj ET\nendstream endobj\n")
buffer.write(b"xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000113 00000 n \n0000000200 00000 n \ntrail\n<< /Size 5 /Root 1 0 R >>\nstartxref\n280\n%%EOF")
Comment on lines +172 to +177
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manually constructed PDF in lines 173-178 is incomplete and malformed:

  1. Missing proper xref table entries (incorrect format and incomplete offsets)
  2. The xref section has "trail\n" instead of "trailer"
  3. Missing proper EOF newline
  4. Object references and offsets don't match the actual byte positions

This will produce a corrupted PDF that most readers will fail to open. Consider using a lightweight PDF library like fpdf or weasyprint, or at minimum, construct a valid minimal PDF with correct xref offsets and trailer structure.

Copilot uses AI. Check for mistakes.
return buffer.getvalue()
Comment on lines +143 to +178
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new method exportar_dashboard bypasses permission checks entirely, while the existing exportar method (line 88-93) includes explicit permission validation. This creates a security vulnerability where users can export dashboards without proper authorization by calling the new method instead of the legacy one. Add the same permission check using UserManagementService.usuario_tiene_permiso with capability code 'sistema.vistas.dashboards.exportar'.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +118 to +178
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new methods personalizar_dashboard and exportar_dashboard lack audit logging, while the existing methods personalizar (line 219-227) and exportar (line 103-110) properly create AuditoriaPermiso records. This creates an inconsistent audit trail where some dashboard operations are logged and others aren't, making it difficult to track user actions and comply with security requirements. Add equivalent audit logging using AuditoriaPermiso.objects.create() for both new methods.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


@staticmethod
def personalizar(
usuario_id: int,
Expand All @@ -120,14 +198,12 @@ def personalizar(

Referencia: docs/PLAN_MAESTRO_PRIORIDAD_02.md (Tarea 28)
"""
# Verificar permiso y auditar
verificar_permiso_y_auditar(
tiene_permiso = UserManagementService.usuario_tiene_permiso(
usuario_id=usuario_id,
capacidad_codigo='sistema.vistas.dashboards.personalizar',
recurso_tipo='dashboard',
accion='personalizar',
mensaje_error='No tiene permiso para personalizar dashboards',
)
if not tiene_permiso:
raise PermissionDenied('No tiene permiso para personalizar dashboards')

# Validar que configuracion sea dict
if not isinstance(configuracion, dict):
Expand All @@ -139,13 +215,13 @@ def personalizar(
defaults={'configuracion': configuracion},
)

# Auditar acción exitosa
auditar_accion_exitosa(
AuditoriaPermiso.objects.create(
usuario_id=usuario_id,
capacidad_codigo='sistema.vistas.dashboards.personalizar',
recurso_tipo='dashboard',
accion='personalizar',
recurso_id=config.id,
resultado='permitido',
detalles=f'Dashboard personalizado. Widgets: {len(configuracion.get("widgets", []))}',
)

Expand Down Expand Up @@ -177,14 +253,12 @@ def compartir(

Referencia: docs/PLAN_MAESTRO_PRIORIDAD_02.md (Tarea 30)
"""
# Verificar permiso y auditar
verificar_permiso_y_auditar(
tiene_permiso = UserManagementService.usuario_tiene_permiso(
usuario_id=usuario_id,
capacidad_codigo='sistema.vistas.dashboards.compartir',
recurso_tipo='dashboard',
accion='compartir',
mensaje_error='No tiene permiso para compartir dashboards',
)
if not tiene_permiso:
raise PermissionDenied('No tiene permiso para compartir dashboards')

# Validar que se especifico al menos un receptor
if not compartir_con_usuario_id and not compartir_con_grupo_codigo:
Expand Down Expand Up @@ -224,12 +298,12 @@ def compartir(
# TODO: Implementar logica real de compartir
# (crear registro en tabla compartidos, enviar notificacion, etc.)

# Auditar acción exitosa
auditar_accion_exitosa(
AuditoriaPermiso.objects.create(
usuario_id=usuario_id,
capacidad_codigo='sistema.vistas.dashboards.compartir',
recurso_tipo='dashboard',
accion='compartir',
resultado='permitido',
detalles=f'Dashboard compartido con {tipo}: {compartido_con}',
)

Expand Down
Loading