Files
SKEEN-Proyecto/odoo-addons/skeen_formularios/controllers/public_formulario.py

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'