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:
@@ -1535,6 +1535,201 @@ def get_propinas(env):
|
||||
"""Bloque de propinas vigente (copia). Apagado por defecto."""
|
||||
return normalizar_propinas(get_settings(env).get('propinas'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Campañas por segmentos (marketing sobre la base de pacientes).
|
||||
#
|
||||
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
|
||||
# Campañas no aparece en el menú, no corre ningún cron, no se encola ni se
|
||||
# envía nada y las tablas propias quedan en cero.
|
||||
#
|
||||
# activo: enciende TODO el módulo de campañas
|
||||
# envios_por_minuto: tope de mensajes que el motor manda por minuto
|
||||
# pausar_tras_errores: errores seguidos que pausan la campaña sola
|
||||
# ventana_atribucion_dias: días tras el envío en que una cita se atribuye
|
||||
# exigir_consentimiento: nunca escribir a quien no autorizó marketing
|
||||
# hora_envio: hora (HH:MM) de los envíos recurrentes
|
||||
# ---------------------------------------------------------------------------
|
||||
CAMPANAS_DEFAULT = {
|
||||
'activo': False,
|
||||
'envios_por_minuto': 20,
|
||||
'pausar_tras_errores': 5,
|
||||
'ventana_atribucion_dias': 7,
|
||||
'exigir_consentimiento': True,
|
||||
'hora_envio': '10:00',
|
||||
}
|
||||
|
||||
CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX = 1, 600
|
||||
CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX = 1, 100
|
||||
CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX = 1, 90
|
||||
|
||||
|
||||
def normalizar_campanas(raw):
|
||||
"""Bloque `campanas` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(CAMPANAS_DEFAULT)
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for k in ('activo', 'exigir_consentimiento'):
|
||||
out[k] = _bool(raw.get(k), CAMPANAS_DEFAULT[k])
|
||||
out['envios_por_minuto'] = min(max(_int(
|
||||
raw.get('envios_por_minuto'), CAMPANAS_DEFAULT['envios_por_minuto']),
|
||||
CAMPANAS_ENVIOS_MIN), CAMPANAS_ENVIOS_MAX)
|
||||
out['pausar_tras_errores'] = min(max(_int(
|
||||
raw.get('pausar_tras_errores'), CAMPANAS_DEFAULT['pausar_tras_errores']),
|
||||
CAMPANAS_ERRORES_MIN), CAMPANAS_ERRORES_MAX)
|
||||
out['ventana_atribucion_dias'] = min(max(_int(
|
||||
raw.get('ventana_atribucion_dias'), CAMPANAS_DEFAULT['ventana_atribucion_dias']),
|
||||
CAMPANAS_VENTANA_MIN), CAMPANAS_VENTANA_MAX)
|
||||
out['hora_envio'] = _hhmm_valida(raw.get('hora_envio'), CAMPANAS_DEFAULT['hora_envio'])
|
||||
return out
|
||||
|
||||
|
||||
def validar_campanas(raw):
|
||||
"""Valida el bloque `campanas` de un PUT. Lanza ValueError si algo no cuadra.
|
||||
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('El bloque de campañas debe ser un objeto')
|
||||
out = {}
|
||||
for k in ('activo', 'exigir_consentimiento'):
|
||||
if k in raw:
|
||||
if not isinstance(raw[k], bool):
|
||||
raise ValueError('El campo %s debe ser verdadero o falso' % k)
|
||||
out[k] = raw[k]
|
||||
for k, lo, hi in (('envios_por_minuto', CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX),
|
||||
('pausar_tras_errores', CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX),
|
||||
('ventana_atribucion_dias', CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX)):
|
||||
if k in raw:
|
||||
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||
raise ValueError('El campo %s debe ser un número entero' % k)
|
||||
if not (lo <= raw[k] <= hi):
|
||||
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
||||
out[k] = raw[k]
|
||||
if 'hora_envio' in raw:
|
||||
partes = str(raw['hora_envio'] or '').strip().split(':')
|
||||
valido = False
|
||||
if len(partes) == 2:
|
||||
try:
|
||||
h, m = int(partes[0]), int(partes[1])
|
||||
valido = 0 <= h <= 23 and 0 <= m <= 59
|
||||
except (TypeError, ValueError):
|
||||
valido = False
|
||||
if not valido:
|
||||
raise ValueError('La hora de envío debe tener el formato HH:MM')
|
||||
out['hora_envio'] = _hhmm_valida(raw['hora_envio'])
|
||||
return out
|
||||
|
||||
|
||||
def get_campanas(env):
|
||||
"""Bloque de campañas vigente (copia). Apagado por defecto."""
|
||||
return normalizar_campanas(get_settings(env).get('campanas'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encuestas de salida y satisfacción (NPS).
|
||||
#
|
||||
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||
# oro: con `activo=False` (default) NADA cambia en la instancia — no se encola
|
||||
# ni se envía ninguna encuesta tras completar una cita y no se genera ninguna
|
||||
# respuesta nueva.
|
||||
#
|
||||
# activo: enciende el envío automático de la encuesta al salir
|
||||
# enviar_tras_horas: horas después de completarse la cita en que se envía
|
||||
# plantilla_id: plantilla de formulario (tipo encuesta) a enviar
|
||||
# canal: whatsapp | email | ambos
|
||||
# recordatorio_horas: horas para reenviar si no responde (0 = sin recordatorio)
|
||||
# solo_primera_visita: envía solo a quien tiene una única visita
|
||||
# agradecimiento: mensaje de gracias que ve el paciente al terminar
|
||||
# ---------------------------------------------------------------------------
|
||||
ENCUESTAS_DEFAULT = {
|
||||
'activo': False,
|
||||
'enviar_tras_horas': 2,
|
||||
'plantilla_id': None,
|
||||
'canal': 'whatsapp',
|
||||
'recordatorio_horas': 48,
|
||||
'solo_primera_visita': False,
|
||||
'agradecimiento': '',
|
||||
}
|
||||
|
||||
ENCUESTAS_CANALES = ('whatsapp', 'email', 'ambos')
|
||||
ENCUESTAS_HORAS_MAX = 720 # 30 días
|
||||
ENCUESTAS_RECORDATORIO_MAX = 720
|
||||
ENCUESTAS_AGRADECIMIENTO_MAX = 500
|
||||
|
||||
|
||||
def normalizar_encuestas(raw):
|
||||
"""Bloque `encuestas` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(ENCUESTAS_DEFAULT)
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for k in ('activo', 'solo_primera_visita'):
|
||||
out[k] = _bool(raw.get(k), ENCUESTAS_DEFAULT[k])
|
||||
out['enviar_tras_horas'] = min(max(_int(
|
||||
raw.get('enviar_tras_horas'), ENCUESTAS_DEFAULT['enviar_tras_horas']),
|
||||
0), ENCUESTAS_HORAS_MAX)
|
||||
out['recordatorio_horas'] = min(max(_int(
|
||||
raw.get('recordatorio_horas'), ENCUESTAS_DEFAULT['recordatorio_horas']),
|
||||
0), ENCUESTAS_RECORDATORIO_MAX)
|
||||
canal = str(raw.get('canal') or '').strip().lower()
|
||||
out['canal'] = canal if canal in ENCUESTAS_CANALES else ENCUESTAS_DEFAULT['canal']
|
||||
pid = raw.get('plantilla_id')
|
||||
if pid in (None, '', False, 0, '0'):
|
||||
out['plantilla_id'] = None
|
||||
else:
|
||||
n = _int(pid, 0)
|
||||
out['plantilla_id'] = n if n > 0 else None
|
||||
out['agradecimiento'] = str(raw.get('agradecimiento') or '')[:ENCUESTAS_AGRADECIMIENTO_MAX]
|
||||
return out
|
||||
|
||||
|
||||
def validar_encuestas(raw):
|
||||
"""Valida el bloque `encuestas` de un PUT. Lanza ValueError si algo no cuadra.
|
||||
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('El bloque de encuestas debe ser un objeto')
|
||||
out = {}
|
||||
for k in ('activo', 'solo_primera_visita'):
|
||||
if k in raw:
|
||||
if not isinstance(raw[k], bool):
|
||||
raise ValueError('El campo %s debe ser verdadero o falso' % k)
|
||||
out[k] = raw[k]
|
||||
for k, hi in (('enviar_tras_horas', ENCUESTAS_HORAS_MAX),
|
||||
('recordatorio_horas', ENCUESTAS_RECORDATORIO_MAX)):
|
||||
if k in raw:
|
||||
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||
raise ValueError('El campo %s debe ser un número entero' % k)
|
||||
if not (0 <= raw[k] <= hi):
|
||||
raise ValueError('El campo %s debe estar entre 0 y %d' % (k, hi))
|
||||
out[k] = raw[k]
|
||||
if 'canal' in raw:
|
||||
canal = str(raw['canal'] or '').strip().lower()
|
||||
if canal not in ENCUESTAS_CANALES:
|
||||
raise ValueError('El canal debe ser whatsapp, email o ambos')
|
||||
out['canal'] = canal
|
||||
if 'plantilla_id' in raw:
|
||||
pid = raw['plantilla_id']
|
||||
if pid in (None, '', False, 0):
|
||||
out['plantilla_id'] = None
|
||||
else:
|
||||
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
||||
raise ValueError('La plantilla de encuesta debe ser un identificador válido')
|
||||
out['plantilla_id'] = pid
|
||||
if 'agradecimiento' in raw:
|
||||
if not isinstance(raw['agradecimiento'], str):
|
||||
raise ValueError('El agradecimiento debe ser texto')
|
||||
if len(raw['agradecimiento']) > ENCUESTAS_AGRADECIMIENTO_MAX:
|
||||
raise ValueError('El agradecimiento es demasiado largo (máx. %d)'
|
||||
% ENCUESTAS_AGRADECIMIENTO_MAX)
|
||||
out['agradecimiento'] = raw['agradecimiento'].strip()
|
||||
return out
|
||||
|
||||
|
||||
def get_encuestas(env):
|
||||
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
|
||||
return normalizar_encuestas(get_settings(env).get('encuestas'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Licencia por plan (producto comercial).
|
||||
#
|
||||
@@ -1966,6 +2161,8 @@ def get_settings(env, aplicar_licencia=True):
|
||||
'comisiones': dict(COMISIONES_DEFAULT),
|
||||
'lealtad': normalizar_lealtad(None),
|
||||
'propinas': dict(PROPINAS_DEFAULT),
|
||||
'campanas': dict(CAMPANAS_DEFAULT),
|
||||
'encuestas': dict(ENCUESTAS_DEFAULT),
|
||||
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
|
||||
'portal_cliente': normalizar_portal_cliente(None),
|
||||
}
|
||||
@@ -1988,6 +2185,8 @@ def get_settings(env, aplicar_licencia=True):
|
||||
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
||||
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
|
||||
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
|
||||
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
|
||||
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
|
||||
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
|
||||
out['portal_cliente'] = normalizar_portal_cliente(
|
||||
guardado.get('portal_cliente'))
|
||||
@@ -2059,6 +2258,16 @@ def set_settings(env, vals):
|
||||
bloque = validar_propinas(vals['propinas'])
|
||||
if bloque is not None:
|
||||
actual['propinas'] = {**actual['propinas'], **bloque}
|
||||
if 'campanas' in vals:
|
||||
bloque = validar_campanas(vals['campanas'])
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||
actual['campanas'] = {**actual['campanas'], **bloque}
|
||||
if 'encuestas' in vals:
|
||||
bloque = validar_encuestas(vals['encuestas'])
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||
actual['encuestas'] = {**actual['encuestas'], **bloque}
|
||||
if 'licencia' in vals:
|
||||
bloque = validar_licencia(vals['licencia'])
|
||||
if bloque is not None:
|
||||
|
||||
Reference in New Issue
Block a user