Oleada UX 7: Chat (presencia, busqueda en historial) y WACRM (metricas para gerencia, notas internas y etiquetas que sobreviven al sync) + fixes de concurrencia

This commit is contained in:
2026-09-17 23:46:40 +00:00
parent 31c92ac419
commit c38783eb31
13 changed files with 1589 additions and 36 deletions

View File

@@ -6,7 +6,7 @@ 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
from odoo import http, fields
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import require_role
@@ -15,10 +15,44 @@ from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _pars
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.
Se hace con SQL directo dentro de un savepoint: el frontend consulta
varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y dos
UPDATE concurrentes sobre la misma fila abortan la transacción en
REPEATABLE READ ("could not serialize access due to concurrent update").
Con el savepoint ese fallo se descarta y la petición sigue; además sólo
se escribe si el último registro tiene más de TOUCH_INTERVAL segundos,
que es lo que evita la mayor parte de los choques.
"""
try:
ahora = fields.Datetime.now()
limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL)
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,
@@ -30,6 +64,36 @@ class SkeenChatController(http.Controller):
'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):
@@ -79,6 +143,8 @@ class SkeenChatController(http.Controller):
'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:
@@ -87,7 +153,8 @@ class SkeenChatController(http.Controller):
@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).
"""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.
"""
@@ -100,15 +167,19 @@ class SkeenChatController(http.Controller):
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 = [
'|', '&', ('sender_id', '=', me.id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me.id),
]
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))
Msg = request.env['skeen.chat.message'].sudo()
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('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:
@@ -116,6 +187,41 @@ class SkeenChatController(http.Controller):
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)
@@ -154,11 +260,18 @@ class SkeenChatController(http.Controller):
@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ú)."""
"""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),
('recipient_id', '=', me.id),
('is_read', '=', False),
('sender_id', 'in', activos),
])
return json_response({'status': 'success', 'unread': total})
except Exception as e: