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:
@@ -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:
|
||||
|
||||
3
odoo-addons/skeen_portal_cliente/__init__.py
Normal file
3
odoo-addons/skeen_portal_cliente/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
38
odoo-addons/skeen_portal_cliente/__manifest__.py
Normal file
38
odoo-addons/skeen_portal_cliente/__manifest__.py
Normal 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',
|
||||
}
|
||||
3
odoo-addons/skeen_portal_cliente/controllers/__init__.py
Normal file
3
odoo-addons/skeen_portal_cliente/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import public_portal
|
||||
from . import staff_portal
|
||||
1225
odoo-addons/skeen_portal_cliente/controllers/public_portal.py
Normal file
1225
odoo-addons/skeen_portal_cliente/controllers/public_portal.py
Normal file
File diff suppressed because it is too large
Load Diff
181
odoo-addons/skeen_portal_cliente/controllers/staff_portal.py
Normal file
181
odoo-addons/skeen_portal_cliente/controllers/staff_portal.py
Normal 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)
|
||||
17
odoo-addons/skeen_portal_cliente/data/portal_cron.xml
Normal file
17
odoo-addons/skeen_portal_cliente/data/portal_cron.xml
Normal 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>
|
||||
2
odoo-addons/skeen_portal_cliente/models/__init__.py
Normal file
2
odoo-addons/skeen_portal_cliente/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import portal
|
||||
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
|
||||
@@ -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
|
||||
|
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user