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:
@@ -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>} />
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,6 +293,7 @@ const ReportesContent: FC = () => {
|
|||||||
Exportar
|
Exportar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{puedeTab('exportar') && (
|
||||||
<Button
|
<Button
|
||||||
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
||||||
onClick={() => setTab('exportar')}
|
onClick={() => setTab('exportar')}
|
||||||
@@ -272,6 +302,7 @@ const ReportesContent: FC = () => {
|
|||||||
<Share2 size={16} className="mr-2" />
|
<Share2 size={16} className="mr-2" />
|
||||||
Exportar datos
|
Exportar datos
|
||||||
</Button>
|
</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 (
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user