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:
2026-10-11 09:48:28 +00:00
parent 91f909a6d0
commit 9286ab8ee2
30 changed files with 3545 additions and 12 deletions

View File

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

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View 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',
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import comun
from . import formularios
from . import public_formulario

View 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

View 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': '*',
},
)

View 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'

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

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import formulario_plantilla
from . import formulario_respuesta
from . import formulario_adjunto

View 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')

View 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

View 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)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_formulario_plantilla Acceso a Plantillas de Formulario SKEEN model_skeen_formulario_plantilla base.group_user 1 1 1 1
3 access_skeen_formulario_respuesta Acceso a Respuestas de Formulario SKEEN model_skeen_formulario_respuesta base.group_user 1 1 1 1
4 access_skeen_formulario_adjunto Acceso a Adjuntos de Formulario SKEEN model_skeen_formulario_adjunto base.group_user 1 1 1 1

View File

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

View File

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