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