# -*- coding: utf-8 -*- """Cuenta del cliente (portal de autoservicio del paciente). Tres modelos de soporte, todos gobernados por el bloque `portal_cliente` de los ajustes de la clínica: skeen.portal.acceso un registro por paciente (activo, método, clave) skeen.portal.codigo código OTP o enlace de acceso (hash, caducidad, intentos) skeen.portal.sesion sesiones del portal (token revocable) La sesión del cliente es un token HMAC firmado con `skeen.frontend.secret`, con alcance limitado al paciente identificado (`pid`) y ligado a una fila de `skeen.portal.sesion` para poder cerrarla de forma explícita. Es independiente de la sesión del personal: un token de cliente no lleva `uid` (el personal lo rechaza con 401) y un token de personal no lleva `pid`/`kind='portal'` (el portal lo rechaza). El portal nunca confía en un identificador enviado por el navegador: siempre resuelve el paciente desde la sesión. Toda la lógica de tokens, códigos y sesiones vive en `skeen.portal.service`, para no duplicarla entre el controlador público y el del personal. """ import hashlib import hmac import json import logging import re import secrets import time from base64 import urlsafe_b64decode, urlsafe_b64encode from datetime import timedelta from odoo import api, fields, models from odoo.exceptions import ValidationError from odoo.addons.skeen_citas.clinic_settings import ( get_notificaciones, get_portal_cliente, url_publica, ) _logger = logging.getLogger(__name__) METODOS = [ ('otp_whatsapp', 'Código por WhatsApp'), ('enlace_correo', 'Enlace por correo'), ('ambos', 'Código o enlace'), ] CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')] TOKEN_KIND_SESION = 'portal' TOKEN_KIND_ENLACE = 'portal_link' CODIGO_MINUTOS = 10 # caducidad del código de 6 dígitos ENLACE_MINUTOS = 30 # caducidad del enlace de acceso por correo CODIGO_MAX_INTENTOS = 5 CODIGOS_POR_HORA = 5 # máximo de códigos solicitados por paciente/hora _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') def _b64(raw): return urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') def _b64d(texto): return urlsafe_b64decode(texto + '=' * (-len(texto) % 4)) class SkeenPortalAcceso(models.Model): _name = 'skeen.portal.acceso' _description = 'Acceso al portal del cliente' _order = 'id desc' partner_id = fields.Many2one('res.partner', string='Paciente', required=True, ondelete='cascade', index=True, domain=[('is_patient', '=', True)]) activo = fields.Boolean(string='Activo', default=True) metodo = fields.Selection(METODOS, string='Método de acceso') password_hash = fields.Char(string='Contraseña (hash)', copy=False) password_salt = fields.Char(string='Sal', copy=False) ultimo_acceso = fields.Datetime(string='Última entrada') total_accesos = fields.Integer(string='Accesos', default=0) invitado_por = fields.Char(string='Invitado por') invitado_at = fields.Datetime(string='Invitado el') notas = fields.Text(string='Notas') sesion_ids = fields.One2many('skeen.portal.sesion', 'acceso_id', string='Sesiones') sesiones_abiertas = fields.Integer(string='Sesiones abiertas', compute='_compute_sesiones_abiertas') _sql_constraints = [ ('partner_uniq', 'unique(partner_id)', 'Ya existe un acceso al portal para ese paciente.'), ] @api.depends('sesion_ids.cerrada', 'sesion_ids.expira') def _compute_sesiones_abiertas(self): ahora = fields.Datetime.now() for acceso in self: acceso.sesiones_abiertas = len(acceso.sesion_ids.filtered( lambda s: not s.cerrada and (not s.expira or s.expira > ahora))) def set_password(self, password): self.ensure_one() salt = secrets.token_hex(16) raw = ('%s%s' % (salt, password)).encode('utf-8') self.write({'password_hash': hashlib.sha256(raw).hexdigest(), 'password_salt': salt}) def check_password(self, password): self.ensure_one() if not self.password_hash or not self.password_salt: return False raw = ('%s%s' % (self.password_salt, password)).encode('utf-8') return hmac.compare_digest(hashlib.sha256(raw).hexdigest(), self.password_hash) def _sesiones_vigentes(self): ahora = fields.Datetime.now() return self.sesion_ids.filtered( lambda s: not s.cerrada and (not s.expira or s.expira > ahora)) def action_cerrar_sesiones(self): ahora = fields.Datetime.now() for acceso in self: vigentes = acceso._sesiones_vigentes() vigentes.write({'cerrada': True, 'cerrada_at': ahora}) return True @api.model def _cron_limpiar(self): """Borra códigos y sesiones caducados (inocuo con el portal apagado).""" ahora = fields.Datetime.now() self.env['skeen.portal.codigo'].sudo().search( [('expira', '<', ahora)]).unlink() self.env['skeen.portal.sesion'].sudo().search( [('expira', '<', ahora)]).unlink() return True class SkeenPortalCodigo(models.Model): _name = 'skeen.portal.codigo' _description = 'Código de acceso al portal' _order = 'id desc' partner_id = fields.Many2one('res.partner', string='Paciente', required=True, ondelete='cascade', index=True) canal = fields.Selection(CANALES, string='Canal', required=True) codigo_hash = fields.Char(string='Código (hash)', required=True, copy=False) expira = fields.Datetime(string='Vence el', required=True, index=True) intentos = fields.Integer(string='Intentos', default=0) max_intentos = fields.Integer(string='Intentos máximos', default=CODIGO_MAX_INTENTOS) usado = fields.Boolean(string='Usado', default=False, index=True) ip = fields.Char(string='IP') create_date = fields.Datetime(string='Creado', readonly=True) class SkeenPortalSesion(models.Model): _name = 'skeen.portal.sesion' _description = 'Sesión del portal del cliente' _order = 'id desc' acceso_id = fields.Many2one('skeen.portal.acceso', string='Acceso', required=True, ondelete='cascade', index=True) partner_id = fields.Many2one(related='acceso_id.partner_id', string='Paciente', store=True, index=True) token_id = fields.Char(string='Identificador de sesión', required=True, index=True, copy=False) expira = fields.Datetime(string='Vence el', index=True) cerrada = fields.Boolean(string='Cerrada', default=False, index=True) cerrada_at = fields.Datetime(string='Cerrada el') ip = fields.Char(string='IP') user_agent = fields.Char(string='Navegador') ultimo_uso = fields.Datetime(string='Último uso') create_date = fields.Datetime(string='Creada', readonly=True) _sql_constraints = [ ('token_id_uniq', 'unique(token_id)', 'El identificador de sesión del portal debe ser único.'), ] class ResPartner(models.Model): _inherit = 'res.partner' portal_acceso_ids = fields.One2many('skeen.portal.acceso', 'partner_id', string='Accesos al portal') class SkeenPortalService(models.AbstractModel): """Servicio del portal: tokens, códigos, sesiones y datos del paciente.""" _name = 'skeen.portal.service' _description = 'Servicio del portal del cliente' # ------------------------------------------------------------------ # Configuración y secreto # ------------------------------------------------------------------ @api.model def cfg(self): return get_portal_cliente(self.env) @api.model def activo(self): return bool(self.cfg().get('activo')) @api.model def _secreto(self): # Mismo secreto de la instancia que firma la sesión del personal y los # tokens de reservas/cobros/formularios. from odoo.addons.skeen_whatsapp.controllers.auth import _get_secret return _get_secret(self.env) # ------------------------------------------------------------------ # Tokens firmados (HMAC) # ------------------------------------------------------------------ @api.model def _firmar(self, payload): body = _b64(json.dumps(payload, separators=(',', ':')).encode('utf-8')) sig = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest() return '%s.%s' % (body, sig) @api.model def _verificar(self, token, kind): if not token or '.' not in str(token): return None try: body, sig = str(token).rsplit('.', 1) esperada = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, esperada): return None payload = json.loads(_b64d(body).decode('utf-8')) except Exception: return None if payload.get('kind') != kind: return None if int(payload.get('exp', 0)) < int(time.time()): return None return payload @api.model def hash_codigo(self, valor): return hmac.new(self._secreto().encode('utf-8'), str(valor or '').encode('utf-8'), hashlib.sha256).hexdigest() # ------------------------------------------------------------------ # Paciente # ------------------------------------------------------------------ @api.model def buscar_paciente(self, identificador): """Paciente (is_patient) por teléfono o correo. None si no existe. La búsqueda es tolerante al formato del teléfono, igual que el portal de reservas (variantes con/sin +52 y búsqueda por últimos dígitos). """ ident = str(identificador or '').strip() if not ident: return None Partner = self.env['res.partner'].sudo() if '@' in ident: if not _EMAIL_RE.match(ident): return None return Partner.search([ ('is_patient', '=', True), ('email', '=ilike', ident)], limit=1) digitos = re.sub(r'\D', '', ident) if len(digitos) < 10: return None variantes = {digitos, '+%s' % digitos, '52%s' % digitos, '+52%s' % digitos} partner = Partner.search([ ('is_patient', '=', True), '|', ('phone', 'in', list(variantes)), ('mobile', 'in', list(variantes)), ], limit=1) if not partner: partner = Partner.search([ ('is_patient', '=', True), '|', ('phone', 'ilike', digitos[-10:]), ('mobile', 'ilike', digitos[-10:]), ], limit=1) return partner @api.model def acceso_de(self, partner, crear=False, metodo=None, invitado_por=''): Acceso = self.env['skeen.portal.acceso'].sudo() acceso = Acceso.search([('partner_id', '=', partner.id)], limit=1) if not acceso and crear: acceso = Acceso.create({ 'partner_id': partner.id, 'activo': True, 'metodo': metodo or self.cfg().get('metodo'), 'invitado_por': invitado_por or '', 'invitado_at': fields.Datetime.now(), }) return acceso # ------------------------------------------------------------------ # Códigos y enlaces # ------------------------------------------------------------------ @api.model def crear_codigo(self, partner, canal, valor, minutos, ip=''): return self.env['skeen.portal.codigo'].sudo().create({ 'partner_id': partner.id, 'canal': canal, 'codigo_hash': self.hash_codigo(valor), 'expira': fields.Datetime.now() + timedelta(minutes=int(minutos)), 'max_intentos': CODIGO_MAX_INTENTOS, 'ip': ip or '', }) @api.model def codigos_recientes(self, partner, canal=None, horas=1): desde = fields.Datetime.now() - timedelta(hours=int(horas)) dom = [('partner_id', '=', partner.id), ('create_date', '>=', desde)] if canal: dom.append(('canal', '=', canal)) return self.env['skeen.portal.codigo'].sudo().search_count(dom) @api.model def destino_disponible(self, partner, canal): if not partner: return False if canal == 'whatsapp': return bool((partner.whatsapp or partner.phone or '').strip()) return bool((partner.email or '').strip()) @api.model def canal_para(self, cfg, partner, identificador): """Canal que corresponde al identificador y al método configurado.""" metodo = cfg.get('metodo') or 'otp_whatsapp' es_email = '@' in str(identificador or '') if metodo == 'enlace_correo': return 'email' if metodo == 'otp_whatsapp': return 'whatsapp' return 'email' if (es_email and (partner.email or '')) else 'whatsapp' @api.model def enviar_codigo(self, partner, ip=''): """Genera, guarda y encola el código de 6 dígitos. Devuelve el código.""" codigo = '%06d' % secrets.randbelow(1000000) self.crear_codigo(partner, 'whatsapp', codigo, CODIGO_MINUTOS, ip=ip) self._encolar_acceso(partner, 'acceso_codigo', 'whatsapp', {'codigo': codigo, 'minutos': CODIGO_MINUTOS}) return codigo @api.model def enviar_enlace(self, partner, ip=''): """Genera, guarda y encola el enlace de acceso por correo. Devuelve la URL.""" token = self._firmar({'pid': partner.id, 'kind': TOKEN_KIND_ENLACE, 'exp': int(time.time()) + ENLACE_MINUTOS * 60, 'n': secrets.token_urlsafe(8)}) self.crear_codigo(partner, 'email', token, ENLACE_MINUTOS, ip=ip) base = url_publica(self.env, fallback='') url = '%s/mi-cuenta/acceso/%s' % (base, token) self._encolar_acceso(partner, 'acceso_enlace', 'email', {'enlace': url}) return url @api.model def _encolar_acceso(self, partner, tipo, canal, extra): """Encola y dispara el aviso por el motor de notificaciones. Con `notificaciones.activo=False` no se envía nada (el motor lo decide). Nunca debe tumbar el acceso: cualquier fallo se registra y se ignora. """ try: svc = self.env['skeen.notificacion.service'].sudo() ncfg = get_notificaciones(self.env) if not ncfg.get('activo'): return False clave = '%s:%s:%s' % (tipo, partner.id, secrets.token_hex(6)) notifs = svc.encolar(partner=partner, tipo=tipo, canal=canal, cuando=fields.Datetime.now(), clave_unica=clave, contexto_extra=extra) for notif in notifs: svc._intentar(notif, ncfg) return bool(notifs) except Exception: _logger.exception('No se pudo enviar el acceso al portal') return False @api.model def modo_prueba(self): return bool(get_notificaciones(self.env).get('modo_prueba')) @api.model def verificar_codigo(self, partner, codigo): """Valida el código. Devuelve (ok, mensaje). Consume intentos.""" codigo = re.sub(r'\D', '', str(codigo or '')) if len(codigo) != 6: return False, 'Escribe el código de 6 dígitos.' Codigo = self.env['skeen.portal.codigo'].sudo() registros = Codigo.search([ ('partner_id', '=', partner.id), ('canal', '=', 'whatsapp'), ('usado', '=', False)], order='id desc', limit=1) if not registros: return False, 'El código no es válido o ya expiró.' registro = registros[0] if registro.expira and registro.expira < fields.Datetime.now(): return False, 'El código expiró. Solicita uno nuevo.' if registro.intentos >= registro.max_intentos: registro.write({'usado': True}) return False, 'Se agotaron los intentos. Solicita uno nuevo.' if not hmac.compare_digest(self.hash_codigo(codigo), registro.codigo_hash): intentos = registro.intentos + 1 registro.write({'intentos': intentos, 'usado': intentos >= registro.max_intentos}) return False, 'El código no es válido o ya expiró.' registro.write({'usado': True}) return True, '' @api.model def verificar_enlace(self, token): """Valida el enlace de acceso. Devuelve (partner, error). Consume el token.""" payload = self._verificar(token, TOKEN_KIND_ENLACE) if not payload: return None, 'El enlace no es válido o ya expiró.' partner = self.env['res.partner'].sudo().browse(int(payload.get('pid') or 0)) if not partner.exists() or not partner.is_patient: return None, 'El enlace no es válido o ya expiró.' Codigo = self.env['skeen.portal.codigo'].sudo() registro = Codigo.search([ ('partner_id', '=', partner.id), ('canal', '=', 'email'), ('codigo_hash', '=', self.hash_codigo(token)), ('usado', '=', False)], limit=1) if not registro: return None, 'El enlace no es válido o ya expiró.' if registro.expira and registro.expira < fields.Datetime.now(): return None, 'El enlace no es válido o ya expiró.' registro.write({'usado': True}) return partner, '' # ------------------------------------------------------------------ # Sesiones # ------------------------------------------------------------------ @api.model def crear_sesion(self, partner, ip='', user_agent=''): """Crea la sesión y devuelve (token, sesion).""" cfg = self.cfg() horas = max(1, int(cfg.get('sesion_horas') or 24)) acceso = self.acceso_de(partner, crear=True, metodo=cfg.get('metodo')) if not acceso.activo: return None, None sesion = self.env['skeen.portal.sesion'].sudo().create({ 'acceso_id': acceso.id, 'token_id': secrets.token_urlsafe(24), 'expira': fields.Datetime.now() + timedelta(hours=horas), 'ip': ip or '', 'user_agent': (user_agent or '')[:200], 'ultimo_uso': fields.Datetime.now(), }) token = self._firmar({ 'pid': partner.id, 'sid': sesion.id, 'jti': sesion.token_id, 'kind': TOKEN_KIND_SESION, 'exp': int(time.time()) + horas * 3600, }) acceso.write({ 'ultimo_acceso': fields.Datetime.now(), 'total_accesos': (acceso.total_accesos or 0) + 1, }) return token, sesion @api.model def verificar_sesion(self, token): """Devuelve (partner, sesion, error). El paciente SIEMPRE sale del token.""" payload = self._verificar(token, TOKEN_KIND_SESION) if not payload: return None, None, 'Sesión no válida o expirada.' sesion = self.env['skeen.portal.sesion'].sudo().browse(int(payload.get('sid') or 0)) if not sesion.exists() or sesion.cerrada: return None, None, 'Sesión no válida o expirada.' if not hmac.compare_digest(str(sesion.token_id or ''), str(payload.get('jti') or '')): return None, None, 'Sesión no válida o expirada.' if sesion.expira and sesion.expira < fields.Datetime.now(): return None, None, 'Sesión no válida o expirada.' partner = self.env['res.partner'].sudo().browse(int(payload.get('pid') or 0)) if not partner.exists() or not partner.is_patient: return None, None, 'Sesión no válida o expirada.' if sesion.partner_id.id != partner.id: return None, None, 'Sesión no válida o expirada.' sesion.write({'ultimo_uso': fields.Datetime.now()}) return partner, sesion, '' @api.model def cerrar_sesion(self, sesion): if sesion and sesion.exists(): sesion.write({'cerrada': True, 'cerrada_at': fields.Datetime.now()}) return True # ------------------------------------------------------------------ # Datos para el staff # ------------------------------------------------------------------ @api.model def estado_paciente(self, partner): """Resumen del acceso de un paciente para la ficha de expediente.""" acceso = self.acceso_de(partner, crear=False) if not acceso: return { 'partner_id': partner.id, 'tiene_acceso': False, 'activo': False, 'ultimo_acceso': None, 'total_accesos': 0, 'sesiones_abiertas': 0, 'metodo': None, } return { 'partner_id': partner.id, 'tiene_acceso': True, 'activo': bool(acceso.activo), 'ultimo_acceso': acceso.ultimo_acceso, 'total_accesos': acceso.total_accesos or 0, 'sesiones_abiertas': len(acceso._sesiones_vigentes()), 'metodo': acceso.metodo, 'invitado_por': acceso.invitado_por or '', 'invitado_at': acceso.invitado_at, } @api.model def lista_accesos(self, limite=200): Acceso = self.env['skeen.portal.acceso'].sudo() accesos = Acceso.search([], order='ultimo_acceso desc, id desc', limit=limite) out = [] for acceso in accesos: out.append({ 'partner_id': acceso.partner_id.id, 'paciente': acceso.partner_id.name, 'patient_id': acceso.partner_id.patient_id or '', 'telefono': acceso.partner_id.phone or '', 'email': acceso.partner_id.email or '', 'activo': bool(acceso.activo), 'metodo': acceso.metodo, 'ultimo_acceso': acceso.ultimo_acceso, 'total_accesos': acceso.total_accesos or 0, 'sesiones_abiertas': len(acceso._sesiones_vigentes()), }) return out