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:
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'
|
||||
Reference in New Issue
Block a user