bloque 2: cuenta del cliente (modulo skeen_portal_cliente: acceso con codigo por WhatsApp o enlace por correo, sesion propia separada del personal, mis citas con cancelar y reagendar, pagos, monedero, paquetes, membresias con pago del periodo, formularios, documentos y perfil; invitacion y control de sesiones desde el expediente); apagado por defecto

This commit is contained in:
2026-10-12 00:33:32 +00:00
parent 6a6c2a928b
commit 1bd4cad6a0
26 changed files with 3981 additions and 5 deletions

View File

@@ -0,0 +1,538 @@
# -*- 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