Files
SKEEN-Proyecto/odoo-addons/skeen_campanas/controllers/campanas.py

577 lines
24 KiB
Python

# -*- 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 '',
}