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:
2026-10-11 09:05:19 +00:00
parent 9257586943
commit dabd8c6bd7
16 changed files with 1832 additions and 12 deletions

View File

@@ -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