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.
This commit is contained in:
2026-09-30 01:26:37 +00:00
parent 5a16f694de
commit 85a281add1
6 changed files with 120 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, useSearchParams } from 'react-router-dom';
import { MENU_ITEMS } from './lib/menu-items';
import { ToastProvider, Button } from './components/ui'; import { ToastProvider, Button } from './components/ui';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import { AuthProvider, useAuth } from './lib/auth'; import { AuthProvider, useAuth } from './lib/auth';
@@ -62,6 +63,28 @@ const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href
return <>{children}</>; return <>{children}</>;
}; };
// Reportes se gatea por pestana: la matriz guarda '/reportes?tab=xxx' y puede
// conceder una sola (p. ej. Horas agenda al medico, Exportar a cosmetologia).
// El guard anterior solo miraba '/reportes', asi que el menu ofrecia la pestana
// y la ruta rebotaba a '/'. Se resuelve la pestana real del URL.
const ReportesRoute: FC = () => {
const { user, loading, bootError, retryBoot, canSee } = useAuth();
const [sp] = useSearchParams();
if (loading) return <FullScreenLoader />;
if (bootError) return <BootErrorScreen onRetry={retryBoot} />;
if (!user) return <Navigate to="/login" replace />;
if (canSee('/reportes')) return <Reportes />;
const tab = sp.get('tab');
if (tab && canSee(`/reportes?tab=${tab}`)) return <Reportes />;
// Sin acceso al modulo completo: si la matriz le concedio alguna pestana,
// aterriza en la primera permitida en vez de rebotar a '/'.
const primera = MENU_ITEMS
.map((it) => it.href)
.find((href) => href.startsWith('/reportes?tab=') && canSee(href));
if (primera) return <Navigate to={primera} replace />;
return <Navigate to="/" replace />;
};
const LoginRoute: FC = () => { const LoginRoute: FC = () => {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) return <FullScreenLoader />; if (loading) return <FullScreenLoader />;
@@ -124,7 +147,7 @@ const App: FC = () => {
<Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} /> <Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} />
<Route path="/tickets" element={<RequireAuth href="/tickets"><Tickets /></RequireAuth>} /> <Route path="/tickets" element={<RequireAuth href="/tickets"><Tickets /></RequireAuth>} />
<Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} /> <Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} />
<Route path="/reportes" element={<RequireAuth href="/reportes"><Reportes /></RequireAuth>} /> <Route path="/reportes" element={<ReportesRoute />} />
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} /> <Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} /> <Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} /> <Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />

View File

@@ -35,6 +35,7 @@ import {
CircleDollarSign, CircleDollarSign,
TicketCheck, TicketCheck,
Layers, Layers,
Download,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Modal, Input } from './ui'; import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -93,6 +94,9 @@ const menuSections = [
items: [ items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' }, { label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' }, { label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
// Pestanas sueltas que la matriz puede conceder sin dar /reportes completo
{ label: 'Horas agenda', icon: FolderClock, href: '/reportes?tab=horas-agenda' },
{ label: 'Exportar datos', icon: Download, href: '/reportes?tab=exportar' },
{ label: 'Chat', icon: MessageCircle, href: '/chat' }, { label: 'Chat', icon: MessageCircle, href: '/chat' },
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' }, { label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' }, { label: 'Configuración', icon: Settings, href: '/configuracion' },

View File

@@ -36,6 +36,8 @@ import {
PanelLeftOpen, PanelLeftOpen,
Ticket, Ticket,
Layers, Layers,
Clock,
Download,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Modal, Input } from './ui'; import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -93,6 +95,11 @@ const menuGroups = [
items: [ items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' }, { label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' }, { label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
// La matriz puede conceder una pestana suelta sin dar /reportes completo
// (Horas agenda al medico, Exportar a cosmetologia): se listan para que
// el permiso sea alcanzable desde el menu y no solo tecleando la URL.
{ label: 'Horas agenda', icon: Clock, href: '/reportes?tab=horas-agenda' },
{ label: 'Exportar datos', icon: Download, href: '/reportes?tab=exportar' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' }, { label: 'Configuración', icon: Settings, href: '/configuracion' },
], ],
}, },

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } from 'lucide-react'; import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } from 'lucide-react';
import { import {
@@ -114,13 +114,15 @@ const FAMILIAS: ReportFamily[] = [
}, },
]; ];
// 'exportar' no vive en ninguna familia: se abre desde el botón del encabezado
// y se renderiza en la cadena de condiciones de abajo. Se lista aquí para que
// el control de acceso por pestana la trate igual que las demas.
const TABS_FUERA_DE_FAMILIA = ['exportar'];
const TODOS: ReportTab[] = FAMILIAS.flatMap((f) => f.tabs); const TODOS: ReportTab[] = FAMILIAS.flatMap((f) => f.tabs);
const ETIQUETA_TAB: Record<string, string> = Object.fromEntries(TODOS.map((t) => [t.key, t.label])); const ETIQUETA_TAB: Record<string, string> = Object.fromEntries(TODOS.map((t) => [t.key, t.label]));
const familiaDe = (tab: string): string =>
FAMILIAS.find((f) => f.tabs.some((t) => t.key === tab))?.key || FAMILIAS[0].key;
interface SalesReport { interface SalesReport {
total_sales: number; total_sales: number;
total_paid: number; total_paid: number;
@@ -140,13 +142,40 @@ interface CashReport {
const ReportesContent: FC = () => { const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'general'; const { hasRole, canSee } = useAuth();
const setTab = (t: string) => setSearchParams({ tab: t });
const familiaKey = familiaDe(tab);
const familia = FAMILIAS.find((f) => f.key === familiaKey) || FAMILIAS[0];
const { favs, toggle, isFav } = useReportFavorites();
const { hasRole } = useAuth();
const esAdmin = hasRole('admin'); const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
// '/reportes'. Asi, quien tiene el modulo completo ve todas y quien tiene una
// concedida suelta (Horas agenda al medico, Exportar a cosmetologia) solo esa.
const puedeTab = useCallback((k: string) => canSee(`/reportes?tab=${k}`), [canSee]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
.map((f) => ({ ...f, tabs: f.tabs.filter((t) => puedeTab(t.key)) }))
.filter((f) => f.tabs.length > 0),
[puedeTab]
);
const tabParam = searchParams.get('tab') || '';
const esTabValido = (k: string) =>
k !== '' && (TABS_FUERA_DE_FAMILIA.includes(k) || TODOS.some((t) => t.key === k)) && puedeTab(k);
const tab = esTabValido(tabParam)
? tabParam
: familiasVisibles[0]?.tabs[0]?.key
|| (esTabValido('exportar') ? 'exportar' : 'general');
const setTab = (t: string) => setSearchParams({ tab: t });
const familiaKey = familiasVisibles.find((f) => f.tabs.some((t) => t.key === tab))?.key
|| familiasVisibles[0]?.key || FAMILIAS[0].key;
const familia = familiasVisibles.find((f) => f.key === familiaKey) || FAMILIAS[0];
const { favs, toggle, isFav } = useReportFavorites();
// Un favorito fijado antes de cambiar la matriz no debe ofrecer una pestana
// que el rol ya no tiene asignada.
const favsVisibles = favs.filter((k) => puedeTab(k));
// Si el URL pedía una pestana no concedida, la página renderiza otra: se
// reescribe el query para que la dirección refleje lo que se está viendo.
useEffect(() => {
if (tab !== tabParam) setSearchParams({ tab });
}, [tab, tabParam, setSearchParams]);
// Rango global del módulo (persistente, compartido por todas las pestañas) // Rango global del módulo (persistente, compartido por todas las pestañas)
const { start, end, preset, setRange } = useGlobalReportRange(); const { start, end, preset, setRange } = useGlobalReportRange();
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]); const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
@@ -264,14 +293,16 @@ const ReportesContent: FC = () => {
Exportar Exportar
</Button> </Button>
)} )}
<Button {puedeTab('exportar') && (
variant={tab === 'exportar' ? 'primary' : 'outline'} <Button
onClick={() => setTab('exportar')} variant={tab === 'exportar' ? 'primary' : 'outline'}
title="Exportar listas de pacientes (correos, VIP, etiquetas)" onClick={() => setTab('exportar')}
> title="Exportar listas de pacientes (correos, VIP, etiquetas)"
<Share2 size={16} className="mr-2" /> >
Exportar datos <Share2 size={16} className="mr-2" />
</Button> Exportar datos
</Button>
)}
</PageHeader> </PageHeader>
{/* Rango de fecha global (aplica a todas las pestañas) */} {/* Rango de fecha global (aplica a todas las pestañas) */}
@@ -318,14 +349,14 @@ const ReportesContent: FC = () => {
</Card> </Card>
{/* Favoritos por usuario */} {/* Favoritos por usuario */}
{favs.length > 0 && ( {favsVisibles.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<p className="text-xs font-medium text-theme-muted uppercase mb-2 inline-flex items-center gap-1.5"> <p className="text-xs font-medium text-theme-muted uppercase mb-2 inline-flex items-center gap-1.5">
<Star size={12} className="text-amber-500" fill="currentColor" /> <Star size={12} className="text-amber-500" fill="currentColor" />
Favoritos Favoritos
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{favs.map((k) => ( {favsVisibles.map((k) => (
<span <span
key={k} key={k}
className={`inline-flex items-center rounded-full border overflow-hidden text-xs font-medium ${ className={`inline-flex items-center rounded-full border overflow-hidden text-xs font-medium ${
@@ -355,7 +386,7 @@ const ReportesContent: FC = () => {
{/* Familias */} {/* Familias */}
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{FAMILIAS.map((f) => { {familiasVisibles.map((f) => {
const Icono = f.icon; const Icono = f.icon;
const activa = f.key === familiaKey; const activa = f.key === familiaKey;
return ( return (

View File

@@ -27,17 +27,20 @@ class SkeenChatController(http.Controller):
def _touch(self, me): def _touch(self, me):
"""Marca actividad del usuario (presencia). Nunca debe romper la petición. """Marca actividad del usuario (presencia). Nunca debe romper la petición.
Se hace con SQL directo dentro de un savepoint: el frontend consulta El frontend consulta varios endpoints de chat en paralelo (bandeja,
varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y dos badge, heartbeat) y todos llaman aquí. Por eso se corta antes de tocar
UPDATE concurrentes sobre la misma fila abortan la transacción en la fila cuando el registro es reciente (TOUCH_INTERVAL): el UPDATE se
REPEATABLE READ ("could not serialize access due to concurrent update"). ejecutaba aunque no aplicara ningún cambio y abortaba la transacción de
Con el savepoint ese fallo se descarta y la petición sigue; además sólo otra petición concurrente ("could not serialize access due to concurrent
se escribe si el último registro tiene más de TOUCH_INTERVAL segundos, update", 211 veces en un barrido de 260 visitas). El savepoint sigue
que es lo que evita la mayor parte de los choques. ahí por si dos peticiones pasan el corte en el mismo instante; su fallo
se descarta y la petición continúa.
""" """
try: try:
ahora = fields.Datetime.now() ahora = fields.Datetime.now()
limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL) limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL)
if me.last_seen and me.last_seen >= limite:
return
cr = request.env.cr cr = request.env.cr
with cr.savepoint(): with cr.savepoint():
cr.execute( cr.execute(

View File

@@ -15,7 +15,7 @@ import time
from odoo.http import request, Response from odoo.http import request, Response
from ..roles import ROLE_RANKS from ..roles import ROLE_RANKS, get_menu_roles
TTL_SECONDS = 60 * 60 * 12 # 12 horas TTL_SECONDS = 60 * 60 * 12 # 12 horas
SECRET_KEY = 'skeen.frontend.secret' SECRET_KEY = 'skeen.frontend.secret'
@@ -36,6 +36,26 @@ MEDICO_BLOCKED_PREFIXES = (
) )
def _medico_reporte_permitido(env, path):
"""True si la matriz de menus concede al medico ESA pestana de reportes.
El veto de 'medico' sobre /reports/ es por diseno (no ve dinero), pero la
matriz permite habilitar pestañas puntuales (/reportes?tab=horas-agenda).
Sin este puente el menu mostraria la opcion y la API la rechazaria con 403.
Las demas rutas vetadas (sales, payments, wallets...) siguen cerradas.
"""
prefijo = '/skeen/frontend/v1/reports/'
if not path.startswith(prefijo):
return False
tab = path[len(prefijo):].strip('/')
if not tab or '/' in tab:
return False
matriz = get_menu_roles(env)
if 'medico' in (matriz.get('/reportes') or []):
return True
return 'medico' in (matriz.get('/reportes?tab=%s' % tab) or [])
def _cors_headers(): def _cors_headers():
return { return {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -156,7 +176,7 @@ def require_role(min_role='lectura'):
request.httprequest.path == p or request.httprequest.path.startswith( request.httprequest.path == p or request.httprequest.path.startswith(
p if p.endswith('/') else p + '/') p if p.endswith('/') else p + '/')
for p in MEDICO_BLOCKED_PREFIXES for p in MEDICO_BLOCKED_PREFIXES
): ) and not _medico_reporte_permitido(request.env, request.httprequest.path):
return _json({'status': 'error', 'message': 'Sin permiso'}, 403) return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
request.frontend_user = user request.frontend_user = user
return func(self, *args, **kw) return func(self, *args, **kw)