fase 2.4: recordatorios y notificaciones automaticas (modelo+cola+bitacora, cron cada 5 min, recordatorios por ventana, confirmacion, cumpleanos, post-visita, recuperacion de no-show, modo prueba, plantillas con glosario, historial y reintentos); apagado por defecto
This commit is contained in:
@@ -8118,5 +8118,194 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Notificaciones y recordatorios de citas
|
||||
#
|
||||
# Las plantillas y el resto de la configuración viven en el bloque
|
||||
# `notificaciones` de clinic-settings (GET/PUT /clinic-settings), no en
|
||||
# endpoints aparte: así hay una sola fuente de verdad.
|
||||
# ============================================================
|
||||
def _notif_to_dict(self, n):
|
||||
return {
|
||||
'id': n.id,
|
||||
'partner_id': n.partner_id.id,
|
||||
'patient': n.partner_id.name or '',
|
||||
'phone': n.partner_id.whatsapp or n.partner_id.phone or '',
|
||||
'email': n.partner_id.email or '',
|
||||
'cita_id': n.cita_id.id if n.cita_id else None,
|
||||
'cita_ref': n.cita_id.name if n.cita_id else '',
|
||||
'tipo': n.tipo,
|
||||
'tipo_label': dict(n._fields['tipo'].selection).get(n.tipo, n.tipo),
|
||||
'canal': n.canal,
|
||||
'canal_label': dict(n._fields['canal'].selection).get(n.canal, n.canal),
|
||||
'estado': n.estado,
|
||||
'estado_label': dict(n._fields['estado'].selection).get(n.estado, n.estado),
|
||||
'asunto': n.asunto or '',
|
||||
'mensaje': n.mensaje or '',
|
||||
'programada_para': n.programada_para.strftime('%Y-%m-%d %H:%M') if n.programada_para else None,
|
||||
'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None,
|
||||
'error': n.error or '',
|
||||
'intentos': n.intentos,
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('notificaciones.ver')
|
||||
def list_notificaciones(self, **kw):
|
||||
"""Historial de notificaciones con filtros y resumen por estado."""
|
||||
try:
|
||||
domain = []
|
||||
try:
|
||||
if kw.get('cita_id'):
|
||||
domain.append(('cita_id', '=', int(kw['cita_id'])))
|
||||
if kw.get('partner_id'):
|
||||
domain.append(('partner_id', '=', int(kw['partner_id'])))
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'cita_id / partner_id deben ser numéricos'}, 400)
|
||||
for campo in ('estado', 'tipo', 'canal'):
|
||||
if kw.get(campo):
|
||||
domain.append((campo, '=', kw[campo]))
|
||||
if kw.get('start'):
|
||||
domain.append(('programada_para', '>=', '%s 00:00:00' % kw['start']))
|
||||
if kw.get('end'):
|
||||
domain.append(('programada_para', '<=', '%s 23:59:59' % kw['end']))
|
||||
|
||||
Notif = request.env['skeen.notificacion'].sudo()
|
||||
try:
|
||||
page = max(int(kw.get('page', 1) or 1), 1)
|
||||
page_size = int(kw.get('page_size') or kw.get('limit') or 50)
|
||||
except (TypeError, ValueError):
|
||||
page, page_size = 1, 50
|
||||
page_size = min(max(page_size, 1), 200)
|
||||
|
||||
total = Notif.search_count(domain)
|
||||
registros = Notif.search(domain, order='programada_para desc, id desc',
|
||||
limit=page_size, offset=(page - 1) * page_size)
|
||||
|
||||
resumen = {estado: 0 for estado, _l in Notif._fields['estado'].selection}
|
||||
for grupo in Notif.read_group(domain, ['estado'], ['estado'], lazy=False):
|
||||
estado = grupo.get('estado')
|
||||
if estado:
|
||||
resumen[estado] = grupo.get('__count', grupo.get('estado_count', 0))
|
||||
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'notificaciones': [self._notif_to_dict(n) for n in registros],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size,
|
||||
'resumen': resumen,
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _procesar_notificaciones(self, limite):
|
||||
"""Corre el mismo ciclo que el cron y devuelve los conteos."""
|
||||
return request.env['skeen.notificacion.service'].sudo().ejecutar(limite=limite)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/procesar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('notificaciones.enviar')
|
||||
def procesar_notificaciones(self, **kw):
|
||||
"""Fuerza el procesamiento: encola lo que toca y envía la cola."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
limite = int(data.get('limit') or 100)
|
||||
except (TypeError, ValueError):
|
||||
limite = 100
|
||||
limite = min(max(limite, 1), 500)
|
||||
resultado = self._procesar_notificaciones(limite)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'message': 'Se encolaron %s y se procesaron %s notificaciones.'
|
||||
% (resultado['encoladas'], resultado['revisadas']),
|
||||
**resultado,
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/enviar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('notificaciones.enviar')
|
||||
def enviar_notificacion(self, **kw):
|
||||
"""Encola y envía un mensaje puntual desde el detalle de la cita."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
cita_id = data.get('cita_id')
|
||||
if not cita_id:
|
||||
return json_response({'status': 'error', 'message': 'Falta cita_id'}, 400)
|
||||
canal = data.get('canal') or None
|
||||
if canal not in (None, 'whatsapp', 'email'):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El canal debe ser whatsapp o email'}, 400)
|
||||
cita = request.env['skeen.cita'].sudo().browse(int(cita_id))
|
||||
if not cita.exists():
|
||||
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
|
||||
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
cfg = service._cfg()
|
||||
if not cfg['activo']:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Las notificaciones están desactivadas'}, 400)
|
||||
|
||||
creadas = service.encolar_manual(cita, canal=canal)
|
||||
if not creadas:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El paciente no tiene destino para ese canal '}, 400)
|
||||
|
||||
estados = {}
|
||||
for notif in creadas:
|
||||
estados[service._intentar(notif, cfg)] = True
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'notificaciones': [self._notif_to_dict(n) for n in creadas],
|
||||
'simulado': bool(cfg['modo_prueba']),
|
||||
'message': ('Modo prueba: el mensaje quedó registrado como simulado.'
|
||||
if cfg['modo_prueba'] else 'Mensaje enviado.'),
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/<int:notif_id>/reintentar',
|
||||
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('notificaciones.enviar')
|
||||
def reintentar_notificacion(self, notif_id, **kw):
|
||||
"""Devuelve a la cola una notificación fallida e intenta de nuevo."""
|
||||
try:
|
||||
notif = request.env['skeen.notificacion'].sudo().browse(notif_id)
|
||||
if not notif.exists():
|
||||
return json_response({'status': 'error', 'message': 'Notificación no encontrada'}, 404)
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
notif.action_reintentar()
|
||||
estado = service._intentar(notif, service._cfg())
|
||||
return json_response({'status': 'success', 'estado': estado,
|
||||
'notificacion': self._notif_to_dict(notif)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/notificaciones/probar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
@require_perm('notificaciones.editar')
|
||||
def probar_notificacion(self, **kw):
|
||||
"""Prueba de conexión de un canal. Nunca lanza: responde 200 siempre."""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
resultado = service.probar_canal(data.get('canal'), data.get('destino') or '')
|
||||
return json_response({'status': 'success', **resultado})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# Importar fields para fechas
|
||||
from odoo import fields
|
||||
|
||||
@@ -254,14 +254,25 @@ class SkeenPublicBookingController(http.Controller):
|
||||
return cita
|
||||
|
||||
def _encolar_confirmacion(self, cita):
|
||||
"""Hook Fase 2.4: avisar al cliente que la clínica confirmará su cita.
|
||||
"""Encola y dispara la confirmación al paciente que reservó en línea.
|
||||
|
||||
A propósito es un no-op: la Fase 2.4 enviará el mensaje de confirmación
|
||||
por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del
|
||||
paciente) cuando esas credenciales estén configuradas. Se deja el punto
|
||||
de extensión listo, sin acoplar el portal público a WhatsApp todavía.
|
||||
Solo actúa si `notificaciones.activo` y `confirmacion_al_crear` están
|
||||
encendidos; el motor decide si envía o simula (modo prueba). Nunca debe
|
||||
tumbar la reserva: cualquier fallo se registra y se ignora.
|
||||
"""
|
||||
return False
|
||||
try:
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
|
||||
cfg = get_notificaciones(request.env)
|
||||
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
|
||||
return False
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
creadas = service.encolar(cita, 'confirmacion')
|
||||
for notif in creadas:
|
||||
service._intentar(notif, cfg)
|
||||
return bool(creadas)
|
||||
except Exception:
|
||||
_logger.exception('No se pudo encolar la confirmación de la reserva en línea')
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Configuración pública del portal
|
||||
|
||||
Reference in New Issue
Block a user