# -*- 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