fase 2.5: formularios digitales y consentimientos (modulo skeen_formularios: plantillas con 12 tipos de campo, versionado, respuestas con firma PNG y fotos antes/despues, portal publico con token HMAC y limite de subida, historial por paciente, permisos y notificacion de envio)
This commit is contained in:
@@ -312,15 +312,20 @@ NOTIFICACIONES_DEFAULT = {
|
||||
'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes '
|
||||
'dudas o quieres agendar de nuevo, llámanos al {telefono}.'
|
||||
),
|
||||
'plantilla_formulario': (
|
||||
'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. '
|
||||
'Ábrelo desde tu celular aquí: {enlace}'
|
||||
),
|
||||
'whatsapp_numero': '',
|
||||
'email_remitente': '',
|
||||
}
|
||||
|
||||
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
|
||||
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
||||
'sucursal', 'telefono')
|
||||
'sucursal', 'telefono', 'enlace', 'formulario')
|
||||
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
||||
'plantilla_cumpleanos', 'plantilla_post_visita')
|
||||
'plantilla_cumpleanos', 'plantilla_post_visita',
|
||||
'plantilla_formulario')
|
||||
NOTIF_HORAS_MAX = 168 # 1 semana
|
||||
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
||||
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
|
||||
|
||||
4
odoo-addons/skeen_formularios/__init__.py
Normal file
4
odoo-addons/skeen_formularios/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
26
odoo-addons/skeen_formularios/__manifest__.py
Normal file
26
odoo-addons/skeen_formularios/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Formularios',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Formularios digitales y consentimientos informados para SKEEN',
|
||||
'description': """
|
||||
Constructor de plantillas de formularios (consentimientos, encuestas,
|
||||
autorizaciones y notas de expediente) que el paciente llena desde su
|
||||
celular con firma y fotos, o que el personal llena en recepción.
|
||||
|
||||
Sin plantillas creadas el módulo no expone nada: ni la API pública
|
||||
responde, ni el expediente muestra secciones nuevas.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_citas', 'skeen_visitas', 'skeen_whatsapp'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
5
odoo-addons/skeen_formularios/controllers/__init__.py
Normal file
5
odoo-addons/skeen_formularios/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import comun
|
||||
from . import formularios
|
||||
from . import public_formulario
|
||||
323
odoo-addons/skeen_formularios/controllers/comun.py
Normal file
323
odoo-addons/skeen_formularios/controllers/comun.py
Normal file
@@ -0,0 +1,323 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Utilidades compartidas por los controladores de formularios.
|
||||
|
||||
Aquí vive lo que no debe duplicarse entre la API autenticada y el portal
|
||||
público: firma/verificación del token HMAC, validación de las respuestas contra
|
||||
la plantilla, lectura acotada de imágenes en data URL y los serializadores.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from odoo.addons.skeen_whatsapp.controllers.auth import _get_secret
|
||||
|
||||
# El token de un formulario viaja en un enlace que el paciente puede abrir
|
||||
# días después: mismo TTL que el portal de reservas (180 días).
|
||||
FORM_TOKEN_TTL = 60 * 60 * 24 * 180
|
||||
FORM_TOKEN_KIND = 'formulario'
|
||||
|
||||
# Límites de subida. Las fotos de celular pesan; se acotan por foto y en número.
|
||||
MAX_FOTO_BYTES = 5 * 1024 * 1024
|
||||
MAX_FIRMA_BYTES = 3 * 1024 * 1024
|
||||
MAX_FOTOS = 10
|
||||
# Solo formatos que el navegador produce de forma habitual y que PIL sabe leer
|
||||
# (así una imagen dañada se rechaza con 400 y no revienta el guardado).
|
||||
MIMES_FOTO = ('image/jpeg', 'image/png', 'image/webp')
|
||||
|
||||
MAX_TEXTO_RESP = 5000
|
||||
|
||||
|
||||
def _b64url_encode(raw):
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def _b64url_decode(s):
|
||||
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
|
||||
|
||||
|
||||
def firmar_token(env, respuesta):
|
||||
"""Token HMAC de una respuesta (mismo formato que el portal de reservas)."""
|
||||
payload = {'rid': respuesta.id, 'kind': FORM_TOKEN_KIND,
|
||||
'exp': int(time.time()) + FORM_TOKEN_TTL}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||||
sig = hmac.new(_get_secret(env).encode('utf-8'),
|
||||
body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||
return '%s.%s' % (body, sig)
|
||||
|
||||
|
||||
def verificar_token(env, token):
|
||||
"""Payload del token o None si es inválido, de otro tipo o expiró."""
|
||||
if not token or '.' not in str(token):
|
||||
return None
|
||||
try:
|
||||
body, sig = str(token).rsplit('.', 1)
|
||||
expected = hmac.new(_get_secret(env).encode('utf-8'),
|
||||
body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
payload = json.loads(_b64url_decode(body).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
if payload.get('kind') != FORM_TOKEN_KIND:
|
||||
return None
|
||||
if int(payload.get('exp', 0)) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def respuesta_de_token(env, token):
|
||||
"""Respuesta vinculada al token, o recordset vacío."""
|
||||
payload = verificar_token(env, token)
|
||||
if not payload:
|
||||
return env['skeen.formulario.respuesta'].sudo().browse()
|
||||
respuesta = env['skeen.formulario.respuesta'].sudo().browse(int(payload.get('rid') or 0))
|
||||
return respuesta if respuesta.exists() else env['skeen.formulario.respuesta'].sudo().browse()
|
||||
|
||||
|
||||
def parse_data_url(valor, max_bytes, mimes_ok):
|
||||
"""Extrae (base64_limpio, mimetype) de un data URL. Lanza ValueError.
|
||||
|
||||
Acepta también base64 plano (sin encabezado) asumiendo el primer mime
|
||||
permitido. Valida el tamaño ya decodificado y que la imagen se pueda leer:
|
||||
una imagen dañada se rechaza aquí con 400 en vez de reventar el guardado
|
||||
(Odoo reprocesa las imágenes al escribir un Binary como adjunto).
|
||||
"""
|
||||
if not valor or not isinstance(valor, str):
|
||||
raise ValueError('Imagen vacía')
|
||||
raw = valor.strip()
|
||||
mimetype = mimes_ok[0] if mimes_ok else 'application/octet-stream'
|
||||
b64 = raw
|
||||
if raw.startswith('data:'):
|
||||
try:
|
||||
cabecera, b64 = raw.split(',', 1)
|
||||
except ValueError:
|
||||
raise ValueError('Formato de imagen no válido')
|
||||
if ';base64' not in cabecera:
|
||||
raise ValueError('La imagen debe venir en base64')
|
||||
tipo = cabecera[5:].split(';')[0].strip().lower()
|
||||
if mimes_ok and tipo not in mimes_ok:
|
||||
raise ValueError('Tipo de imagen no permitido: %s' % (tipo or 'desconocido'))
|
||||
mimetype = tipo or mimetype
|
||||
b64 = ''.join(b64.split())
|
||||
try:
|
||||
crudo = base64.b64decode(b64, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise ValueError('La imagen no es base64 válido')
|
||||
if not crudo:
|
||||
raise ValueError('La imagen está vacía')
|
||||
if len(crudo) > max_bytes:
|
||||
raise ValueError('La imagen excede el tamaño máximo permitido')
|
||||
_validar_imagen(crudo)
|
||||
return b64, mimetype
|
||||
|
||||
|
||||
def _validar_imagen(crudo):
|
||||
"""Comprueba que los bytes son una imagen legible. Lanza ValueError si no."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(crudo)) as img:
|
||||
img.verify()
|
||||
with Image.open(io.BytesIO(crudo)) as img:
|
||||
img.load()
|
||||
except Exception:
|
||||
raise ValueError('La imagen no se pudo leer (archivo dañado o formato no soportado)')
|
||||
|
||||
|
||||
def _texto(valor, limite=MAX_TEXTO_RESP):
|
||||
return str(valor)[:limite]
|
||||
|
||||
|
||||
def limpiar_respuestas(campos, respuestas):
|
||||
"""Deja solo las claves conocidas de la plantilla, con su valor normalizado."""
|
||||
respuestas = respuestas if isinstance(respuestas, dict) else {}
|
||||
out = {}
|
||||
for campo in campos:
|
||||
clave = campo['clave']
|
||||
if clave not in respuestas:
|
||||
continue
|
||||
valor = respuestas[clave]
|
||||
tipo = campo['tipo']
|
||||
if tipo in ('texto', 'texto_largo'):
|
||||
out[clave] = _texto(valor)
|
||||
elif tipo == 'numero':
|
||||
try:
|
||||
out[clave] = float(valor)
|
||||
except (TypeError, ValueError):
|
||||
out[clave] = None
|
||||
elif tipo == 'fecha':
|
||||
out[clave] = _texto(valor, 20)
|
||||
elif tipo == 'seleccion':
|
||||
out[clave] = _texto(valor, 300)
|
||||
elif tipo == 'multi_seleccion':
|
||||
out[clave] = [_texto(v, 300) for v in valor] if isinstance(valor, list) else []
|
||||
elif tipo in ('checkbox', 'consentimiento'):
|
||||
out[clave] = bool(valor)
|
||||
elif tipo == 'escala':
|
||||
try:
|
||||
out[clave] = int(valor)
|
||||
except (TypeError, ValueError):
|
||||
out[clave] = None
|
||||
elif tipo in ('firma', 'foto', 'archivo'):
|
||||
out[clave] = bool(valor)
|
||||
else:
|
||||
out[clave] = _texto(valor)
|
||||
return out
|
||||
|
||||
|
||||
def validar_respuestas(plantilla, campos, respuestas, fotos_claves,
|
||||
tiene_firma, requiere_firma, requiere_foto):
|
||||
"""Errores de validación de un llenado (lista de mensajes claros)."""
|
||||
errores = []
|
||||
respuestas = respuestas if isinstance(respuestas, dict) else {}
|
||||
fotos_claves = set(fotos_claves or [])
|
||||
for campo in campos:
|
||||
clave = campo['clave']
|
||||
etiqueta = campo.get('etiqueta') or clave
|
||||
tipo = campo['tipo']
|
||||
obligatorio = bool(campo.get('obligatorio'))
|
||||
valor = respuestas.get(clave)
|
||||
|
||||
if tipo == 'consentimiento':
|
||||
if obligatorio and not valor:
|
||||
errores.append('Debes aceptar: %s' % etiqueta)
|
||||
continue
|
||||
if tipo == 'checkbox':
|
||||
continue
|
||||
if tipo == 'firma':
|
||||
if obligatorio and not tiene_firma:
|
||||
errores.append('Falta la firma: %s' % etiqueta)
|
||||
continue
|
||||
if tipo in ('foto', 'archivo'):
|
||||
if obligatorio and clave not in fotos_claves:
|
||||
errores.append('Falta adjuntar: %s' % etiqueta)
|
||||
continue
|
||||
if tipo == 'escala':
|
||||
if valor in (None, ''):
|
||||
if obligatorio:
|
||||
errores.append('Falta responder: %s' % etiqueta)
|
||||
else:
|
||||
try:
|
||||
n = int(valor)
|
||||
if not (1 <= n <= 10):
|
||||
errores.append('%s debe estar entre 1 y 10' % etiqueta)
|
||||
except (TypeError, ValueError):
|
||||
errores.append('%s debe ser un número del 1 al 10' % etiqueta)
|
||||
continue
|
||||
if tipo == 'numero':
|
||||
if valor in (None, ''):
|
||||
if obligatorio:
|
||||
errores.append('Falta responder: %s' % etiqueta)
|
||||
else:
|
||||
try:
|
||||
float(valor)
|
||||
except (TypeError, ValueError):
|
||||
errores.append('%s debe ser un número' % etiqueta)
|
||||
continue
|
||||
if tipo == 'multi_seleccion':
|
||||
opciones = campo.get('opciones') or []
|
||||
if valor in (None, '', []):
|
||||
if obligatorio:
|
||||
errores.append('Selecciona al menos una opción en: %s' % etiqueta)
|
||||
elif not isinstance(valor, list):
|
||||
errores.append('%s debe ser una lista de opciones' % etiqueta)
|
||||
else:
|
||||
invalidas = [v for v in valor if v not in opciones]
|
||||
if invalidas:
|
||||
errores.append('%s tiene opciones no válidas' % etiqueta)
|
||||
continue
|
||||
if tipo == 'seleccion':
|
||||
opciones = campo.get('opciones') or []
|
||||
if valor in (None, ''):
|
||||
if obligatorio:
|
||||
errores.append('Falta elegir: %s' % etiqueta)
|
||||
elif valor not in opciones:
|
||||
errores.append('%s tiene una opción no válida' % etiqueta)
|
||||
continue
|
||||
# texto, texto_largo, fecha
|
||||
if obligatorio and (valor is None or str(valor).strip() == ''):
|
||||
errores.append('Falta responder: %s' % etiqueta)
|
||||
|
||||
if requiere_firma and not tiene_firma:
|
||||
errores.append('Este formulario requiere firma')
|
||||
if requiere_foto and not fotos_claves:
|
||||
errores.append('Este formulario requiere al menos una foto')
|
||||
return errores
|
||||
|
||||
|
||||
def plantilla_dict(plantilla, public=False):
|
||||
data = {
|
||||
'id': plantilla.id,
|
||||
'name': plantilla.name,
|
||||
'tipo': plantilla.tipo,
|
||||
'tipo_label': dict(plantilla._fields['tipo'].selection).get(plantilla.tipo),
|
||||
'activo': plantilla.activo,
|
||||
'version': plantilla.version,
|
||||
'descripcion': plantilla.descripcion or '',
|
||||
'campos': plantilla.campos_lista(),
|
||||
'texto_legal': plantilla.texto_legal or '',
|
||||
'requiere_firma': plantilla.requiere_firma,
|
||||
'requiere_foto': plantilla.requiere_foto,
|
||||
'duracion_estimada_min': plantilla.duracion_estimada_min,
|
||||
'secuencia': plantilla.secuencia,
|
||||
'respuestas_count': plantilla.respuestas_count,
|
||||
}
|
||||
if not public:
|
||||
data['sucursales'] = [{'id': s.id, 'name': s.name} for s in plantilla.sucursales]
|
||||
data['servicios'] = [{'id': s.id, 'name': s.name} for s in plantilla.servicios]
|
||||
return data
|
||||
|
||||
|
||||
def adjunto_dict(adj):
|
||||
return {
|
||||
'id': adj.id,
|
||||
'campo_clave': adj.campo_clave or '',
|
||||
'kind': adj.kind or 'otro',
|
||||
'kind_label': dict(adj._fields['kind'].selection).get(adj.kind),
|
||||
'name': adj.name or '',
|
||||
'mimetype': adj.mimetype or '',
|
||||
'notes': adj.notes or '',
|
||||
'create_date': adj.create_date.strftime('%Y-%m-%d %H:%M') if adj.create_date else None,
|
||||
'url': '/skeen/frontend/v1/formularios/adjuntos/%s/file' % adj.id,
|
||||
}
|
||||
|
||||
|
||||
def respuesta_dict(respuesta, detalle=False):
|
||||
data = {
|
||||
'id': respuesta.id,
|
||||
'name': respuesta.name,
|
||||
'plantilla_id': respuesta.plantilla_id.id,
|
||||
'plantilla': respuesta.plantilla_id.name,
|
||||
'plantilla_tipo': respuesta.plantilla_id.tipo,
|
||||
'plantilla_version': respuesta.plantilla_version,
|
||||
'partner_id': respuesta.partner_id.id,
|
||||
'patient': respuesta.partner_id.name,
|
||||
'cita_id': respuesta.cita_id.id if respuesta.cita_id else None,
|
||||
'estado': respuesta.estado,
|
||||
'estado_label': dict(respuesta._fields['estado'].selection).get(respuesta.estado),
|
||||
'canal_envio': respuesta.canal_envio,
|
||||
'creado_por_empleado': respuesta.creado_por_empleado,
|
||||
'tiene_firma': bool(respuesta.firma_imagen),
|
||||
'firma_nombre': respuesta.firma_nombre or '',
|
||||
'enviado_at': respuesta.enviado_at.strftime('%Y-%m-%d %H:%M') if respuesta.enviado_at else None,
|
||||
'llenado_at': respuesta.llenado_at.strftime('%Y-%m-%d %H:%M') if respuesta.llenado_at else None,
|
||||
'create_date': respuesta.create_date.strftime('%Y-%m-%d %H:%M') if respuesta.create_date else None,
|
||||
'fotos_count': len(respuesta.foto_ids),
|
||||
}
|
||||
if detalle:
|
||||
try:
|
||||
data['respuestas'] = json.loads(respuesta.respuestas or '{}')
|
||||
except (ValueError, TypeError):
|
||||
data['respuestas'] = {}
|
||||
data['campos'] = respuesta.plantilla_id.campos_lista()
|
||||
data['texto_legal'] = respuesta.plantilla_id.texto_legal or ''
|
||||
data['notas'] = respuesta.notas or ''
|
||||
data['firma_url'] = ('/skeen/frontend/v1/formularios/respuestas/%s/firma' % respuesta.id
|
||||
if respuesta.firma_imagen else None)
|
||||
data['fotos'] = [adjunto_dict(a) for a in respuesta.foto_ids]
|
||||
return data
|
||||
496
odoo-addons/skeen_formularios/controllers/formularios.py
Normal file
496
odoo-addons/skeen_formularios/controllers/formularios.py
Normal file
@@ -0,0 +1,496 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API autenticada de formularios digitales (panel de la clínica).
|
||||
|
||||
Rutas bajo `/skeen/frontend/v1/formularios/*`. Mismo guard doble que el resto
|
||||
del frontend: `@require_role` para el rango del rol y `@require_perm` para la
|
||||
acción concreta del catálogo (`formularios.ver|crear|editar|enviar`).
|
||||
|
||||
Los binarios (firma y fotos) se sirven por rutas propias que aceptan el token
|
||||
de sesión por header o por query string, para poder usarlos en `<img src>`.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.skeen_whatsapp.controllers.auth import (
|
||||
_json, current_user, require_role, require_perm,
|
||||
verify_token as verify_session_token,
|
||||
)
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones, url_publica
|
||||
|
||||
from .comun import (
|
||||
firmar_token, plantilla_dict, respuesta_dict, adjunto_dict,
|
||||
limpiar_respuestas, validar_respuestas,
|
||||
parse_data_url, MAX_FOTO_BYTES, MAX_FIRMA_BYTES, MIMES_FOTO,
|
||||
)
|
||||
from ..models.formulario_plantilla import normalizar_campos
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/formularios'
|
||||
|
||||
|
||||
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 _enlace_publico(env, token):
|
||||
base = url_publica(env, '')
|
||||
ruta = '/formulario/%s' % token
|
||||
return (base + ruta) if base else ruta
|
||||
|
||||
|
||||
def _kind_de_campo(plantilla, clave):
|
||||
for campo in plantilla.campos_lista():
|
||||
if campo.get('clave') == clave:
|
||||
return campo.get('momento') or 'otro'
|
||||
return 'otro'
|
||||
|
||||
|
||||
class SkeenFormulariosController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plantillas
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/plantillas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('formularios.ver')
|
||||
def listar_plantillas(self, **kw):
|
||||
try:
|
||||
incluir_inactivas = str(kw.get('include_inactive') or '') in ('1', 'true', 'True')
|
||||
dominio = [] if incluir_inactivas else [('activo', '=', True)]
|
||||
if kw.get('tipo'):
|
||||
dominio.append(('tipo', '=', kw['tipo']))
|
||||
plantillas = request.env['skeen.formulario.plantilla'].sudo().search(
|
||||
dominio, order='secuencia, name, id')
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'plantillas': [plantilla_dict(p) for p in plantillas],
|
||||
'total': len(plantillas),
|
||||
})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/plantillas/<int:plantilla_id>', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('formularios.ver')
|
||||
def get_plantilla(self, plantilla_id, **kw):
|
||||
plantilla = request.env['skeen.formulario.plantilla'].sudo().browse(plantilla_id)
|
||||
if not plantilla.exists():
|
||||
return _error('Plantilla no encontrada', 404)
|
||||
return _json({'status': 'success', 'plantilla': plantilla_dict(plantilla)})
|
||||
|
||||
@http.route(PREFIX + '/plantillas', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('formularios.crear')
|
||||
def crear_plantilla(self, **kw):
|
||||
data = _body()
|
||||
try:
|
||||
campos = normalizar_campos(data.get('campos') or [])
|
||||
vals = self._vals_plantilla(data, campos, crear=True)
|
||||
plantilla = request.env['skeen.formulario.plantilla'].sudo().create(vals)
|
||||
self._set_m2m(plantilla, data)
|
||||
return _json({'status': 'success', 'plantilla': plantilla_dict(plantilla)}, 201)
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/plantillas/<int:plantilla_id>', type='http', auth='none',
|
||||
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('formularios.editar')
|
||||
def actualizar_plantilla(self, plantilla_id, **kw):
|
||||
plantilla = request.env['skeen.formulario.plantilla'].sudo().browse(plantilla_id)
|
||||
if not plantilla.exists():
|
||||
return _error('Plantilla no encontrada', 404)
|
||||
data = _body()
|
||||
try:
|
||||
campos = None
|
||||
if 'campos' in data:
|
||||
campos = normalizar_campos(data.get('campos') or [])
|
||||
vals = self._vals_plantilla(data, campos, crear=False)
|
||||
# Si la plantilla ya se usó, cualquier cambio de definición abre una
|
||||
# versión nueva: las respuestas viejas conservan su snapshot.
|
||||
definicion = {'campos', 'texto_legal', 'requiere_firma', 'requiere_foto',
|
||||
'name', 'tipo', 'descripcion'}
|
||||
if plantilla.tiene_respuestas() and (definicion & set(data.keys())):
|
||||
vals['version'] = plantilla.version + 1
|
||||
plantilla.write(vals)
|
||||
self._set_m2m(plantilla, data)
|
||||
return _json({'status': 'success', 'plantilla': plantilla_dict(plantilla)})
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/plantillas/<int:plantilla_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('formularios.editar')
|
||||
def borrar_plantilla(self, plantilla_id, **kw):
|
||||
plantilla = request.env['skeen.formulario.plantilla'].sudo().browse(plantilla_id)
|
||||
if not plantilla.exists():
|
||||
return _error('Plantilla no encontrada', 404)
|
||||
try:
|
||||
if plantilla.tiene_respuestas():
|
||||
# Con respuestas asociadas solo se desactiva: se conserva el
|
||||
# histórico del expediente y deja de ofrecerse para envíos.
|
||||
plantilla.write({'activo': False})
|
||||
return _json({'status': 'success', 'desactivada': True,
|
||||
'message': 'La plantilla tiene respuestas: se desactivó en lugar de borrarse.',
|
||||
'plantilla': plantilla_dict(plantilla)})
|
||||
plantilla.unlink()
|
||||
return _json({'status': 'success', 'desactivada': False,
|
||||
'message': 'Plantilla eliminada'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
def _vals_plantilla(self, data, campos, crear):
|
||||
vals = {}
|
||||
if 'name' in data or crear:
|
||||
name = str(data.get('name') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('El nombre de la plantilla es obligatorio')
|
||||
vals['name'] = name[:200]
|
||||
if 'tipo' in data or crear:
|
||||
tipo = str(data.get('tipo') or 'consentimiento').strip()
|
||||
if tipo not in dict(request.env['skeen.formulario.plantilla']._fields['tipo'].selection):
|
||||
raise ValueError('Tipo de plantilla no válido')
|
||||
vals['tipo'] = tipo
|
||||
if 'descripcion' in data:
|
||||
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
|
||||
if campos is not None:
|
||||
vals['campos'] = json.dumps(campos, ensure_ascii=False)
|
||||
if 'texto_legal' in data:
|
||||
vals['texto_legal'] = str(data.get('texto_legal') or '')
|
||||
for bandera in ('requiere_firma', 'requiere_foto', 'activo'):
|
||||
if bandera in data:
|
||||
vals[bandera] = bool(data[bandera])
|
||||
if 'duracion_estimada_min' in data:
|
||||
try:
|
||||
vals['duracion_estimada_min'] = max(0, int(data['duracion_estimada_min']))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('La duración estimada debe ser un número entero')
|
||||
if 'secuencia' in data:
|
||||
try:
|
||||
vals['secuencia'] = int(data['secuencia'])
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('La secuencia debe ser un número entero')
|
||||
return vals
|
||||
|
||||
def _set_m2m(self, plantilla, data):
|
||||
Sucursal = request.env['skeen.sucursal'].sudo()
|
||||
Servicio = request.env['skeen.servicio'].sudo()
|
||||
if 'sucursal_ids' in data:
|
||||
ids = [int(i) for i in (data.get('sucursal_ids') or []) if str(i).isdigit()]
|
||||
plantilla.sucursales = [(6, 0, Sucursal.search([('id', 'in', ids)]).ids)]
|
||||
if 'servicio_ids' in data:
|
||||
ids = [int(i) for i in (data.get('servicio_ids') or []) if str(i).isdigit()]
|
||||
plantilla.servicios = [(6, 0, Servicio.search([('id', 'in', ids)]).ids)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Respuestas
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/respuestas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('formularios.ver')
|
||||
def listar_respuestas(self, **kw):
|
||||
try:
|
||||
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
|
||||
dominio = []
|
||||
if kw.get('partner_id'):
|
||||
dominio.append(('partner_id', '=', int(kw['partner_id'])))
|
||||
if kw.get('plantilla_id'):
|
||||
dominio.append(('plantilla_id', '=', int(kw['plantilla_id'])))
|
||||
if kw.get('estado'):
|
||||
dominio.append(('estado', '=', kw['estado']))
|
||||
if kw.get('start'):
|
||||
dominio.append(('create_date', '>=', '%s 00:00:00' % kw['start']))
|
||||
if kw.get('end'):
|
||||
dominio.append(('create_date', '<=', '%s 23:59:59' % kw['end']))
|
||||
try:
|
||||
page = max(1, int(kw.get('page') or 1))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
try:
|
||||
page_size = min(200, max(1, int(kw.get('page_size') or 50)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 50
|
||||
total = Respuesta.search_count(dominio)
|
||||
offset = (page - 1) * page_size
|
||||
registros = Respuesta.search(dominio, order='create_date desc, id desc',
|
||||
limit=page_size, offset=offset)
|
||||
resumen = {}
|
||||
for estado, _label in Respuesta._fields['estado'].selection:
|
||||
resumen[estado] = Respuesta.search_count(dominio + [('estado', '=', estado)])
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'respuestas': [respuesta_dict(r) for r in registros],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size if page_size else 0,
|
||||
'resumen': resumen,
|
||||
})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/respuestas/<int:respuesta_id>', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('formularios.ver')
|
||||
def get_respuesta(self, respuesta_id, **kw):
|
||||
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
|
||||
if not respuesta.exists():
|
||||
return _error('Respuesta no encontrada', 404)
|
||||
return _json({'status': 'success', 'respuesta': respuesta_dict(respuesta, detalle=True)})
|
||||
|
||||
@http.route(PREFIX + '/fotos', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('formularios.ver')
|
||||
def listar_fotos(self, **kw):
|
||||
"""Fotos y archivos agrupables por fecha para la galería del expediente."""
|
||||
try:
|
||||
dominio = []
|
||||
if kw.get('partner_id'):
|
||||
dominio.append(('partner_id', '=', int(kw['partner_id'])))
|
||||
if kw.get('respuesta_id'):
|
||||
dominio.append(('respuesta_id', '=', int(kw['respuesta_id'])))
|
||||
adjuntos = request.env['skeen.formulario.adjunto'].sudo().search(
|
||||
dominio, order='create_date desc, id desc', limit=200)
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'total': len(adjuntos),
|
||||
'fotos': [{
|
||||
**adjunto_dict(a),
|
||||
'respuesta_id': a.respuesta_id.id,
|
||||
'respuesta_name': a.respuesta_id.name,
|
||||
'plantilla': a.respuesta_id.plantilla_id.name,
|
||||
'cita_id': a.cita_id.id if a.cita_id else None,
|
||||
} for a in adjuntos],
|
||||
})
|
||||
except Exception as e:
|
||||
return _error(str(e), 500)
|
||||
|
||||
@http.route(PREFIX + '/enviar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('formularios.enviar')
|
||||
def enviar_formulario(self, **kw):
|
||||
data = _body()
|
||||
usuario = getattr(request, 'frontend_user', None)
|
||||
try:
|
||||
Plantilla = request.env['skeen.formulario.plantilla'].sudo()
|
||||
plantilla = Plantilla.browse(int(data.get('plantilla_id') or 0))
|
||||
if not plantilla.exists() or not plantilla.activo:
|
||||
return _error('La plantilla no existe o está desactivada', 404)
|
||||
partner = request.env['res.partner'].sudo().browse(int(data.get('partner_id') or 0))
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return _error('El paciente no existe', 404)
|
||||
canal = str(data.get('canal') or 'enlace').strip().lower()
|
||||
if canal not in ('whatsapp', 'email', 'enlace'):
|
||||
return _error('Canal no válido (whatsapp, email o enlace)')
|
||||
cita = None
|
||||
if data.get('cita_id'):
|
||||
cita = request.env['skeen.cita'].sudo().browse(int(data['cita_id']))
|
||||
if not cita.exists() or cita.partner_id.id != partner.id:
|
||||
cita = None
|
||||
|
||||
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
|
||||
respuesta = Respuesta.create({
|
||||
'plantilla_id': plantilla.id,
|
||||
'plantilla_version': plantilla.version,
|
||||
'partner_id': partner.id,
|
||||
'cita_id': cita.id if cita else False,
|
||||
'estado': 'enviado',
|
||||
'enviado_at': fields.Datetime.now(),
|
||||
'canal_envio': canal,
|
||||
'respuestas': '{}',
|
||||
})
|
||||
token = firmar_token(request.env, respuesta)
|
||||
respuesta.write({'token': token})
|
||||
enlace = _enlace_publico(request.env, token)
|
||||
|
||||
notificado = self._encolar_envio(respuesta, canal, enlace)
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'respuesta': respuesta_dict(respuesta),
|
||||
'token': token,
|
||||
'enlace': enlace,
|
||||
'enviado_por': usuario.name if usuario else '',
|
||||
'notificado': notificado,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
def _encolar_envio(self, respuesta, canal, enlace):
|
||||
"""Encola el aviso por el motor de notificaciones (tipo 'formulario').
|
||||
|
||||
Con `notificaciones.activo=False` (o modo prueba) no se envía nada real;
|
||||
el canal 'enlace' no depende del motor y siempre devuelve el enlace.
|
||||
"""
|
||||
if canal not in ('whatsapp', 'email'):
|
||||
return False
|
||||
try:
|
||||
service = request.env['skeen.notificacion.service'].sudo()
|
||||
creadas = service.encolar(
|
||||
partner=respuesta.partner_id,
|
||||
cita=respuesta.cita_id or None,
|
||||
tipo='formulario',
|
||||
canal=canal,
|
||||
clave_unica='formulario:r%s' % respuesta.id,
|
||||
contexto_extra={'enlace': enlace, 'formulario': respuesta.plantilla_id.name},
|
||||
)
|
||||
cfg = get_notificaciones(request.env)
|
||||
for notif in creadas:
|
||||
service._intentar(notif, cfg)
|
||||
return bool(creadas)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception(
|
||||
'No se pudo encolar el formulario %s', respuesta.id)
|
||||
return False
|
||||
|
||||
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/llenar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('formularios.crear')
|
||||
def llenar_respuesta(self, respuesta_id, **kw):
|
||||
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
|
||||
if not respuesta.exists():
|
||||
return _error('Respuesta no encontrada', 404)
|
||||
if respuesta.estado == 'llenado':
|
||||
return _error('Esta respuesta ya fue llenada', 409)
|
||||
data = _body()
|
||||
try:
|
||||
plantilla = respuesta.plantilla_id
|
||||
campos = plantilla.campos_lista()
|
||||
fotos_claves = [a.campo_clave for a in respuesta.foto_ids if a.campo_clave]
|
||||
firma_b64 = None
|
||||
if data.get('firma_imagen'):
|
||||
firma_b64, _mime = parse_data_url(
|
||||
data['firma_imagen'], MAX_FIRMA_BYTES, ('image/png', 'image/jpeg'))
|
||||
tiene_firma = bool(firma_b64) or bool(respuesta.firma_imagen)
|
||||
errores = validar_respuestas(
|
||||
plantilla, campos, data.get('respuestas'), fotos_claves,
|
||||
tiene_firma, plantilla.requiere_firma, plantilla.requiere_foto)
|
||||
if errores:
|
||||
return _error(' '.join(errores), 400)
|
||||
|
||||
limpio = limpiar_respuestas(campos, data.get('respuestas'))
|
||||
vals = {
|
||||
'respuestas': json.dumps(limpio, ensure_ascii=False),
|
||||
'estado': 'llenado',
|
||||
'llenado_at': fields.Datetime.now(),
|
||||
'creado_por_empleado': True,
|
||||
}
|
||||
if firma_b64:
|
||||
vals['firma_imagen'] = firma_b64
|
||||
if 'firma_nombre' in data:
|
||||
vals['firma_nombre'] = str(data.get('firma_nombre') or '')[:200]
|
||||
respuesta.write(vals)
|
||||
return _json({'status': 'success', 'respuesta': respuesta_dict(respuesta, detalle=True)})
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Adjuntos (fotos y archivos)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/adjuntar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('formularios.crear')
|
||||
def adjuntar(self, **kw):
|
||||
data = _body()
|
||||
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(
|
||||
int(data.get('respuesta_id') or 0))
|
||||
if not respuesta.exists():
|
||||
return _error('Respuesta no encontrada', 404)
|
||||
try:
|
||||
if len(respuesta.foto_ids) >= 10:
|
||||
return _error('Ya hay 10 archivos adjuntos en esta respuesta')
|
||||
b64, mimetype = parse_data_url(
|
||||
data.get('file') or data.get('file_b64'), MAX_FOTO_BYTES, MIMES_FOTO)
|
||||
campo_clave = str(data.get('campo_clave') or '')[:60]
|
||||
kind = str(data.get('kind') or '').strip() or _kind_de_campo(respuesta.plantilla_id, campo_clave)
|
||||
if kind not in ('antes', 'despues', 'otro'):
|
||||
kind = 'otro'
|
||||
adj = request.env['skeen.formulario.adjunto'].sudo().create({
|
||||
'respuesta_id': respuesta.id,
|
||||
'campo_clave': campo_clave,
|
||||
'kind': kind,
|
||||
'name': str(data.get('name') or 'adjunto')[:200],
|
||||
'file': b64,
|
||||
'mimetype': mimetype or str(data.get('mimetype') or ''),
|
||||
'notes': str(data.get('notes') or '')[:200],
|
||||
})
|
||||
return _json({'status': 'success', 'adjunto': adjunto_dict(adj)}, 201)
|
||||
except ValueError as e:
|
||||
return _error(str(e), 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Binarios (aceptan token de sesión por header o query string)
|
||||
# ------------------------------------------------------------------
|
||||
def _autorizado_binario(self, kw):
|
||||
if current_user(request.env):
|
||||
return True
|
||||
payload = verify_session_token(request.env, kw.get('token') or '')
|
||||
if not payload:
|
||||
return False
|
||||
user = request.env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
|
||||
return bool(user.exists() and user.active)
|
||||
|
||||
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/firma', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
def get_firma(self, respuesta_id, **kw):
|
||||
if not self._autorizado_binario(kw):
|
||||
return _error('No autenticado', 401)
|
||||
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
|
||||
if not respuesta.exists() or not respuesta.firma_imagen:
|
||||
return _error('Firma no encontrada', 404)
|
||||
return Response(
|
||||
base64.b64decode(respuesta.firma_imagen), status=200, mimetype='image/png',
|
||||
headers={'Content-Disposition': 'inline; filename="firma.png"',
|
||||
'Access-Control-Allow-Origin': '*'},
|
||||
)
|
||||
|
||||
@http.route(PREFIX + '/adjuntos/<int:adjunto_id>/file', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
def get_adjunto_file(self, adjunto_id, **kw):
|
||||
if not self._autorizado_binario(kw):
|
||||
return _error('No autenticado', 401)
|
||||
adj = request.env['skeen.formulario.adjunto'].sudo().browse(adjunto_id)
|
||||
if not adj.exists() or not adj.file:
|
||||
return _error('Adjunto no encontrado', 404)
|
||||
return Response(
|
||||
base64.b64decode(adj.file), status=200,
|
||||
mimetype=adj.mimetype or 'application/octet-stream',
|
||||
headers={
|
||||
'Content-Disposition': 'inline; filename="%s"' % (adj.name or 'adjunto'),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
)
|
||||
273
odoo-addons/skeen_formularios/controllers/public_formulario.py
Normal file
273
odoo-addons/skeen_formularios/controllers/public_formulario.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Portal público de formularios (el paciente llena desde su celular).
|
||||
|
||||
Endpoints anónimos bajo `/skeen/frontend/v1/public/formulario`. Siguen el mismo
|
||||
patrón que el portal de reservas: rate limiting por IP, honeypot en el llenado,
|
||||
token HMAC como única credencial y 404 cuando el enlace no aplica. No se expone
|
||||
ningún dato interno: solo el nombre de pila del paciente, el nombre de la
|
||||
plantilla, sus campos públicos, el texto legal y el brand/glosario vigentes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
|
||||
from odoo.addons.skeen_citas.clinic_settings import brand_dict, get_settings
|
||||
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||
|
||||
from .comun import (
|
||||
respuesta_de_token, validar_respuestas, limpiar_respuestas,
|
||||
parse_data_url, MAX_FOTO_BYTES, MAX_FIRMA_BYTES, MAX_FOTOS, MIMES_FOTO,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/public/formulario'
|
||||
|
||||
# Buckets propios dentro del rate limiter compartido del portal público.
|
||||
_pb.RATE_LIMITS.update({'form_config': (60, 60), 'form_fill': (10, 60)})
|
||||
|
||||
# Reexport del helper JSON/CORS del portal de reservas.
|
||||
_json = _pb._json
|
||||
_client_ip = _pb._client_ip
|
||||
_rate_limited = _pb._rate_limited
|
||||
|
||||
|
||||
def _404():
|
||||
return _json({'status': 'error', 'message': 'Enlace no válido o expirado'}, 404)
|
||||
|
||||
|
||||
def _brand(env):
|
||||
"""Brand vigente con defaults, para el portal sin sesión."""
|
||||
guardado = brand_dict(env)
|
||||
settings = get_settings(env)
|
||||
out = {
|
||||
'nombre': settings.get('nombre') or 'Clínica',
|
||||
'logo_claro': '/brand/logo-blanco.svg',
|
||||
'logo_oscuro': '/brand/logo-negro.svg',
|
||||
'colores': {'primario': '', 'acento': ''},
|
||||
}
|
||||
if isinstance(guardado, dict):
|
||||
if guardado.get('nombre'):
|
||||
out['nombre'] = str(guardado['nombre'])
|
||||
for k in ('logo_claro', 'logo_oscuro'):
|
||||
if guardado.get(k):
|
||||
out[k] = str(guardado[k])
|
||||
colores = guardado.get('colores')
|
||||
if isinstance(colores, dict):
|
||||
for c in ('primario', 'acento'):
|
||||
if colores.get(c):
|
||||
out['colores'][c] = str(colores[c])
|
||||
return out
|
||||
|
||||
|
||||
def _campos_publicos(campos):
|
||||
out = []
|
||||
for campo in campos:
|
||||
item = {
|
||||
'clave': campo.get('clave'),
|
||||
'etiqueta': campo.get('etiqueta') or campo.get('clave'),
|
||||
'tipo': campo.get('tipo'),
|
||||
'obligatorio': bool(campo.get('obligatorio')),
|
||||
'ayuda': campo.get('ayuda') or '',
|
||||
'opciones': campo.get('opciones') or [],
|
||||
}
|
||||
if campo.get('tipo') in ('foto', 'archivo'):
|
||||
item['momento'] = campo.get('momento') or 'otro'
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _primer_nombre(partner):
|
||||
nombre = (partner.name or '').strip()
|
||||
return nombre.split(' ')[0] if nombre else ''
|
||||
|
||||
|
||||
class SkeenPublicFormularioController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Configuración pública del formulario
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/config', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def form_config(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('form_config'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
respuesta = respuesta_de_token(request.env, kw.get('token'))
|
||||
if not respuesta:
|
||||
return _404()
|
||||
try:
|
||||
plantilla = respuesta.plantilla_id
|
||||
_pack, terms = resolver_glosario(request.env)
|
||||
cita = respuesta.cita_id
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'brand': _brand(request.env),
|
||||
'terms': terms,
|
||||
'paciente': {'nombre': _primer_nombre(respuesta.partner_id)},
|
||||
'cita': {
|
||||
'fecha': cita.date.strftime('%Y-%m-%d') if cita and cita.date else None,
|
||||
'hora': cita.time_str if cita else None,
|
||||
'servicio': cita.servicio_id.name if cita and cita.servicio_id else None,
|
||||
} if cita else None,
|
||||
'estado': respuesta.estado,
|
||||
'ya_llenado': respuesta.estado == 'llenado',
|
||||
'plantilla': {
|
||||
'nombre': plantilla.name,
|
||||
'tipo': plantilla.tipo,
|
||||
'tipo_label': dict(plantilla._fields['tipo'].selection).get(plantilla.tipo),
|
||||
'descripcion': plantilla.descripcion or '',
|
||||
'texto_legal': plantilla.texto_legal or '',
|
||||
'requiere_firma': plantilla.requiere_firma,
|
||||
'requiere_foto': plantilla.requiere_foto,
|
||||
'duracion_estimada_min': plantilla.duracion_estimada_min,
|
||||
'campos': _campos_publicos(plantilla.campos_lista()),
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception('Error al servir la config del formulario público')
|
||||
return _json({'status': 'error',
|
||||
'message': 'No se pudo cargar el formulario. Intenta de nuevo.'}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Estado de la respuesta (¿ya se llenó?)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX, type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def form_estado(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('form_config'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
respuesta = respuesta_de_token(request.env, kw.get('token'))
|
||||
if not respuesta:
|
||||
return _404()
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'estado': respuesta.estado,
|
||||
'ya_llenado': respuesta.estado == 'llenado',
|
||||
'paciente': {'nombre': _primer_nombre(respuesta.partner_id)},
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Llenado del formulario
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/llenar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def form_llenar(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('form_fill'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||
except Exception:
|
||||
data = {}
|
||||
if str(data.get('hp') or '').strip():
|
||||
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
|
||||
|
||||
respuesta = respuesta_de_token(request.env, data.get('token'))
|
||||
if not respuesta:
|
||||
return _404()
|
||||
if respuesta.estado == 'llenado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este formulario ya fue llenado.'}, 409)
|
||||
if respuesta.estado == 'cancelado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este formulario ya no está disponible.'}, 409)
|
||||
|
||||
try:
|
||||
plantilla = respuesta.plantilla_id
|
||||
campos = plantilla.campos_lista()
|
||||
|
||||
firma_b64 = None
|
||||
if data.get('firma_imagen'):
|
||||
firma_b64, _mime = parse_data_url(
|
||||
data['firma_imagen'], MAX_FIRMA_BYTES, ('image/png', 'image/jpeg'))
|
||||
|
||||
fotos = data.get('fotos') or []
|
||||
if not isinstance(fotos, list):
|
||||
return _json({'status': 'error', 'message': 'Las fotos deben ser una lista'}, 400)
|
||||
if len(fotos) > MAX_FOTOS:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Máximo %d fotos por formulario' % MAX_FOTOS}, 400)
|
||||
fotos_listas = []
|
||||
for item in fotos:
|
||||
if isinstance(item, str):
|
||||
b64, mime = parse_data_url(item, MAX_FOTO_BYTES, MIMES_FOTO)
|
||||
fotos_listas.append({'file': b64, 'mimetype': mime,
|
||||
'campo_clave': '', 'kind': 'otro',
|
||||
'name': 'foto.jpg'})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
return _json({'status': 'error', 'message': 'Foto no válida'}, 400)
|
||||
b64, mime = parse_data_url(
|
||||
item.get('file') or item.get('data_url'), MAX_FOTO_BYTES, MIMES_FOTO)
|
||||
campo_clave = str(item.get('campo_clave') or '')[:60]
|
||||
fotos_listas.append({
|
||||
'file': b64,
|
||||
'mimetype': mime,
|
||||
'campo_clave': campo_clave,
|
||||
'kind': _kind_de_campo(plantilla, campo_clave, item.get('kind')),
|
||||
'name': str(item.get('name') or 'foto.jpg')[:200],
|
||||
})
|
||||
|
||||
fotos_claves = [f['campo_clave'] for f in fotos_listas if f['campo_clave']]
|
||||
errores = validar_respuestas(
|
||||
plantilla, campos, data.get('respuestas'), fotos_claves,
|
||||
bool(firma_b64), plantilla.requiere_firma, plantilla.requiere_foto)
|
||||
if errores:
|
||||
return _json({'status': 'error', 'message': ' '.join(errores),
|
||||
'errores': errores}, 400)
|
||||
|
||||
limpio = limpiar_respuestas(campos, data.get('respuestas'))
|
||||
vals = {
|
||||
'respuestas': json.dumps(limpio, ensure_ascii=False),
|
||||
'estado': 'llenado',
|
||||
'llenado_at': fields.Datetime.now(),
|
||||
'ip_llenado': _client_ip(),
|
||||
'creado_por_empleado': False,
|
||||
}
|
||||
if firma_b64:
|
||||
vals['firma_imagen'] = firma_b64
|
||||
if data.get('firma_nombre'):
|
||||
vals['firma_nombre'] = str(data['firma_nombre'])[:200]
|
||||
respuesta.write(vals)
|
||||
|
||||
Adjunto = request.env['skeen.formulario.adjunto'].sudo()
|
||||
for foto in fotos_listas:
|
||||
Adjunto.create({
|
||||
'respuesta_id': respuesta.id,
|
||||
'campo_clave': foto['campo_clave'],
|
||||
'kind': foto['kind'],
|
||||
'name': foto['name'],
|
||||
'file': foto['file'],
|
||||
'mimetype': foto['mimetype'],
|
||||
})
|
||||
return _json({'status': 'success',
|
||||
'message': '¡Gracias! Tu formulario quedó registrado.'})
|
||||
except ValueError as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception:
|
||||
request.env.cr.rollback()
|
||||
_logger.exception('Error al guardar el llenado del formulario público')
|
||||
return _json({'status': 'error',
|
||||
'message': 'No se pudo guardar el formulario. Intenta de nuevo.'}, 500)
|
||||
|
||||
|
||||
def _kind_de_campo(plantilla, clave, sugerido=None):
|
||||
if sugerido in ('antes', 'despues', 'otro'):
|
||||
return sugerido
|
||||
for campo in plantilla.campos_lista():
|
||||
if campo.get('clave') == clave and campo.get('momento') in ('antes', 'despues', 'otro'):
|
||||
return campo['momento']
|
||||
return 'otro'
|
||||
14
odoo-addons/skeen_formularios/data/sequences.xml
Normal file
14
odoo-addons/skeen_formularios/data/sequences.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Folio de las respuestas de formulario (noupdate: no reiniciar en upgrades) -->
|
||||
<data noupdate="1">
|
||||
<record id="seq_skeen_formulario_respuesta" model="ir.sequence">
|
||||
<field name="name">Respuesta de Formulario SKEEN</field>
|
||||
<field name="code">skeen.formulario.respuesta</field>
|
||||
<field name="prefix">FORM-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
5
odoo-addons/skeen_formularios/models/__init__.py
Normal file
5
odoo-addons/skeen_formularios/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import formulario_plantilla
|
||||
from . import formulario_respuesta
|
||||
from . import formulario_adjunto
|
||||
32
odoo-addons/skeen_formularios/models/formulario_adjunto.py
Normal file
32
odoo-addons/skeen_formularios/models/formulario_adjunto.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Foto o archivo asociado a una respuesta de formulario.
|
||||
|
||||
Se guarda el binario con `attachment=True` (igual que `skeen.visita.adjunto`)
|
||||
y se replica el paciente y la fecha de la respuesta para poder armar la
|
||||
galería antes/después del expediente sin recorrer las respuestas.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SkeenFormularioAdjunto(models.Model):
|
||||
_name = 'skeen.formulario.adjunto'
|
||||
_description = 'Adjunto de respuesta de formulario'
|
||||
_order = 'create_date asc, id asc'
|
||||
|
||||
respuesta_id = fields.Many2one('skeen.formulario.respuesta', string='Respuesta',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente',
|
||||
related='respuesta_id.partner_id', store=True, index=True)
|
||||
cita_id = fields.Many2one('skeen.cita', string='Cita',
|
||||
related='respuesta_id.cita_id', store=True)
|
||||
campo_clave = fields.Char(string='Campo del formulario')
|
||||
kind = fields.Selection([
|
||||
('antes', 'Antes'),
|
||||
('despues', 'Después'),
|
||||
('otro', 'Otro'),
|
||||
], string='Momento', default='otro', required=True)
|
||||
name = fields.Char(string='Archivo')
|
||||
file = fields.Binary(string='Contenido', attachment=True, required=True)
|
||||
mimetype = fields.Char(string='Tipo MIME')
|
||||
notes = fields.Char(string='Notas')
|
||||
139
odoo-addons/skeen_formularios/models/formulario_plantilla.py
Normal file
139
odoo-addons/skeen_formularios/models/formulario_plantilla.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Plantillas de formularios digitales (consentimientos, encuestas, etc.).
|
||||
|
||||
`campos` guarda un JSON con la lista ORDENADA de campos. Se usa Text en lugar
|
||||
de Json para poder validar/limpiar el contenido en el controlador y mantenerlo
|
||||
legible en la BD y en respaldos. Formato de cada campo:
|
||||
|
||||
{clave, etiqueta, tipo, obligatorio, ayuda, opciones[]}
|
||||
|
||||
`momento` es opcional y solo aplica a los campos de tipo `foto`/`archivo`
|
||||
(antes | despues | otro), para armar la galería antes/después del expediente.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
TIPOS_CAMPO = (
|
||||
'texto', 'texto_largo', 'numero', 'fecha', 'seleccion',
|
||||
'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto',
|
||||
'archivo', 'consentimiento',
|
||||
)
|
||||
TIPOS_PLANTILLA = (
|
||||
('consentimiento', 'Consentimiento informado'),
|
||||
('expediente', 'Nota de expediente'),
|
||||
('encuesta', 'Encuesta'),
|
||||
('autorizacion', 'Autorización'),
|
||||
)
|
||||
MOMENTOS = ('antes', 'despues', 'otro')
|
||||
MAX_CAMPOS = 60
|
||||
MAX_OPCIONES = 40
|
||||
MAX_TEXTO = 300
|
||||
|
||||
|
||||
class SkeenFormularioPlantilla(models.Model):
|
||||
_name = 'skeen.formulario.plantilla'
|
||||
_description = 'Plantilla de formulario digital'
|
||||
_order = 'secuencia, name, id'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
tipo = fields.Selection(TIPOS_PLANTILLA, string='Tipo', required=True,
|
||||
default='consentimiento', index=True)
|
||||
activo = fields.Boolean(string='Activo', default=True, index=True)
|
||||
version = fields.Integer(string='Versión', default=1, readonly=True, copy=False)
|
||||
descripcion = fields.Text(string='Descripción')
|
||||
campos = fields.Text(string='Campos (JSON)', default='[]')
|
||||
texto_legal = fields.Text(string='Texto legal / consentimiento')
|
||||
requiere_firma = fields.Boolean(string='Requiere firma', default=False)
|
||||
requiere_foto = fields.Boolean(string='Requiere foto', default=False)
|
||||
duracion_estimada_min = fields.Integer(string='Duración estimada (min)', default=5)
|
||||
secuencia = fields.Integer(string='Secuencia', default=10)
|
||||
|
||||
sucursales = fields.Many2many(
|
||||
'skeen.sucursal', 'skeen_formulario_plantilla_sucursal_rel',
|
||||
'plantilla_id', 'sucursal_id', string='Sucursales',
|
||||
help='Vacío = todas las sucursales.')
|
||||
servicios = fields.Many2many(
|
||||
'skeen.servicio', 'skeen_formulario_plantilla_servicio_rel',
|
||||
'plantilla_id', 'servicio_id', string='Servicios',
|
||||
help='Sugerir esta plantilla cuando el paciente tenga uno de estos servicios.')
|
||||
|
||||
respuesta_ids = fields.One2many('skeen.formulario.respuesta', 'plantilla_id',
|
||||
string='Respuestas')
|
||||
respuestas_count = fields.Integer(string='# Respuestas', compute='_compute_respuestas_count')
|
||||
|
||||
@api.depends('respuesta_ids')
|
||||
def _compute_respuestas_count(self):
|
||||
for rec in self:
|
||||
rec.respuestas_count = len(rec.respuesta_ids)
|
||||
|
||||
def campos_lista(self):
|
||||
"""Lista de campos normalizada (nunca lanza: JSON inválido -> [])."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
data = json.loads(self.campos or '[]')
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def tiene_respuestas(self):
|
||||
self.ensure_one()
|
||||
return bool(self.respuesta_ids)
|
||||
|
||||
|
||||
def normalizar_campos(raw):
|
||||
"""Valida y limpia la lista de campos de un payload. Lanza ValueError.
|
||||
|
||||
Devuelve siempre una lista nueva (JSON-serializable). Se descartan claves
|
||||
repetidas, tipos desconocidos y opciones fuera de rango.
|
||||
"""
|
||||
if raw is None:
|
||||
raise ValueError('Falta la lista de campos')
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError('Los campos deben ser una lista')
|
||||
if len(raw) > MAX_CAMPOS:
|
||||
raise ValueError('Demasiados campos (máximo %d)' % MAX_CAMPOS)
|
||||
out = []
|
||||
claves = set()
|
||||
for i, campo in enumerate(raw):
|
||||
if not isinstance(campo, dict):
|
||||
raise ValueError('Cada campo debe ser un objeto')
|
||||
clave = str(campo.get('clave') or '').strip()
|
||||
if not clave:
|
||||
raise ValueError('El campo #%d no tiene clave' % (i + 1))
|
||||
if len(clave) > 60:
|
||||
raise ValueError('La clave del campo #%d es demasiado larga' % (i + 1))
|
||||
if clave in claves:
|
||||
raise ValueError('La clave "%s" está repetida' % clave)
|
||||
claves.add(clave)
|
||||
tipo = str(campo.get('tipo') or '').strip()
|
||||
if tipo not in TIPOS_CAMPO:
|
||||
raise ValueError('Tipo de campo no válido: %s' % (tipo or '(vacío)'))
|
||||
etiqueta = str(campo.get('etiqueta') or '').strip() or clave
|
||||
opciones = []
|
||||
if tipo in ('seleccion', 'multi_seleccion'):
|
||||
raw_op = campo.get('opciones') or []
|
||||
if not isinstance(raw_op, list) or not raw_op:
|
||||
raise ValueError('El campo "%s" necesita al menos una opción' % etiqueta)
|
||||
if len(raw_op) > MAX_OPCIONES:
|
||||
raise ValueError('El campo "%s" tiene demasiadas opciones' % etiqueta)
|
||||
for o in raw_op:
|
||||
texto = str(o or '').strip()[:MAX_TEXTO]
|
||||
if texto and texto not in opciones:
|
||||
opciones.append(texto)
|
||||
if not opciones:
|
||||
raise ValueError('El campo "%s" necesita al menos una opción' % etiqueta)
|
||||
clean = {
|
||||
'clave': clave,
|
||||
'etiqueta': etiqueta[:MAX_TEXTO],
|
||||
'tipo': tipo,
|
||||
'obligatorio': bool(campo.get('obligatorio')),
|
||||
'ayuda': str(campo.get('ayuda') or '').strip()[:MAX_TEXTO],
|
||||
'opciones': opciones,
|
||||
}
|
||||
if tipo in ('foto', 'archivo'):
|
||||
momento = str(campo.get('momento') or 'otro').strip()
|
||||
clean['momento'] = momento if momento in MOMENTOS else 'otro'
|
||||
out.append(clean)
|
||||
return out
|
||||
68
odoo-addons/skeen_formularios/models/formulario_respuesta.py
Normal file
68
odoo-addons/skeen_formularios/models/formulario_respuesta.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Respuesta (instancia llenada) de una plantilla de formulario.
|
||||
|
||||
Guarda el snapshot de versión de la plantilla (`plantilla_version`), las
|
||||
respuestas como JSON en `respuestas`, la firma como PNG en `firma_imagen`
|
||||
(Binary) y las fotos/archivos en `skeen.formulario.adjunto`.
|
||||
|
||||
El `token` es un HMAC que viaja en el enlace público; una vez llenada la
|
||||
respuesta el token deja de ser válido para reenvíos.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SkeenFormularioRespuesta(models.Model):
|
||||
_name = 'skeen.formulario.respuesta'
|
||||
_description = 'Respuesta de formulario'
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Folio', readonly=True, copy=False)
|
||||
plantilla_id = fields.Many2one('skeen.formulario.plantilla', string='Plantilla',
|
||||
required=True, ondelete='restrict', index=True)
|
||||
plantilla_version = fields.Integer(string='Versión de la plantilla', default=1)
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||
ondelete='cascade', index=True,
|
||||
domain=[('is_patient', '=', True)])
|
||||
cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null', index=True)
|
||||
respuestas = fields.Text(string='Respuestas (JSON)', default='{}')
|
||||
firma_imagen = fields.Binary(string='Firma (PNG)', attachment=True)
|
||||
firma_nombre = fields.Char(string='Firmado por')
|
||||
foto_ids = fields.One2many('skeen.formulario.adjunto', 'respuesta_id', string='Fotos y archivos')
|
||||
|
||||
estado = fields.Selection([
|
||||
('borrador', 'Borrador'),
|
||||
('enviado', 'Enviado'),
|
||||
('llenado', 'Llenado'),
|
||||
('cancelado', 'Cancelado'),
|
||||
], string='Estado', default='borrador', required=True, index=True)
|
||||
|
||||
enviado_at = fields.Datetime(string='Enviado el')
|
||||
llenado_at = fields.Datetime(string='Llenado el')
|
||||
token = fields.Char(string='Token', index=True, copy=False)
|
||||
canal_envio = fields.Selection([
|
||||
('whatsapp', 'WhatsApp'),
|
||||
('email', 'Correo'),
|
||||
('enlace', 'Enlace'),
|
||||
('manual', 'Manual'),
|
||||
], string='Canal de envío', default='enlace')
|
||||
ip_llenado = fields.Char(string='IP de llenado')
|
||||
creado_por_empleado = fields.Boolean(string='Llenado por el personal', default=False)
|
||||
notas = fields.Text(string='Notas')
|
||||
|
||||
_sql_constraints = [
|
||||
('token_uniq', 'unique(token)',
|
||||
'El token del formulario debe ser único.'),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = (self.env['ir.sequence'].next_by_code('skeen.formulario.respuesta')
|
||||
or 'FORM-00001')
|
||||
return super().create(vals_list)
|
||||
|
||||
def tiene_firma(self):
|
||||
self.ensure_one()
|
||||
return bool(self.firma_imagen)
|
||||
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_formulario_plantilla,Acceso a Plantillas de Formulario SKEEN,model_skeen_formulario_plantilla,base.group_user,1,1,1,1
|
||||
access_skeen_formulario_respuesta,Acceso a Respuestas de Formulario SKEEN,model_skeen_formulario_respuesta,base.group_user,1,1,1,1
|
||||
access_skeen_formulario_adjunto,Acceso a Adjuntos de Formulario SKEEN,model_skeen_formulario_adjunto,base.group_user,1,1,1,1
|
||||
|
@@ -43,6 +43,7 @@ TIPOS = [
|
||||
('cumpleanos', 'Cumpleaños'),
|
||||
('post_visita', 'Post-visita'),
|
||||
('recuperacion', 'Recuperación'),
|
||||
('formulario', 'Formulario'),
|
||||
('manual', 'Manual'),
|
||||
]
|
||||
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
||||
@@ -64,6 +65,8 @@ PLANTILLA_POR_TIPO = {
|
||||
# bloque `notificaciones` solo define cuatro plantillas (recordatorio,
|
||||
# confirmación, cumpleaños y post-visita).
|
||||
'recuperacion': 'plantilla_recordatorio',
|
||||
# El aviso de un formulario digital usa su propia plantilla con {enlace}.
|
||||
'formulario': 'plantilla_formulario',
|
||||
'manual': 'plantilla_recordatorio',
|
||||
}
|
||||
|
||||
@@ -129,12 +132,13 @@ class SkeenNotificacionService(models.AbstractModel):
|
||||
return clinic_get_notificaciones(self.env)
|
||||
|
||||
@api.model
|
||||
def render(self, plantilla, cita=None, partner=None, cfg=None):
|
||||
def render(self, plantilla, cita=None, partner=None, cfg=None, extra=None):
|
||||
"""Sustituye las variables de una plantilla con datos reales.
|
||||
|
||||
Los nombres de los términos (`{cita}`) salen del glosario vigente; las
|
||||
demás variables son los datos de la cita y del paciente. Una variable sin
|
||||
dato queda como cadena vacía.
|
||||
dato queda como cadena vacía. `extra` añade variables puntuales (p. ej.
|
||||
`{enlace}` y `{formulario}` para el aviso de un formulario digital).
|
||||
"""
|
||||
cfg = cfg or self._cfg()
|
||||
settings = clinic_get_settings(self.env)
|
||||
@@ -166,6 +170,10 @@ class SkeenNotificacionService(models.AbstractModel):
|
||||
contexto['fecha'] = fields.Date.today().strftime('%d/%m/%Y')
|
||||
contexto['sucursal'] = self._sucursal_default()
|
||||
|
||||
if extra:
|
||||
for clave, valor in extra.items():
|
||||
contexto[clave] = '' if valor is None else str(valor)
|
||||
|
||||
texto = str(plantilla or '')
|
||||
for clave, valor in contexto.items():
|
||||
texto = texto.replace('{%s}' % clave, str(valor or ''))
|
||||
@@ -221,12 +229,14 @@ class SkeenNotificacionService(models.AbstractModel):
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
|
||||
partner=None, ventana=None, clave_unica=None, asunto=None):
|
||||
partner=None, ventana=None, clave_unica=None, asunto=None,
|
||||
contexto_extra=None):
|
||||
"""Crea las notificaciones pendientes de una cita (una por canal).
|
||||
|
||||
Con `activo=False` no hace nada. No duplica: si ya existe la misma
|
||||
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
|
||||
recordset de lo creado.
|
||||
recordset de lo creado. `contexto_extra` alimenta variables adicionales
|
||||
del render (p. ej. `{enlace}` del formulario digital).
|
||||
"""
|
||||
Notif = self.env['skeen.notificacion'].sudo()
|
||||
cfg = self._cfg()
|
||||
@@ -239,7 +249,8 @@ class SkeenNotificacionService(models.AbstractModel):
|
||||
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
|
||||
if not plantilla.strip():
|
||||
return Notif
|
||||
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg)
|
||||
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
|
||||
extra=contexto_extra)
|
||||
if asunto is None:
|
||||
asunto = self._asunto(tipo)
|
||||
|
||||
|
||||
@@ -89,6 +89,12 @@ PERMISSION_GROUPS = [
|
||||
('notificaciones.enviar', 'Enviar y reintentar notificaciones'),
|
||||
('notificaciones.editar', 'Configurar recordatorios y plantillas'),
|
||||
]),
|
||||
('formularios', 'Formularios y consentimientos', [
|
||||
('formularios.ver', 'Ver formularios y respuestas'),
|
||||
('formularios.crear', 'Llenar formularios (recepción)'),
|
||||
('formularios.editar', 'Administrar plantillas de formularios'),
|
||||
('formularios.enviar', 'Enviar formularios a pacientes'),
|
||||
]),
|
||||
('config', 'Usuarios y configuración', [
|
||||
('config.usuarios', 'Administrar usuarios'),
|
||||
('config.roles', 'Administrar roles y menús'),
|
||||
@@ -116,6 +122,7 @@ READ_PERM_KEYS = (
|
||||
'ventas.reimprimir',
|
||||
'whatsapp.ver',
|
||||
'notificaciones.ver',
|
||||
'formularios.ver',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -197,6 +204,12 @@ DEFAULT_PERM_ROLES = {
|
||||
'notificaciones.ver': ALL,
|
||||
'notificaciones.enviar': W,
|
||||
'notificaciones.editar': AG,
|
||||
# Formularios digitales y consentimientos: ver es de todos; llenar/enviar lo
|
||||
# hace quien atiende al paciente; administrar plantillas es de admin/gerencia.
|
||||
'formularios.ver': ALL,
|
||||
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
||||
'formularios.editar': AG,
|
||||
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
||||
# Usuarios y configuración
|
||||
'config.usuarios': AG,
|
||||
'config.roles': AG,
|
||||
|
||||
Reference in New Issue
Block a user