Files
SKEEN-Proyecto/odoo-addons/skeen_chat/controllers/chat.py
Consultoría Alcaraz Salazar 85a281add1 Permisos por pestana de reportes y ruido de serializacion en presencia
Auditoria de 260 visitas (26 rutas x 10 roles) sobre el build actual: 0 hallazgos.
Dos defectos reales que salieron del barrido anterior se corrigen aqui.

1) Reportes: la matriz guarda claves '/reportes?tab=x' y puede conceder una
   pestana suelta (Horas agenda al medico, Exportar a cosmetologia) sin dar el
   modulo. Nadie la respetaba:
   - App.tsx: nuevo ReportesRoute que evalua la pestana del URL; si el rol no
     tiene '/reportes' pero si alguna pestana, aterriza en la primera permitida.
   - Reportes.tsx: familias, favoritos y el boton "Exportar datos" se filtran
     por canSee('/reportes?tab=x'). 'exportar' no pertenece a ninguna familia,
     por eso se lista en TABS_FUERA_DE_FAMILIA (si no, el guard lo reescribia a
     'general' y admin perdia la vista). El query se sincroniza con lo renderizado.
   - SkeenLayout/HomeNestLayout: "Horas agenda" y "Exportar datos" aparecen en
     Analitica para que el permiso sea alcanzable desde el menu, no solo a
     mano. Clasico ya los traia.
   - auth.py: _medico_reporte_permitido consulta la matriz antes de vetar
     /reports/ al medico. Sin ese puente el menu ofrecia la opcion y la API
     respondia 403. Las demas rutas vetadas (sales, payments, wallets, pos)
     siguen cerradas: verificado comisiones=403, horas-agenda=200.

2) skeen_chat/_touch: 211 "could not serialize access due to concurrent update"
   en un barrido. Bandeja, badge y heartbeat salen en paralelo y cada uno
   emitia el UPDATE de last_seen aunque no aplicara cambios, abortando la
   transaccion de otra peticion. Se corta antes de tocar la fila cuando el
   registro es reciente; el savepoint queda para el caso de empate exacto.
   Ráfaga de 128 peticiones concurrentes: todas 200, presencia sigue
   actualizando (online=true, last_seen avanza) y el ruido bajo de 211 a 5.
2026-09-30 01:26:37 +00:00

282 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""Chat interno 1-a-1 entre usuarios del frontend React (skeen.frontend.user).
Mismo esquema de auth HMAC que el resto de la API. Todos los endpoints están
restringidos al usuario autenticado: solo puede listar/leer conversaciones
donde él es sender o recipient, y solo puede enviar como él mismo.
"""
from odoo import http, fields
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import require_role
from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _parse_json_body
MAX_BODY = 2000
HISTORY_LIMIT = 50
LIST_SCAN_LIMIT = 500
# Presencia: actividad en los últimos 2 minutos = "en línea"
ONLINE_SECONDS = 120
# No reescribir last_seen más seguido que esto (varias pestañas del mismo
# usuario laten a la vez)
TOUCH_INTERVAL = 20
class SkeenChatController(http.Controller):
def _touch(self, me):
"""Marca actividad del usuario (presencia). Nunca debe romper la petición.
El frontend consulta varios endpoints de chat en paralelo (bandeja,
badge, heartbeat) y todos llaman aquí. Por eso se corta antes de tocar
la fila cuando el registro es reciente (TOUCH_INTERVAL): el UPDATE se
ejecutaba aunque no aplicara ningún cambio y abortaba la transacción de
otra petición concurrente ("could not serialize access due to concurrent
update", 211 veces en un barrido de 260 visitas). El savepoint sigue
ahí por si dos peticiones pasan el corte en el mismo instante; su fallo
se descarta y la petición continúa.
"""
try:
ahora = fields.Datetime.now()
limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL)
if me.last_seen and me.last_seen >= limite:
return
cr = request.env.cr
with cr.savepoint():
cr.execute(
"UPDATE skeen_frontend_user SET last_seen = %s, write_date = %s "
"WHERE id = %s AND (last_seen IS NULL OR last_seen < %s)",
(ahora, ahora, me.id, limite))
except Exception:
pass
def _online(self, user):
if not user.last_seen:
return False
delta = (fields.Datetime.now() - user.last_seen).total_seconds()
return delta <= ONLINE_SECONDS
def _message_to_dict(self, msg, me_id):
return {
'id': msg.id,
'sender_id': msg.sender_id.id,
'recipient_id': msg.recipient_id.id,
'body': msg.body,
'is_read': msg.is_read,
'mine': msg.sender_id.id == me_id,
'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None,
}
def _pair_domain(self, me_id, other_id):
return [
'|', '&', ('sender_id', '=', me_id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me_id),
]
@http.route('/skeen/frontend/v1/chat/heartbeat', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_heartbeat(self, **kw):
"""Latido de presencia: el cliente lo manda cada ~30 s con la app abierta.
Es la ÚNICA señal que escribe last_seen: así no compite con las demás
llamadas de chat que el frontend lanza en paralelo.
"""
try:
me = request.frontend_user
self._touch(me)
online = request.env['skeen.frontend.user'].sudo().search_count([
('active', '=', True),
('last_seen', '>=', fields.Datetime.subtract(fields.Datetime.now(), seconds=ONLINE_SECONDS)),
])
return json_response({
'status': 'success',
'server_time': fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'online_count': online,
'online_window_seconds': ONLINE_SECONDS,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_users(self, **kw):
"""Usuarios activos (menos yo) con conteo de no leídos y último mensaje.
Orden: primero conversaciones con actividad (más reciente primero),
luego el resto alfabéticamente.
"""
try:
me = request.frontend_user
User = request.env['skeen.frontend.user'].sudo()
Msg = request.env['skeen.chat.message'].sudo()
users = User.search([('active', '=', True), ('id', '!=', me.id)], order='name')
unread = {}
for g in Msg.read_group(
[('recipient_id', '=', me.id), ('is_read', '=', False)],
['sender_id'], ['sender_id']):
unread[g['sender_id'][0]] = g['sender_id_count']
last = {}
recent = Msg.search([
'|', ('sender_id', '=', me.id), ('recipient_id', '=', me.id),
], order='id desc', limit=LIST_SCAN_LIMIT)
for m in recent:
other_id = m.recipient_id.id if m.sender_id.id == me.id else m.sender_id.id
if other_id not in last:
last[other_id] = {
'body': m.body or '',
'date': m.create_date.strftime('%Y-%m-%d %H:%M:%S') if m.create_date else None,
'mine': m.sender_id.id == me.id,
}
# Conversaciones con actividad primero (más reciente arriba)
conversations = sorted(
(u for u in users if u.id in last),
key=lambda u: last[u.id]['date'] or '', reverse=True)
rest = [u for u in users if u.id not in last]
return json_response({
'status': 'success',
'users': [{
'id': u.id,
'name': u.name,
'role': u.role,
'unread': unread.get(u.id, 0),
'last_message': last.get(u.id, {}).get('body'),
'last_date': last.get(u.id, {}).get('date'),
'last_mine': last.get(u.id, {}).get('mine'),
'online': self._online(u),
'last_seen': u.last_seen.strftime('%Y-%m-%d %H:%M:%S') if u.last_seen else None,
} for u in conversations + rest],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_messages(self, **kw):
"""Historial con otro usuario (últimos 50; since_id para incremental,
around_id para abrir la ventana que termina en un mensaje concreto).
Marca como leídos los mensajes recibidos de ese usuario.
"""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
domain = self._pair_domain(me.id, other_id)
since_id = int(data.get('since_id') or 0)
around_id = int(data.get('around_id') or 0)
Msg = request.env['skeen.chat.message'].sudo()
if since_id:
domain.append(('id', '>', since_id))
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
elif around_id:
# Ventana de historial que TERMINA en ese mensaje (contexto hacia atrás)
msgs = Msg.search(domain + [('id', '<=', around_id)],
order='id desc', limit=HISTORY_LIMIT).sorted('id')
else:
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
# Marcar leídos los recibidos en esta conversación
incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read)
if incoming:
incoming.write({'is_read': True})
return json_response({
'status': 'success',
'messages': [self._message_to_dict(m, me.id) for m in msgs],
'around_id': around_id or None,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/search', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_search(self, **kw):
"""Busca texto en el historial con otro usuario.
Devuelve los mensajes que coinciden (más recientes primero) con el
texto y el fragmento resaltado del lado del cliente.
"""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
q = (data.get('q') or '').strip()
limit = min(max(int(data.get('limit', 50) or 50), 1), 100)
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
if len(q) < 2:
return json_response({'status': 'error', 'message': 'Escribe al menos 2 caracteres'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
Msg = request.env['skeen.chat.message'].sudo()
msgs = Msg.search(self._pair_domain(me.id, other_id) + [('body', 'ilike', q)],
order='id desc', limit=limit)
results = [self._message_to_dict(m, me.id) for m in msgs]
return json_response({
'status': 'success',
'query': q,
'count': len(results),
'results': results,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/send', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_send(self, **kw):
"""Enviar mensaje directo a otro usuario."""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
body = (data.get('message') or '').strip()
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
if not body:
return json_response({'status': 'error', 'message': 'El mensaje no puede estar vacío'}, 400)
if len(body) > MAX_BODY:
return json_response(
{'status': 'error', 'message': f'El mensaje excede {MAX_BODY} caracteres'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
msg = request.env['skeen.chat.message'].sudo().create({
'sender_id': me.id,
'recipient_id': other_id,
'body': body,
})
return json_response({
'status': 'success',
'message': self._message_to_dict(msg, me.id),
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_unread_count(self, **kw):
"""Total global de mensajes no leídos del usuario (badge del menú).
Mismo universo que /chat/users: sólo remitentes ACTIVOS, para que el
badge cuadre con la suma de los contadores de la bandeja.
"""
try:
me = request.frontend_user
activos = request.env['skeen.frontend.user'].sudo().search([('active', '=', True)]).ids
total = request.env['skeen.chat.message'].sudo().search_count([
('recipient_id', '=', me.id),
('is_read', '=', False),
('sender_id', 'in', activos),
])
return json_response({'status': 'success', 'unread': total})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)