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