539 lines
22 KiB
Python
539 lines
22 KiB
Python
# -*- 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
|