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.
282 lines
13 KiB
Python
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)
|