288 lines
12 KiB
Python
288 lines
12 KiB
Python
# -*- 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_encuestas, 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 ''
|
|
|
|
|
|
def _agradecimiento(env, plantilla):
|
|
"""Mensaje de gracias de la encuesta de satisfacción (si es la configurada)."""
|
|
try:
|
|
cfg = get_encuestas(env)
|
|
if cfg.get('plantilla_id') and int(cfg['plantilla_id']) == plantilla.id:
|
|
return (cfg.get('agradecimiento') or '').strip()
|
|
except Exception:
|
|
pass
|
|
return ''
|
|
|
|
|
|
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',
|
|
'agradecimiento': _agradecimiento(request.env, plantilla),
|
|
'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'
|