bloque 4: campanas por segmentos con medicion (modulo skeen_campanas: segmentos reutilizables con conteo verificado, campanas por WhatsApp y correo con programacion, prueba previa, limite por minuto, pausa por errores, consentimiento por canal y atribucion de citas) + encuesta al salir con NPS (campo propio, envio automatico y recordatorio, reporte con tendencia y desglose); apagado por defecto
This commit is contained in:
4
odoo-addons/skeen_campanas/controllers/__init__.py
Normal file
4
odoo-addons/skeen_campanas/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import campanas
|
||||
from . import encuestas
|
||||
576
odoo-addons/skeen_campanas/controllers/campanas.py
Normal file
576
odoo-addons/skeen_campanas/controllers/campanas.py
Normal file
@@ -0,0 +1,576 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API autenticada de campañas y segmentos.
|
||||
|
||||
Rutas bajo `/skeen/frontend/v1/campanas/*`. Mismo guard doble que el resto del
|
||||
frontend: `@require_role` para el rango del rol y `@require_perm` para la acción
|
||||
concreta (`campanas.ver|gestionar|enviar|medir`).
|
||||
|
||||
El módulo nace APAGADO (`campanas.activo=false`): las acciones que envían
|
||||
mensajes responden con un aviso claro y no tocan nada. Leer y administrar
|
||||
segmentos/campañas sigue disponible para poder configurarlas.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.skeen_whatsapp.controllers.auth import (
|
||||
_json, current_user, require_role, require_perm,
|
||||
)
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_campanas
|
||||
|
||||
from ..models.segmento import normalizar_criterios, validar_criterios
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/campanas'
|
||||
|
||||
|
||||
def _body():
|
||||
try:
|
||||
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _error(message, status=400):
|
||||
return _json({'status': 'error', 'message': message}, status)
|
||||
|
||||
|
||||
def _parse_dt(valor):
|
||||
"""Acepta 'YYYY-MM-DD HH:MM', 'YYYY-MM-DDTHH:MM' o vacío."""
|
||||
texto = str(valor or '').strip().replace('T', ' ')
|
||||
if not texto:
|
||||
return False
|
||||
if len(texto) == 10:
|
||||
texto += ' 00:00:00'
|
||||
try:
|
||||
return fields.Datetime.to_datetime(texto)
|
||||
except Exception:
|
||||
raise ValueError('La fecha no es válida (usa AAAA-MM-DD HH:MM)')
|
||||
|
||||
|
||||
def _seg_dict(seg):
|
||||
return {
|
||||
'id': seg.id,
|
||||
'name': seg.name,
|
||||
'descripcion': seg.descripcion or '',
|
||||
'activo': seg.activo,
|
||||
'criterios': seg.criterios_dict(),
|
||||
'ultimo_conteo': seg.ultimo_conteo,
|
||||
'muestra': seg.muestra_lista(),
|
||||
'preview_at': seg.preview_at.strftime('%Y-%m-%d %H:%M') if seg.preview_at else None,
|
||||
'creado_por': seg.creado_por or '',
|
||||
}
|
||||
|
||||
|
||||
def _camp_dict(c):
|
||||
return {
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'descripcion': c.descripcion or '',
|
||||
'activo': c.activo,
|
||||
'segmento_id': c.segmento_id.id if c.segmento_id else None,
|
||||
'segmento': c.segmento_id.name if c.segmento_id else '',
|
||||
'canal': c.canal,
|
||||
'canales': c.canales_lista(),
|
||||
'plantilla': c.plantilla or '',
|
||||
'enlace': c.enlace or '',
|
||||
'programacion': c.programacion,
|
||||
'fecha_programada': c.fecha_programada.strftime('%Y-%m-%d %H:%M')
|
||||
if c.fecha_programada else None,
|
||||
'hora': c.hora or '',
|
||||
'estado': c.estado,
|
||||
'ultima_ejecucion': c.ultima_ejecucion.strftime('%Y-%m-%d %H:%M')
|
||||
if c.ultima_ejecucion else None,
|
||||
'proxima_ejecucion': c.proxima_ejecucion.strftime('%Y-%m-%d %H:%M')
|
||||
if c.proxima_ejecucion else None,
|
||||
'motivo_pausa': c.motivo_pausa or '',
|
||||
'creado_por': c.creado_por or '',
|
||||
'notas': c.notas or '',
|
||||
}
|
||||
|
||||
|
||||
class SkeenCampanasController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilidades
|
||||
# ------------------------------------------------------------------
|
||||
def _cfg(self):
|
||||
return get_campanas(request.env)
|
||||
|
||||
def _requiere_activo(self):
|
||||
if not self._cfg()['activo']:
|
||||
return _error('El módulo de campañas está apagado. Actívalo en '
|
||||
'Configuración para enviar.', 400)
|
||||
return None
|
||||
|
||||
def _vals_segmento(self, data, crear):
|
||||
vals = {}
|
||||
if 'name' in data or crear:
|
||||
name = str(data.get('name') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('El nombre del segmento es obligatorio')
|
||||
vals['name'] = name[:120]
|
||||
if 'descripcion' in data:
|
||||
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
|
||||
if 'activo' in data:
|
||||
vals['activo'] = bool(data['activo'])
|
||||
if 'criterios' in data or crear:
|
||||
vals['criterios'] = json.dumps(
|
||||
validar_criterios(data.get('criterios') or {}), ensure_ascii=False)
|
||||
return vals
|
||||
|
||||
def _vals_campana(self, data, crear):
|
||||
vals = {}
|
||||
if 'name' in data or crear:
|
||||
name = str(data.get('name') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('El nombre de la campaña es obligatorio')
|
||||
vals['name'] = name[:200]
|
||||
if 'descripcion' in data:
|
||||
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
|
||||
if 'activo' in data:
|
||||
vals['activo'] = bool(data['activo'])
|
||||
if 'segmento_id' in data:
|
||||
sid = data.get('segmento_id')
|
||||
if sid in (None, '', 0):
|
||||
vals['segmento_id'] = False
|
||||
else:
|
||||
seg = request.env['skeen.segmento'].sudo().browse(int(sid))
|
||||
if not seg.exists():
|
||||
raise ValueError('El segmento no existe')
|
||||
vals['segmento_id'] = seg.id
|
||||
if 'canal' in data:
|
||||
canal = str(data.get('canal') or '').strip()
|
||||
if canal not in ('whatsapp', 'email', 'ambos'):
|
||||
raise ValueError('El canal debe ser whatsapp, email o ambos')
|
||||
vals['canal'] = canal
|
||||
if 'plantilla' in data:
|
||||
vals['plantilla'] = str(data.get('plantilla') or '')[:2000]
|
||||
if 'enlace' in data:
|
||||
vals['enlace'] = str(data.get('enlace') or '').strip()[:500]
|
||||
if 'programacion' in data or crear:
|
||||
prog = str(data.get('programacion') or 'una_vez').strip()
|
||||
if prog not in ('una_vez', 'mensual', 'semanal', 'segmento'):
|
||||
raise ValueError('Programación no válida')
|
||||
vals['programacion'] = prog
|
||||
if 'fecha_programada' in data:
|
||||
vals['fecha_programada'] = _parse_dt(data.get('fecha_programada'))
|
||||
if 'hora' in data:
|
||||
hora = str(data.get('hora') or '').strip()
|
||||
partes = hora.split(':')
|
||||
if len(partes) != 2:
|
||||
raise ValueError('La hora debe tener el formato HH:MM')
|
||||
try:
|
||||
h, m = int(partes[0]), int(partes[1])
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('La hora debe tener el formato HH:MM')
|
||||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||||
raise ValueError('La hora debe tener el formato HH:MM')
|
||||
vals['hora'] = '%02d:%02d' % (h, m)
|
||||
if 'notas' in data:
|
||||
vals['notas'] = str(data.get('notas') or '')[:2000]
|
||||
return vals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Segmentos
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/segmentos', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def listar_segmentos(self, **kw):
|
||||
try:
|
||||
incluir_inactivos = str(kw.get('include_inactive') or '') in ('1', 'true', 'True')
|
||||
dominio = [] if incluir_inactivos else [('activo', '=', True)]
|
||||
segmentos = request.env['skeen.segmento'].sudo().search(dominio, order='name, id')
|
||||
return _json({'status': 'success',
|
||||
'segmentos': [_seg_dict(s) for s in segmentos],
|
||||
'total': len(segmentos)})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/segmentos/preview', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def preview_criterios(self, **kw):
|
||||
"""Vista previa (conteo + muestra) SIN guardar el segmento."""
|
||||
try:
|
||||
data = _body()
|
||||
resultado = request.env['skeen.segmento'].sudo().previsualizar(
|
||||
data.get('criterios') or {})
|
||||
return _json({'status': 'success', **resultado})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def get_segmento(self, segmento_id, **kw):
|
||||
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||
if not seg.exists():
|
||||
return _error('Segmento no encontrado', 404)
|
||||
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
|
||||
|
||||
@http.route(PREFIX + '/segmentos', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def crear_segmento(self, **kw):
|
||||
data = _body()
|
||||
try:
|
||||
vals = self._vals_segmento(data, crear=True)
|
||||
usuario = current_user(request.env)
|
||||
vals['creado_por'] = usuario.name if usuario else ''
|
||||
seg = request.env['skeen.segmento'].sudo().create(vals)
|
||||
seg.guardar_preview()
|
||||
return _json({'status': 'success', 'segmento': _seg_dict(seg)}, 201)
|
||||
except ValueError as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
|
||||
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def actualizar_segmento(self, segmento_id, **kw):
|
||||
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||
if not seg.exists():
|
||||
return _error('Segmento no encontrado', 404)
|
||||
data = _body()
|
||||
try:
|
||||
seg.write(self._vals_segmento(data, crear=False))
|
||||
if 'criterios' in data:
|
||||
seg.guardar_preview()
|
||||
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
|
||||
except ValueError as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def borrar_segmento(self, segmento_id, **kw):
|
||||
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||
if not seg.exists():
|
||||
return _error('Segmento no encontrado', 404)
|
||||
try:
|
||||
usadas = request.env['skeen.campana'].sudo().search_count(
|
||||
[('segmento_id', '=', seg.id)])
|
||||
if usadas:
|
||||
seg.write({'activo': False})
|
||||
return _json({'status': 'success', 'desactivado': True,
|
||||
'message': 'El segmento está en uso: se desactivó '
|
||||
'en lugar de borrarse.'})
|
||||
seg.unlink()
|
||||
return _json({'status': 'success', 'desactivado': False,
|
||||
'message': 'Segmento eliminado'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/segmentos/<int:segmento_id>/preview', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def recalcular_segmento(self, segmento_id, **kw):
|
||||
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||
if not seg.exists():
|
||||
return _error('Segmento no encontrado', 404)
|
||||
try:
|
||||
seg.guardar_preview()
|
||||
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consentimiento
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def get_consentimiento(self, partner_id, **kw):
|
||||
partner = request.env['res.partner'].sudo().browse(partner_id)
|
||||
if not partner.exists():
|
||||
return _error('Paciente no encontrado', 404)
|
||||
return _json({'status': 'success', 'consentimiento': _consent_dict(partner)})
|
||||
|
||||
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
|
||||
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def set_consentimiento(self, partner_id, **kw):
|
||||
partner = request.env['res.partner'].sudo().browse(partner_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return _error('Paciente no encontrado', 404)
|
||||
data = _body()
|
||||
try:
|
||||
usuario = current_user(request.env)
|
||||
request.env['res.partner']._marcar_consentimiento(
|
||||
partner,
|
||||
general=data.get('general'),
|
||||
whatsapp=data.get('whatsapp'),
|
||||
email=data.get('email'),
|
||||
origen=(usuario.name if usuario else '') or str(data.get('origen') or ''))
|
||||
return _json({'status': 'success',
|
||||
'consentimiento': _consent_dict(partner)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Campañas
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX, type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def listar_campanas(self, **kw):
|
||||
try:
|
||||
dominio = []
|
||||
if kw.get('estado'):
|
||||
dominio.append(('estado', '=', kw['estado']))
|
||||
campanas = request.env['skeen.campana'].sudo().search(
|
||||
dominio, order='create_date desc, id desc', limit=500)
|
||||
return _json({'status': 'success',
|
||||
'campanas': [_camp_dict(c) for c in campanas],
|
||||
'total': len(campanas)})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX, type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def crear_campana(self, **kw):
|
||||
data = _body()
|
||||
try:
|
||||
vals = self._vals_campana(data, crear=True)
|
||||
usuario = current_user(request.env)
|
||||
vals['creado_por'] = usuario.name if usuario else ''
|
||||
campana = request.env['skeen.campana'].sudo().create(vals)
|
||||
return _json({'status': 'success', 'campana': _camp_dict(campana)}, 201)
|
||||
except ValueError as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.ver')
|
||||
def get_campana(self, campana_id, **kw):
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
return _json({'status': 'success', 'campana': _camp_dict(campana)})
|
||||
|
||||
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
|
||||
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def actualizar_campana(self, campana_id, **kw):
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
data = _body()
|
||||
try:
|
||||
campana.write(self._vals_campana(data, crear=False))
|
||||
return _json({'status': 'success', 'campana': _camp_dict(campana)})
|
||||
except ValueError as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.gestionar')
|
||||
def borrar_campana(self, campana_id, **kw):
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
try:
|
||||
campana.write({'activo': False, 'estado': 'cancelada'})
|
||||
return _json({'status': 'success',
|
||||
'message': 'La campaña se archivó (cancelada y desactivada).'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Acciones
|
||||
# ------------------------------------------------------------------
|
||||
def _accion(self, campana_id, accion, **kw):
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
try:
|
||||
if accion == 'lanzar':
|
||||
if not campana.segmento_id:
|
||||
return _error('La campaña necesita un segmento para lanzarse')
|
||||
if not (campana.plantilla or '').strip():
|
||||
return _error('La campaña necesita un mensaje para lanzarse')
|
||||
if campana.programacion == 'una_vez' and not campana.fecha_programada:
|
||||
campana.fecha_programada = fields.Datetime.now()
|
||||
campana.action_lanzar()
|
||||
elif accion == 'pausar':
|
||||
campana.action_pausar()
|
||||
elif accion == 'reanudar':
|
||||
campana.action_reanudar()
|
||||
elif accion == 'cancelar':
|
||||
campana.action_cancelar()
|
||||
elif accion == 'borrador':
|
||||
campana.action_borrador()
|
||||
elif accion == 'procesar':
|
||||
servicio = request.env['skeen.campana.service'].sudo()
|
||||
resultado = servicio.ejecutar()
|
||||
return _json({'status': 'success', **resultado,
|
||||
'campana': _camp_dict(campana)})
|
||||
return _json({'status': 'success', 'campana': _camp_dict(campana)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/<int:campana_id>/<string:accion>', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.enviar')
|
||||
def accion_campana(self, campana_id, accion, **kw):
|
||||
bloqueo = self._requiere_activo()
|
||||
if bloqueo:
|
||||
return bloqueo
|
||||
if accion not in ('lanzar', 'pausar', 'reanudar', 'cancelar', 'borrador',
|
||||
'procesar'):
|
||||
return _error('Acción no válida', 404)
|
||||
return self._accion(campana_id, accion, **kw)
|
||||
|
||||
@http.route(PREFIX + '/procesar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.enviar')
|
||||
def procesar_campanas(self, **kw):
|
||||
bloqueo = self._requiere_activo()
|
||||
if bloqueo:
|
||||
return bloqueo
|
||||
try:
|
||||
resultado = request.env['skeen.campana.service'].sudo().ejecutar()
|
||||
return _json({'status': 'success', **resultado})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/<int:campana_id>/prueba', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('campanas.enviar')
|
||||
def prueba_campana(self, campana_id, **kw):
|
||||
bloqueo = self._requiere_activo()
|
||||
if bloqueo:
|
||||
return bloqueo
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
data = _body()
|
||||
partner = None
|
||||
if data.get('partner_id'):
|
||||
partner = request.env['res.partner'].sudo().browse(int(data['partner_id']))
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return _error('El paciente indicado no existe', 404)
|
||||
try:
|
||||
resultado = request.env['skeen.campana.service'].sudo().enviar_prueba(
|
||||
campana, partner=partner,
|
||||
telefono=str(data.get('telefono') or '').strip(),
|
||||
correo=str(data.get('correo') or '').strip())
|
||||
return _json({'status': 'success' if resultado.get('ok') else 'error',
|
||||
**resultado}, 200 if resultado.get('ok') else 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Medición
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/<int:campana_id>/metricas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.medir')
|
||||
def metricas_campana(self, campana_id, **kw):
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
try:
|
||||
servicio = request.env['skeen.campana.service'].sudo()
|
||||
campana_res = campana.with_context(lang=campana.env.user.lang)
|
||||
datos = campana_res.metricas(start=kw.get('start'), end=kw.get('end'))
|
||||
return _json({'status': 'success', 'metricas': datos})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/metricas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.medir')
|
||||
def metricas_todas(self, **kw):
|
||||
try:
|
||||
servicio = request.env['skeen.campana.service'].sudo()
|
||||
campanas = request.env['skeen.campana'].sudo().search([], limit=500)
|
||||
datos = [campana.metricas(start=kw.get('start'), end=kw.get('end'))
|
||||
for campana in campanas]
|
||||
total = {
|
||||
'alcanzados': sum(d['alcanzados'] for d in datos),
|
||||
'enviados': sum(d['enviados'] for d in datos),
|
||||
'citas_atribuidas': sum(d['citas_atribuidas'] for d in datos),
|
||||
'valor_atribuido': round(sum(d['valor_atribuido'] for d in datos), 2),
|
||||
}
|
||||
return _json({'status': 'success', 'metricas': datos, 'totales': total})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/<int:campana_id>/envios', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('campanas.medir')
|
||||
def envios_campana(self, campana_id, **kw):
|
||||
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||
if not campana.exists():
|
||||
return _error('Campaña no encontrada', 404)
|
||||
try:
|
||||
servicio = request.env['skeen.campana.service'].sudo()
|
||||
dominio = [('campana_id', '=', campana.id)]
|
||||
if str(kw.get('incluir_pruebas') or '') not in ('1', 'true', 'True'):
|
||||
dominio.append(('es_prueba', '=', False))
|
||||
notifs = request.env['skeen.notificacion'].sudo().search(
|
||||
dominio, order='id desc', limit=500)
|
||||
return _json({'status': 'success',
|
||||
'envios': [servicio._notif_dict(n) for n in notifs],
|
||||
'total': len(notifs)})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
|
||||
def _consent_dict(partner):
|
||||
return {
|
||||
'partner_id': partner.id,
|
||||
'name': partner.name,
|
||||
'general': bool(partner.consentimiento_marketing),
|
||||
'whatsapp': bool(partner.consentimiento_whatsapp),
|
||||
'email': bool(partner.consentimiento_email),
|
||||
'at': partner.consentimiento_at.strftime('%Y-%m-%d %H:%M')
|
||||
if partner.consentimiento_at else None,
|
||||
'origen': partner.consentimiento_origen or '',
|
||||
}
|
||||
258
odoo-addons/skeen_campanas/controllers/encuestas.py
Normal file
258
odoo-addons/skeen_campanas/controllers/encuestas.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API autenticada de encuestas de satisfacción (NPS).
|
||||
|
||||
Rutas bajo `/skeen/frontend/v1/encuestas/*`. Guard doble `@require_role` +
|
||||
`@require_perm` (`encuestas.ver|gestionar|medir`).
|
||||
|
||||
Con `encuestas.activo=false` (default) no se encola ni se envía nada; el
|
||||
reporte simplemente muestra lo que haya (cero en una instancia recién
|
||||
instalada).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.skeen_whatsapp.controllers.auth import (
|
||||
_json, current_user, require_role, require_perm,
|
||||
)
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/encuestas'
|
||||
|
||||
|
||||
def _error(message, status=400):
|
||||
return _json({'status': 'error', 'message': message}, status)
|
||||
|
||||
|
||||
def _body():
|
||||
try:
|
||||
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _rango(kw):
|
||||
hoy = date.today()
|
||||
start = kw.get('start') or (hoy.replace(day=1)).strftime('%Y-%m-%d')
|
||||
end = kw.get('end') or hoy.strftime('%Y-%m-%d')
|
||||
return start, end
|
||||
|
||||
|
||||
def _respuesta_dict(r, sucursales=None):
|
||||
cita = r.cita_id
|
||||
sucursal = ''
|
||||
if cita and cita.branch:
|
||||
sucursal = (sucursales or {}).get(cita.branch, cita.branch)
|
||||
return {
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'partner_id': r.partner_id.id,
|
||||
'patient': r.partner_id.name,
|
||||
'phone': r.partner_id.whatsapp or r.partner_id.phone or '',
|
||||
'nps_puntaje': r.nps_puntaje,
|
||||
'nps_categoria': r.nps_categoria or '',
|
||||
'comentario': r.nps_comentario or '',
|
||||
'llenado_at': r.llenado_at.strftime('%Y-%m-%d %H:%M') if r.llenado_at else None,
|
||||
'cita_id': cita.id if cita else None,
|
||||
'sucursal': sucursal,
|
||||
'doctor': cita.doctor_id.name if cita and cita.doctor_id else '',
|
||||
'servicio': cita.servicio_id.name if cita and cita.servicio_id else '',
|
||||
}
|
||||
|
||||
|
||||
class SkeenEncuestasController(http.Controller):
|
||||
|
||||
def _cfg(self):
|
||||
return get_encuestas(request.env)
|
||||
|
||||
def _plantilla(self):
|
||||
pid = self._cfg().get('plantilla_id')
|
||||
if not pid:
|
||||
return request.env['skeen.formulario.plantilla'].sudo().browse()
|
||||
p = request.env['skeen.formulario.plantilla'].sudo().browse(int(pid))
|
||||
return p if p.exists() else request.env['skeen.formulario.plantilla'].sudo().browse()
|
||||
|
||||
def _respuestas(self, start, end, solo_nps=False):
|
||||
plantilla = self._plantilla()
|
||||
if not plantilla:
|
||||
return request.env['skeen.formulario.respuesta'].sudo().browse()
|
||||
dominio = [
|
||||
('plantilla_id', '=', plantilla.id),
|
||||
('estado', '=', 'llenado'),
|
||||
('llenado_at', '>=', '%s 00:00:00' % start),
|
||||
('llenado_at', '<=', '%s 23:59:59' % end),
|
||||
]
|
||||
if solo_nps:
|
||||
dominio.append(('nps_puntaje', '>=', 0))
|
||||
return request.env['skeen.formulario.respuesta'].sudo().search(
|
||||
dominio, order='llenado_at desc, id desc')
|
||||
|
||||
def _sucursales(self):
|
||||
return {s.code: s.name for s in
|
||||
request.env['skeen.sucursal'].sudo().search([])}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuración
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('encuestas.ver')
|
||||
def config_encuestas(self, **kw):
|
||||
try:
|
||||
cfg = self._cfg()
|
||||
plantillas = request.env['skeen.formulario.plantilla'].sudo().search(
|
||||
[('tipo', '=', 'encuesta')], order='name')
|
||||
plantilla = self._plantilla()
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'config': cfg,
|
||||
'plantilla': {
|
||||
'id': plantilla.id, 'name': plantilla.name,
|
||||
'activo': plantilla.activo,
|
||||
} if plantilla else None,
|
||||
'plantillas': [{
|
||||
'id': p.id, 'name': p.name, 'activo': p.activo,
|
||||
'tiene_nps': any(c.get('tipo') == 'nps' for c in p.campos_lista()),
|
||||
} for p in plantillas],
|
||||
})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reporte de satisfacción
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/reporte', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('encuestas.medir')
|
||||
def reporte_satisfaccion(self, **kw):
|
||||
try:
|
||||
start, end = _rango(kw)
|
||||
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
|
||||
respuestas = self._respuestas(start, end, solo_nps=True)
|
||||
general = Respuesta.nps_de(respuestas)
|
||||
sucursales = self._sucursales()
|
||||
|
||||
# Tendencia por mes (últimos 12 meses, independiente del rango).
|
||||
hoy = date.today()
|
||||
tendencia = []
|
||||
for i in range(11, -1, -1):
|
||||
m = (hoy.replace(day=1) - timedelta(days=i * 31)).replace(day=1)
|
||||
fin = (m.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
|
||||
del_mes = Respuesta.search([
|
||||
('plantilla_id', '=', self._plantilla().id),
|
||||
('estado', '=', 'llenado'),
|
||||
('nps_puntaje', '>=', 0),
|
||||
('llenado_at', '>=', '%s 00:00:00' % m.strftime('%Y-%m-%d')),
|
||||
('llenado_at', '<=', '%s 23:59:59' % fin.strftime('%Y-%m-%d')),
|
||||
])
|
||||
datos = Respuesta.nps_de(del_mes)
|
||||
tendencia.append({
|
||||
'mes': m.strftime('%Y-%m'),
|
||||
'total': datos['total'],
|
||||
'nps': datos['nps'],
|
||||
'promotores': datos['promotores'],
|
||||
'detractores': datos['detractores'],
|
||||
})
|
||||
|
||||
def desglose(etiqueta):
|
||||
grupos = {}
|
||||
for r in respuestas:
|
||||
valor = etiqueta(r) or 'Sin dato'
|
||||
grupos.setdefault(valor, Respuesta.browse())
|
||||
grupos[valor] |= r
|
||||
salida = []
|
||||
for valor, resps in grupos.items():
|
||||
d = Respuesta.nps_de(resps)
|
||||
salida.append({'nombre': valor, **d})
|
||||
salida.sort(key=lambda x: (-(x['nps'] if x['nps'] is not None else -999),
|
||||
-x['total']))
|
||||
return salida
|
||||
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'start': start,
|
||||
'end': end,
|
||||
'general': general,
|
||||
'tendencia': tendencia,
|
||||
'por_sucursal': desglose(lambda r: (r.cita_id.branch
|
||||
and sucursales.get(r.cita_id.branch))
|
||||
or (r.cita_id.branch if r.cita_id else '')),
|
||||
'por_doctor': desglose(
|
||||
lambda r: r.cita_id.doctor_id.name if r.cita_id and r.cita_id.doctor_id else ''),
|
||||
'por_servicio': desglose(
|
||||
lambda r: r.cita_id.servicio_id.name if r.cita_id and r.cita_id.servicio_id else ''),
|
||||
})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/respuestas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('encuestas.medir')
|
||||
def listar_respuestas(self, **kw):
|
||||
try:
|
||||
start, end = _rango(kw)
|
||||
respuestas = self._respuestas(start, end, solo_nps=False)
|
||||
sucursales = self._sucursales()
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'respuestas': [_respuesta_dict(r, sucursales) for r in respuestas[:500]],
|
||||
'total': len(respuestas),
|
||||
})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Acciones
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/procesar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('encuestas.gestionar')
|
||||
def procesar_encuestas(self, **kw):
|
||||
if not self._cfg()['activo']:
|
||||
return _error('Las encuestas de salida están apagadas. Actívalas en '
|
||||
'Configuración.', 400)
|
||||
try:
|
||||
resultado = request.env['skeen.encuesta.service'].sudo().ejecutar()
|
||||
return _json({'status': 'success', **resultado})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/whatsapp', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('encuestas.medir')
|
||||
def responder_whatsapp(self, respuesta_id, **kw):
|
||||
"""Responde por WhatsApp al paciente desde el reporte (vía el motor)."""
|
||||
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
|
||||
if not respuesta.exists():
|
||||
return _error('Respuesta no encontrada', 404)
|
||||
data = _body()
|
||||
mensaje = str(data.get('mensaje') or '').strip()
|
||||
if not mensaje:
|
||||
return _error('Escribe un mensaje para enviar')
|
||||
partner = respuesta.partner_id
|
||||
servicio = request.env['skeen.notificacion.service'].sudo()
|
||||
try:
|
||||
import uuid as _uuid
|
||||
notifs = servicio.encolar(
|
||||
partner=partner, tipo='manual', canal='whatsapp',
|
||||
cuando=fields.Datetime.now(), mensaje=mensaje[:1000],
|
||||
clave_unica='encuesta-wa:r%s:%s' % (respuesta.id, _uuid.uuid4().hex[:10]))
|
||||
if not notifs:
|
||||
return _error('No se pudo enviar. Revisa que las notificaciones '
|
||||
'estén activas y el paciente tenga teléfono.', 400)
|
||||
cfg = servicio._cfg()
|
||||
estados = [servicio._intentar(n, cfg) for n in notifs]
|
||||
return _json({'status': 'success',
|
||||
'simulado': bool(cfg['modo_prueba']),
|
||||
'notificaciones': len(notifs)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
Reference in New Issue
Block a user