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