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:
538
odoo-addons/skeen_portal_cliente/models/portal.py
Normal file
538
odoo-addons/skeen_portal_cliente/models/portal.py
Normal 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
|
||||
Reference in New Issue
Block a user