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

@@ -362,6 +362,111 @@ def get_anticipo(env):
"""Sub-bloque `anticipo` vigente (copia). Apagado por defecto."""
return get_reservas_online(env)['anticipo']
# ---------------------------------------------------------------------------
# Portal del cliente (cuenta de autoservicio del paciente).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y
# `reservas_online`. La regla de oro: con `activo=False` (default) NADA cambia
# en la instancia — el portal público responde `activo=false`/404, el frontend
# no muestra ninguna ruta ni enlace nuevo y las tablas propias quedan en cero.
# Por eso el default es apagado.
#
# activo: enciende TODO el portal del cliente
# metodo: otp_whatsapp | enlace_correo | ambos
# requerir_correo_verificado: exige correo registrado para acceder
# permitir_contrasena: permite fijar contraseña tras entrar
# sesion_horas: vigencia de la sesión del cliente (horas)
# permitir_reagendar: permite reagendar citas en línea
# horas_minimas_cancelar: horas de antelación para cancelar en línea
# mostrar_precios: muestra importes en citas y ventas
# mostrar_recetas: muestra recetas en el portal
# permitir_descargar_estado_cuenta: permite descargar el estado de cuenta
# permitir_actualizar_datos: permite al cliente editar sus datos
# ---------------------------------------------------------------------------
PORTAL_CLIENTE_DEFAULT = {
'activo': False,
'metodo': 'otp_whatsapp',
'requerir_correo_verificado': False,
'permitir_contrasena': False,
'sesion_horas': 24,
'permitir_reagendar': True,
'horas_minimas_cancelar': 24,
'mostrar_precios': True,
'mostrar_recetas': True,
'permitir_descargar_estado_cuenta': True,
'permitir_actualizar_datos': True,
}
PORTAL_METODOS = ('otp_whatsapp', 'enlace_correo', 'ambos')
PORTAL_SESION_HORAS_MIN, PORTAL_SESION_HORAS_MAX = 1, 720
PORTAL_CANCELAR_HORAS_MAX = 168 # 1 semana
PORTAL_BOOLEANOS = (
'activo', 'requerir_correo_verificado', 'permitir_contrasena',
'permitir_reagendar', 'mostrar_precios', 'mostrar_recetas',
'permitir_descargar_estado_cuenta', 'permitir_actualizar_datos',
)
def normalizar_portal_cliente(raw):
"""Bloque `portal_cliente` completo, con defaults. Tolerante: nunca lanza."""
out = dict(PORTAL_CLIENTE_DEFAULT)
if not isinstance(raw, dict):
return out
for k in PORTAL_BOOLEANOS:
out[k] = _bool(raw.get(k), PORTAL_CLIENTE_DEFAULT[k])
metodo = str(raw.get('metodo') or '').strip()
out['metodo'] = metodo if metodo in PORTAL_METODOS else PORTAL_CLIENTE_DEFAULT['metodo']
out['sesion_horas'] = min(max(
_int(raw.get('sesion_horas'), PORTAL_CLIENTE_DEFAULT['sesion_horas']),
PORTAL_SESION_HORAS_MIN), PORTAL_SESION_HORAS_MAX)
out['horas_minimas_cancelar'] = min(max(
_int(raw.get('horas_minimas_cancelar'),
PORTAL_CLIENTE_DEFAULT['horas_minimas_cancelar']),
0), PORTAL_CANCELAR_HORAS_MAX)
return out
def validar_portal_cliente(raw):
"""Valida el bloque `portal_cliente` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque del portal del cliente debe ser un objeto')
out = {}
for k in PORTAL_BOOLEANOS:
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'metodo' in raw:
metodo = str(raw['metodo'] or '').strip()
if metodo not in PORTAL_METODOS:
raise ValueError("El método debe ser 'otp_whatsapp', 'enlace_correo' o 'ambos'")
out['metodo'] = metodo
if 'sesion_horas' in raw:
if isinstance(raw['sesion_horas'], bool) or not isinstance(raw['sesion_horas'], int):
raise ValueError('Las horas de sesión deben ser un número entero')
if not (PORTAL_SESION_HORAS_MIN <= raw['sesion_horas'] <= PORTAL_SESION_HORAS_MAX):
raise ValueError('Las horas de sesión deben estar entre %d y %d'
% (PORTAL_SESION_HORAS_MIN, PORTAL_SESION_HORAS_MAX))
out['sesion_horas'] = raw['sesion_horas']
if 'horas_minimas_cancelar' in raw:
if isinstance(raw['horas_minimas_cancelar'], bool) or \
not isinstance(raw['horas_minimas_cancelar'], int):
raise ValueError('Las horas mínimas para cancelar deben ser un número entero')
if not (0 <= raw['horas_minimas_cancelar'] <= PORTAL_CANCELAR_HORAS_MAX):
raise ValueError('Las horas mínimas para cancelar deben estar entre 0 y %d'
% PORTAL_CANCELAR_HORAS_MAX)
out['horas_minimas_cancelar'] = raw['horas_minimas_cancelar']
return out
def get_portal_cliente(env):
"""Bloque del portal del cliente vigente (copia). Apagado por defecto."""
return normalizar_portal_cliente(get_settings(env).get('portal_cliente'))
# ---------------------------------------------------------------------------
# Recordatorios y notificaciones automáticas de citas.
#
@@ -427,6 +532,16 @@ NOTIFICACIONES_DEFAULT = {
'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. '
'Paga {monto} desde este enlace: {enlace}'
),
# Acceso a la cuenta del cliente (portal). El código y el enlace caducan:
# se advierte de no compartirlos. Ver `skeen_portal_cliente`.
'plantilla_codigo_acceso': (
'Hola {nombre}, tu código para entrar a tu cuenta es {codigo}. Vence en '
'{minutos} minutos. Por tu seguridad, no compartas este código con nadie.'
),
'plantilla_enlace_acceso': (
'Hola {nombre}, entra a tu cuenta desde este enlace: {enlace}. '
'No compartas este enlace con nadie.'
),
'whatsapp_numero': '',
'email_remitente': '',
}
@@ -435,11 +550,14 @@ NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono', 'enlace', 'formulario',
# Variables del aviso de membresías.
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
'plan', 'membresia', 'monto', 'sesiones', 'periodo',
# Variables del acceso a la cuenta del cliente (portal).
'codigo', 'minutos')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita',
'plantilla_formulario', 'plantilla_renovacion_membresia',
'plantilla_cobro_membresia')
'plantilla_cobro_membresia', 'plantilla_codigo_acceso',
'plantilla_enlace_acceso')
NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
@@ -1498,6 +1616,7 @@ def get_settings(env, aplicar_licencia=True):
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -1517,6 +1636,8 @@ def get_settings(env, aplicar_licencia=True):
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
if aplicar_licencia:
_aplicar_licencia(out)
return out
@@ -1570,6 +1691,11 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['comisiones'] = {**actual['comisiones'], **bloque}
if 'portal_cliente' in vals:
bloque = validar_portal_cliente(vals['portal_cliente'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['portal_cliente'] = {**actual['portal_cliente'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Portal del cliente (cuenta de autoservicio)',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Cuenta del cliente: acceso sin contraseña, citas, pagos, monedero, paquetes, membresías, formularios y documentos',
'description': """
Portal de autoservicio del paciente final, APAGADO por defecto.
- Acceso sin contraseña: código de 6 dígitos por WhatsApp o enlace de
acceso por correo, usando el motor de notificaciones (modo prueba).
- Sesión propia del cliente (token HMAC revocable), separada de la del
personal: un token de cliente no sirve en los endpoints del personal y
viceversa. El alcance queda fijado al paciente identificado.
- El cliente ve sus citas (cancelar/reagendar), pagos y estado de cuenta,
monedero, paquetes, membresías, formularios y documentos.
- Para el personal: invitar al portal, ver accesos activos y cerrar
sesiones, con permiso propio (`portal.invitar` / `portal.sesiones`).
La regla de oro: con `portal_cliente.activo=False` (default) TODO
responde 404/activo=false y no aparece ninguna ruta ni enlace nuevo.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': [
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_whatsapp',
'skeen_cobros', 'skeen_membresias', 'skeen_monedero', 'skeen_ventas',
'skeen_pagos', 'skeen_formularios', 'skeen_visitas',
],
'data': [
'security/ir.model.access.csv',
'data/portal_cron.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import public_portal
from . import staff_portal

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""Portal del cliente: endpoints del PERSONAL (invitar, accesos, sesiones).
Bajo `/skeen/frontend/v1/portal`, autenticados con la sesión del personal
(`require_perm`). Un token del portal del cliente NO sirve aquí (no lleva `uid`).
Permiso nuevo: `portal.invitar` (invitar y cerrar sesiones) y `portal.sesiones`
(ver el listado de accesos). Por defecto: admin, gerencia y recepción.
"""
import json
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import (
_json, require_perm, require_role,
)
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
_logger = logging.getLogger(__name__)
PREFIX = '/skeen/frontend/v1/portal'
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
return {}
class SkeenPortalStaffController(http.Controller):
"""Gestión del portal del cliente desde la interfaz del personal."""
def _svc(self):
return request.env['skeen.portal.service'].sudo()
# ------------------------------------------------------------------
# Invitar al portal (envía el código o el enlace)
# ------------------------------------------------------------------
@http.route(PREFIX + '/invitar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('portal.invitar')
def portal_invitar(self, **kw):
data = _body()
try:
partner = request.env['res.partner'].sudo().browse(
int(data.get('partner_id') or 0))
if not partner.exists() or not partner.is_patient:
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
svc = self._svc()
cfg = svc.cfg()
if not cfg.get('activo'):
return _json({'status': 'error',
'message': 'El portal del cliente está apagado. '
'Actívalo en Configuración.'}, 403)
canal = str(data.get('canal') or '').strip()
if canal not in ('whatsapp', 'email'):
canal = svc.canal_para(cfg, partner, partner.email or partner.phone or '')
if not svc.destino_disponible(partner, canal):
canal = 'whatsapp' if svc.destino_disponible(partner, 'whatsapp') else 'email'
if not svc.destino_disponible(partner, canal):
return _json({'status': 'error',
'message': 'El paciente no tiene teléfono ni correo.'}, 400)
usuario = getattr(request, 'frontend_user', None)
acceso = svc.acceso_de(partner, crear=True, metodo=cfg.get('metodo'),
invitado_por=usuario.login if usuario else '')
acceso.write({'activo': True, 'invitado_at': fields.Datetime.now()})
salida = {'status': 'success', 'ok': True, 'canal': canal,
'modo_prueba': svc.modo_prueba(),
'acceso': svc.estado_paciente(partner)}
if canal == 'whatsapp':
codigo = svc.enviar_codigo(partner, ip=_pb._client_ip())
if svc.modo_prueba():
salida['codigo_prueba'] = codigo
else:
url = svc.enviar_enlace(partner, ip=_pb._client_ip())
if svc.modo_prueba():
salida['enlace_prueba'] = url
return _json(salida)
except Exception as exc:
request.env.cr.rollback()
_logger.exception('Error invitando al portal: %s', exc)
return _json({'status': 'error',
'message': 'No se pudo invitar al portal.'}, 500)
# ------------------------------------------------------------------
# Estado del acceso de un paciente (ficha de expediente)
# ------------------------------------------------------------------
@http.route(PREFIX + '/paciente/<int:partner_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('portal.invitar')
def portal_paciente_estado(self, partner_id, **kw):
try:
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists() or not partner.is_patient:
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
return _json({'status': 'success',
'activo': self._svc().activo(),
'estado': self._svc().estado_paciente(partner)})
except Exception as exc:
_logger.exception('Error consultando acceso del portal: %s', exc)
return _json({'status': 'error', 'message': 'No se pudo consultar.'}, 500)
# ------------------------------------------------------------------
# Listado de accesos activos
# ------------------------------------------------------------------
@http.route(PREFIX + '/accesos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('portal.sesiones')
def portal_accesos(self, **kw):
try:
svc = self._svc()
return _json({'status': 'success',
'activo': svc.activo(),
'accesos': svc.lista_accesos()})
except Exception as exc:
_logger.exception('Error listando accesos del portal: %s', exc)
return _json({'status': 'error', 'message': 'No se pudo consultar.'}, 500)
# ------------------------------------------------------------------
# Cerrar sesiones de un paciente
# ------------------------------------------------------------------
@http.route(PREFIX + '/sesiones/cerrar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('portal.invitar')
def portal_cerrar_sesiones(self, **kw):
data = _body()
try:
partner = request.env['res.partner'].sudo().browse(
int(data.get('partner_id') or 0))
if not partner.exists() or not partner.is_patient:
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
svc = self._svc()
acceso = svc.acceso_de(partner, crear=False)
if not acceso:
return _json({'status': 'error',
'message': 'El paciente no tiene acceso al portal.'}, 404)
acceso.action_cerrar_sesiones()
return _json({'status': 'success', 'ok': True,
'acceso': svc.estado_paciente(partner)})
except Exception as exc:
request.env.cr.rollback()
_logger.exception('Error cerrando sesiones del portal: %s', exc)
return _json({'status': 'error', 'message': 'No se pudo cerrar sesiones.'}, 500)
# ------------------------------------------------------------------
# Activar / desactivar el acceso de un paciente
# ------------------------------------------------------------------
@http.route(PREFIX + '/acceso/<int:partner_id>/<string:accion>', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('portal.invitar')
def portal_acceso_estado(self, partner_id, accion, **kw):
try:
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists() or not partner.is_patient:
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
if accion not in ('activar', 'desactivar'):
return _json({'status': 'error', 'message': 'Acción no válida'}, 400)
svc = self._svc()
acceso = svc.acceso_de(partner, crear=(accion == 'activar'))
if not acceso:
return _json({'status': 'error',
'message': 'El paciente no tiene acceso al portal.'}, 404)
activo = accion == 'activar'
acceso.write({'activo': activo})
if not activo:
acceso.action_cerrar_sesiones()
return _json({'status': 'success', 'ok': True,
'acceso': svc.estado_paciente(partner)})
except Exception as exc:
request.env.cr.rollback()
_logger.exception('Error cambiando el acceso al portal: %s', exc)
return _json({'status': 'error', 'message': 'No se pudo actualizar.'}, 500)

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Limpieza diaria de códigos y sesiones caducados. Es inocua con el
portal apagado (solo borra filas ya vencidas). -->
<record id="cron_portal_limpieza" model="ir.cron">
<field name="name">Portal del cliente: limpiar códigos y sesiones</field>
<field name="model_id" ref="model_skeen_portal_acceso"/>
<field name="state">code</field>
<field name="code">model._cron_limpiar()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import portal

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

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_portal_acceso_user,Acceso al portal del cliente,model_skeen_portal_acceso,base.group_user,1,1,1,1
access_skeen_portal_codigo_user,Código de acceso al portal,model_skeen_portal_codigo,base.group_user,1,1,1,1
access_skeen_portal_sesion_user,Sesión del portal del cliente,model_skeen_portal_sesion,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_portal_acceso_user Acceso al portal del cliente model_skeen_portal_acceso base.group_user 1 1 1 1
3 access_skeen_portal_codigo_user Código de acceso al portal model_skeen_portal_codigo base.group_user 1 1 1 1
4 access_skeen_portal_sesion_user Sesión del portal del cliente model_skeen_portal_sesion base.group_user 1 1 1 1

View File

@@ -46,6 +46,8 @@ TIPOS = [
('formulario', 'Formulario'),
('renovacion_membresia', 'Renovación de membresía'),
('cobro_membresia', 'Cobro de membresía'),
('acceso_codigo', 'Código de acceso al portal'),
('acceso_enlace', 'Enlace de acceso al portal'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -73,6 +75,9 @@ PLANTILLA_POR_TIPO = {
'renovacion_membresia': 'plantilla_renovacion_membresia',
# Aviso con enlace de pago del periodo (cobro automático de membresías).
'cobro_membresia': 'plantilla_cobro_membresia',
# Acceso a la cuenta del cliente (portal): código de 6 dígitos o enlace.
'acceso_codigo': 'plantilla_codigo_acceso',
'acceso_enlace': 'plantilla_enlace_acceso',
'manual': 'plantilla_recordatorio',
}

View File

@@ -50,6 +50,8 @@ PERMISSION_GROUPS = [
('pacientes.eliminar', 'Eliminar pacientes'),
('pacientes.exportar', 'Exportar pacientes'),
('pacientes.saldos', 'Ver saldos y adeudos'),
('portal.invitar', 'Invitar al portal del cliente y cerrar sus sesiones'),
('portal.sesiones', 'Ver quién tiene acceso al portal del cliente'),
]),
('visitas', 'Visitas y expedientes', [
('visitas.ver', 'Ver visitas'),
@@ -213,6 +215,10 @@ DEFAULT_PERM_ROLES = {
'pacientes.eliminar': W,
'pacientes.exportar': ALL,
'pacientes.saldos': ALL,
# Portal del cliente: invitar y cerrar sesiones, y ver el listado de
# accesos, es de recepción/gerencia/admin (dato de contacto del paciente).
'portal.invitar': ('admin', 'recepcion', 'gerencia'),
'portal.sesiones': ('admin', 'recepcion', 'gerencia'),
# Visitas
'visitas.ver': ALL,
'visitas.crear': W,