# -*- 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 ``. """ 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/', 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/', 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/', 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/', 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//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//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//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': '*', }, )