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

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