permisos de agenda: selector de acciones por usuario (ver, agendar, editar, eliminar, visitas)
- roles.py: AGENDA_PERMS, AGENDA_PERM_KEYS, WRITE_RANK_MIN, agenda_defaults_for, resolve_agenda_perms, AGENDA_STATUS_PERM, AGENDA_STATE_PERM, _agenda_status_perm - frontend_user.py: campo agenda_perms, _agenda_perms_list(), to_public_dict() expone agenda_perms y agenda_perms_custom - auth.py: require_agenda(perm) decorador + agenda_perm_error() - frontend.py: @require_agenda aplicado a 22 endpoints + branch en update_appointment_status por accion - auth_users_update: whitelist acepta agenda_perms - Odoo upgrade: columna skeen_frontend_user.agenda_perms - Frontend: types AgendaPerm, AGENDA_PERM_ITEMS, canAgenda() en auth.tsx - UsuariosPanel: checkboxes de permisos de agenda en modal de permisos - Agenda.tsx: gates por permiso en crear/editar/eliminar/visitas/dragdrop/bloqueos - EstadoChecklist/BloqueosModal/ListaEspera: hide actions without permission Verificacion API: 18/18 tests OK (admin hereda, recorte=403, lect=403, restore=null)
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
|||||||
MobileCard,
|
MobileCard,
|
||||||
toast,
|
toast,
|
||||||
} from './ui';
|
} from './ui';
|
||||||
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||||
import { ROLE_LABELS, useAuth } from '../lib/auth';
|
import { ROLE_LABELS, useAuth } from '../lib/auth';
|
||||||
import { MENU_ITEMS } from '../lib/menu-items';
|
import { MENU_ITEMS } from '../lib/menu-items';
|
||||||
|
|
||||||
@@ -44,6 +44,9 @@ const UsuariosPanel: FC = () => {
|
|||||||
const [permUser, setPermUser] = useState<FrontendUser | null>(null);
|
const [permUser, setPermUser] = useState<FrontendUser | null>(null);
|
||||||
const [permCustom, setPermCustom] = useState(false);
|
const [permCustom, setPermCustom] = useState(false);
|
||||||
const [permSelected, setPermSelected] = useState<string[]>([]);
|
const [permSelected, setPermSelected] = useState<string[]>([]);
|
||||||
|
// Permisos de Agenda (independientes de los de menú): lista propia o herencia.
|
||||||
|
const [agendaCustom, setAgendaCustom] = useState(false);
|
||||||
|
const [agendaSelected, setAgendaSelected] = useState<AgendaPerm[]>([]);
|
||||||
const [permSaving, setPermSaving] = useState(false);
|
const [permSaving, setPermSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -149,17 +152,30 @@ const UsuariosPanel: FC = () => {
|
|||||||
setPermUser(u);
|
setPermUser(u);
|
||||||
setPermCustom(custom);
|
setPermCustom(custom);
|
||||||
setPermSelected(custom && u.allowed_menus ? u.allowed_menus : MENU_ITEMS.map((m) => m.href));
|
setPermSelected(custom && u.allowed_menus ? u.allowed_menus : MENU_ITEMS.map((m) => m.href));
|
||||||
|
// 'ver' siempre viene en la lista resuelta del backend; aquí solo se
|
||||||
|
// manejan las 4 acciones configurables.
|
||||||
|
const agenda = Array.isArray(u.agenda_perms_custom) ? u.agenda_perms_custom : null;
|
||||||
|
setAgendaCustom(agenda !== null);
|
||||||
|
setAgendaSelected(agenda ? agenda.filter((p) => p !== 'ver') : ['agendar', 'editar', 'eliminar', 'visitas']);
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePerm = (href: string) => {
|
const togglePerm = (href: string) => {
|
||||||
setPermSelected((prev) => (prev.includes(href) ? prev.filter((h) => h !== href) : [...prev, href]));
|
setPermSelected((prev) => (prev.includes(href) ? prev.filter((h) => h !== href) : [...prev, href]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAgendaPerm = (perm: AgendaPerm) => {
|
||||||
|
setAgendaSelected((prev) => (prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]));
|
||||||
|
};
|
||||||
|
|
||||||
const savePermisos = async () => {
|
const savePermisos = async () => {
|
||||||
if (!permUser) return;
|
if (!permUser) return;
|
||||||
try {
|
try {
|
||||||
setPermSaving(true);
|
setPermSaving(true);
|
||||||
await odooApi.updateFrontendUser(permUser.id, { allowed_menus: permCustom ? permSelected : [] });
|
await odooApi.updateFrontendUser(permUser.id, {
|
||||||
|
allowed_menus: permCustom ? permSelected : [],
|
||||||
|
// null = hereda del rol; la lista siempre lleva 'ver' aunque se desmarque todo
|
||||||
|
agenda_perms: agendaCustom ? ['ver', ...agendaSelected] : null,
|
||||||
|
});
|
||||||
toast.success('Permisos actualizados (se aplican en el próximo inicio de sesión del usuario)');
|
toast.success('Permisos actualizados (se aplican en el próximo inicio de sesión del usuario)');
|
||||||
setPermUser(null);
|
setPermUser(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -372,6 +388,51 @@ const UsuariosPanel: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Permisos de acción dentro de la Agenda */}
|
||||||
|
<div className="pt-4 border-t border-theme-border space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-theme-heading">Agenda</h4>
|
||||||
|
<span className="text-xs text-theme-muted">
|
||||||
|
{agendaCustom ? 'Permisos propios' : 'Hereda del rol'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!agendaCustom}
|
||||||
|
onChange={(e) => setAgendaCustom(!e.target.checked)}
|
||||||
|
className="rounded border-theme-border-strong"
|
||||||
|
/>
|
||||||
|
Permitir todo lo que deja el rol (default)
|
||||||
|
</label>
|
||||||
|
{agendaCustom && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{AGENDA_PERM_ITEMS.map((item) => {
|
||||||
|
const fijo = item.key === 'ver';
|
||||||
|
const activo = fijo || agendaSelected.includes(item.key);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={item.key}
|
||||||
|
className={`flex items-start gap-2 p-2.5 border border-theme-border rounded-lg text-sm ${fijo ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-theme-bg'} text-theme-heading`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activo}
|
||||||
|
disabled={fijo}
|
||||||
|
onChange={() => !fijo && toggleAgendaPerm(item.key)}
|
||||||
|
className="mt-0.5 rounded border-theme-border-strong"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
<span className="block text-xs text-theme-muted">{item.nota}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ interface Props {
|
|||||||
ownDoctorId?: number | null;
|
ownDoctorId?: number | null;
|
||||||
/** Precarga del cierre rápido desde la cabecera de una columna */
|
/** Precarga del cierre rápido desde la cabecera de una columna */
|
||||||
initial?: BloqueoQuickClose | null;
|
initial?: BloqueoQuickClose | null;
|
||||||
|
/** Sin permiso de 'Agendar cita': el modal queda en solo consulta */
|
||||||
|
puedeGestionar?: boolean;
|
||||||
/** Avisa a la agenda para recargar sus bloqueos */
|
/** Avisa a la agenda para recargar sus bloqueos */
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BloqueosModal: FC<Props> = ({
|
const BloqueosModal: FC<Props> = ({
|
||||||
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, onChanged,
|
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, puedeGestionar = true, onChanged,
|
||||||
}) => {
|
}) => {
|
||||||
const [doctorIds, setDoctorIds] = useState<number[]>([]);
|
const [doctorIds, setDoctorIds] = useState<number[]>([]);
|
||||||
const [dateFrom, setDateFrom] = useState(date);
|
const [dateFrom, setDateFrom] = useState(date);
|
||||||
@@ -271,7 +273,13 @@ const BloqueosModal: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Alta / edición */}
|
{/* Alta / edición */}
|
||||||
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
|
{!puedeGestionar && (
|
||||||
|
<p className="text-xs text-theme-muted bg-theme-bg rounded-lg p-2.5">
|
||||||
|
Solo consulta: tu perfil no tiene permiso de <span className="font-medium">Agendar cita</span>,
|
||||||
|
que es el que habilita cerrar y reabrir agendas.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`p-3 bg-theme-bg rounded-xl space-y-3 ${puedeGestionar ? '' : 'hidden'}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-theme-muted uppercase">
|
<p className="text-xs font-medium text-theme-muted uppercase">
|
||||||
{editing ? `Editando bloqueo #${editing.id}` : 'Nuevo bloqueo'}
|
{editing ? `Editando bloqueo #${editing.id}` : 'Nuevo bloqueo'}
|
||||||
@@ -472,7 +480,7 @@ const BloqueosModal: FC<Props> = ({
|
|||||||
{b.motivo && <span className="text-theme-muted"> {b.motivo}</span>}
|
{b.motivo && <span className="text-theme-muted"> {b.motivo}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 shrink-0">
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
{b.grupo && (
|
{puedeGestionar && b.grupo && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void remove(b, true)}
|
onClick={() => void remove(b, true)}
|
||||||
@@ -482,12 +490,16 @@ const BloqueosModal: FC<Props> = ({
|
|||||||
serie
|
serie
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{puedeGestionar && (
|
||||||
|
<>
|
||||||
<Button variant="ghost" size="sm" onClick={() => startEdit(b)} title="Editar">
|
<Button variant="ghost" size="sm" onClick={() => startEdit(b)} title="Editar">
|
||||||
<Pencil size={14} className="text-theme-heading" />
|
<Pencil size={14} className="text-theme-heading" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => void remove(b)} title="Eliminar">
|
<Button variant="ghost" size="sm" onClick={() => void remove(b)} title="Eliminar">
|
||||||
<Trash2 size={14} className="text-rose-500" />
|
<Trash2 size={14} className="text-rose-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface Props {
|
|||||||
onChange: (action: string, state?: string) => void;
|
onChange: (action: string, state?: string) => void;
|
||||||
/** Oculta la bitácora (vista compacta) */
|
/** Oculta la bitácora (vista compacta) */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** Permisos de Agenda: Cancelar exige 'Eliminar citas', No se presentó 'Editar citas' */
|
||||||
|
puedeCancelar?: boolean;
|
||||||
|
puedeNoShow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +36,7 @@ interface Props {
|
|||||||
* checklist y piden confirmación. La confirmación telefónica y la llegada
|
* checklist y piden confirmación. La confirmación telefónica y la llegada
|
||||||
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
|
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
|
||||||
*/
|
*/
|
||||||
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact }) => {
|
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact, puedeCancelar = true, puedeNoShow = true }) => {
|
||||||
const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null);
|
const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null);
|
||||||
const idx = stepIndex(apt.state);
|
const idx = stepIndex(apt.state);
|
||||||
const fueraDelFlujo = idx < 0;
|
const fueraDelFlujo = idx < 0;
|
||||||
@@ -117,6 +120,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
|
|||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
<span className="ml-auto flex items-center gap-2">
|
<span className="ml-auto flex items-center gap-2">
|
||||||
|
{puedeNoShow && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmarSalida('no_show')}
|
onClick={() => setConfirmarSalida('no_show')}
|
||||||
@@ -127,6 +131,8 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
|
|||||||
<UserX size={13} />
|
<UserX size={13} />
|
||||||
No se presentó
|
No se presentó
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{puedeCancelar && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmarSalida('cancel')}
|
onClick={() => setConfirmarSalida('cancel')}
|
||||||
@@ -137,6 +143,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
|
|||||||
<XCircle size={13} />
|
<XCircle size={13} />
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface PanelProps {
|
|||||||
onQuitar: (entry: ListaEspera) => void;
|
onQuitar: (entry: ListaEspera) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
/** Permiso 'Agendar cita': sin él el panel es solo consulta */
|
||||||
|
puedeGestionar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +38,7 @@ interface PanelProps {
|
|||||||
* prioridad y antigüedad, con acciones de agendar / avisar / quitar.
|
* prioridad y antigüedad, con acciones de agendar / avisar / quitar.
|
||||||
*/
|
*/
|
||||||
export const ListaEsperaPanel: FC<PanelProps> = ({
|
export const ListaEsperaPanel: FC<PanelProps> = ({
|
||||||
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh,
|
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [abierto, setAbierto] = useState(true);
|
const [abierto, setAbierto] = useState(true);
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +61,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
|
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
|
||||||
Actualizar
|
Actualizar
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={onAdd}>
|
<Button size="sm" variant="outline" onClick={onAdd} disabled={!puedeGestionar}>
|
||||||
<Plus size={14} className="mr-1" />
|
<Plus size={14} className="mr-1" />
|
||||||
Agregar paciente
|
Agregar paciente
|
||||||
</Button>
|
</Button>
|
||||||
@@ -106,6 +108,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onAgendar(e)}
|
onClick={() => onAgendar(e)}
|
||||||
loading={loadingAgendaId === e.id}
|
loading={loadingAgendaId === e.id}
|
||||||
|
disabled={!puedeGestionar}
|
||||||
title="Agendar con el paciente precargado"
|
title="Agendar con el paciente precargado"
|
||||||
>
|
>
|
||||||
<CalendarPlus size={14} className="mr-1" />
|
<CalendarPlus size={14} className="mr-1" />
|
||||||
@@ -125,6 +128,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] text-theme-muted">Sin teléfono</span>
|
<span className="text-[11px] text-theme-muted">Sin teléfono</span>
|
||||||
)}
|
)}
|
||||||
|
{puedeGestionar && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onQuitar(e)}
|
onClick={() => onQuitar(e)}
|
||||||
@@ -134,6 +138,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
|
|||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
Quitar
|
Quitar
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm } from '../services/odoo';
|
||||||
|
|
||||||
const TOKEN_KEY = 'skeen_token';
|
const TOKEN_KEY = 'skeen_token';
|
||||||
const USER_KEY = 'skeen_user';
|
const USER_KEY = 'skeen_user';
|
||||||
@@ -75,6 +75,7 @@ interface AuthContextValue {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
hasRole: (min: FrontendRole) => boolean;
|
hasRole: (min: FrontendRole) => boolean;
|
||||||
canSee: (href: string) => boolean;
|
canSee: (href: string) => boolean;
|
||||||
|
canAgenda: (perm: AgendaPerm) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@@ -219,8 +220,19 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Acciones dentro de la Agenda. El backend resuelve la herencia del rol y
|
||||||
|
// manda la lista efectiva en user.agenda_perms; el fallback por rango cubre
|
||||||
|
// la sesión cacheada anterior al despliegue.
|
||||||
|
const canAgenda = (perm: AgendaPerm): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
const todas: AgendaPerm[] = ['ver', 'agendar', 'editar', 'eliminar', 'visitas'];
|
||||||
|
const perms = user.agenda_perms
|
||||||
|
?? (ROLE_RANK[user.role] >= ROLE_RANK.recepcion ? todas : ['ver']);
|
||||||
|
return perms.includes(perm);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee }}>
|
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
badgeForAppointmentState,
|
badgeForAppointmentState,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData } from '../services/odoo';
|
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { todayLocal } from '../lib/utils';
|
import { todayLocal } from '../lib/utils';
|
||||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||||
@@ -139,7 +139,7 @@ const QUARTER_TIME_OPTIONS = (() => {
|
|||||||
const Agenda: FC = () => {
|
const Agenda: FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user, canAgenda } = useAuth();
|
||||||
const { openPatient } = usePatientDrawer();
|
const { openPatient } = usePatientDrawer();
|
||||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||||
@@ -149,6 +149,21 @@ const Agenda: FC = () => {
|
|||||||
const isCosmetologa = user?.role === 'cosmetologia';
|
const isCosmetologa = user?.role === 'cosmetologia';
|
||||||
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
|
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
|
||||||
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
|
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
|
||||||
|
// Permisos de acción dentro de la Agenda (selector por usuario, resueltos
|
||||||
|
// en el backend). El guard real está en la API; aquí solo se oculta lo que
|
||||||
|
// no se puede usar, para no ofrecer botones que devuelven 403.
|
||||||
|
const puedeAgendar = canAgenda('agendar');
|
||||||
|
const puedeEditar = canAgenda('editar');
|
||||||
|
const puedeEliminar = canAgenda('eliminar');
|
||||||
|
const puedeVisitas = canAgenda('visitas');
|
||||||
|
// Aviso uniforme cuando una acción está recortada (el backend responde 403
|
||||||
|
// de todas formas; esto evita el round-trip y explica por qué).
|
||||||
|
const exigePermiso = (perm: AgendaPerm): boolean => {
|
||||||
|
if (canAgenda(perm)) return false;
|
||||||
|
const etiqueta = AGENDA_PERM_ITEMS.find((p) => p.key === perm)?.label ?? perm;
|
||||||
|
toast.error(`Sin permiso: ${etiqueta}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
@@ -503,6 +518,8 @@ const Agenda: FC = () => {
|
|||||||
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
||||||
const handleDrop = async (doctorId: number | null, slot: number) => {
|
const handleDrop = async (doctorId: number | null, slot: number) => {
|
||||||
if (!dragApt) return;
|
if (!dragApt) return;
|
||||||
|
if (exigePermiso('editar')) { setDragApt(null); return; }
|
||||||
|
if (exigePermiso('editar')) { setDragApt(null); return; }
|
||||||
const apt = dragApt;
|
const apt = dragApt;
|
||||||
setDragApt(null);
|
setDragApt(null);
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
@@ -544,7 +561,7 @@ const Agenda: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const chipDragProps = (apt: Appointment) =>
|
const chipDragProps = (apt: Appointment) =>
|
||||||
DRAGGABLE_STATES.includes(apt.state)
|
DRAGGABLE_STATES.includes(apt.state) && puedeEditar
|
||||||
? {
|
? {
|
||||||
draggable: true,
|
draggable: true,
|
||||||
onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setTooltip(null); setDragApt(apt); },
|
onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setTooltip(null); setDragApt(apt); },
|
||||||
@@ -939,6 +956,7 @@ const Agenda: FC = () => {
|
|||||||
* la cita; si se cancela el modal, simplemente no se toca nada.
|
* la cita; si se cancela el modal, simplemente no se toca nada.
|
||||||
*/
|
*/
|
||||||
const agendarDesdeLista = (entry: ListaEspera) => {
|
const agendarDesdeLista = (entry: ListaEspera) => {
|
||||||
|
if (exigePermiso('agendar')) return;
|
||||||
setPickedPatient({ id: String(entry.patient_id), name: entry.patient });
|
setPickedPatient({ id: String(entry.patient_id), name: entry.patient });
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
@@ -1100,7 +1118,34 @@ const Agenda: FC = () => {
|
|||||||
fetchSlots(form.service_id, form.date, form.doctor_id, form.duration);
|
fetchSlots(form.service_id, form.date, form.doctor_id, form.duration);
|
||||||
}, [form.service_id, form.date, form.doctor_id, form.duration, fetchSlots]);
|
}, [form.service_id, form.date, form.doctor_id, form.duration, fetchSlots]);
|
||||||
|
|
||||||
|
// Permiso de Agenda que exige cada acción de estado. Espejo de
|
||||||
|
// AGENDA_STATUS_PERM / AGENDA_STATE_PERM en el backend: si aquí no cuadra,
|
||||||
|
// la API devuelve 403 igual, así que solo evita el click inútil.
|
||||||
|
const permisoParaAccion = (action: string, apt: Appointment | null, destino?: string): AgendaPerm => {
|
||||||
|
if (action === 'arrive') return 'visitas';
|
||||||
|
if (action === 'cancel') return 'eliminar';
|
||||||
|
if (action === 'waitlist') return 'agendar';
|
||||||
|
if (action === 'next' && apt) {
|
||||||
|
const actual = stepIndex(apt.state);
|
||||||
|
const siguiente = actual >= 0 && actual < CHECKLIST_STEPS.length - 1 ? CHECKLIST_STEPS[actual + 1].state : null;
|
||||||
|
// 'next' solo recorre CHECKLIST_STEPS: el único destino especial es la llegada.
|
||||||
|
if (siguiente === 'arrived') return 'visitas';
|
||||||
|
}
|
||||||
|
if (action === 'set_state') {
|
||||||
|
if (destino === 'arrived') return 'visitas';
|
||||||
|
if (destino === 'cancelled') return 'eliminar';
|
||||||
|
}
|
||||||
|
return 'editar';
|
||||||
|
};
|
||||||
|
|
||||||
const handleStatus = async (id: number, action: string, state?: string) => {
|
const handleStatus = async (id: number, action: string, state?: string) => {
|
||||||
|
const aptParaPermiso = appointments.find((a) => a.id === id) || (detailApt?.id === id ? detailApt : null);
|
||||||
|
const permiso = permisoParaAccion(action, aptParaPermiso, state);
|
||||||
|
if (!canAgenda(permiso)) {
|
||||||
|
const etiqueta = AGENDA_PERM_ITEMS.find((p) => p.key === permiso)?.label ?? permiso;
|
||||||
|
toast.error(`Sin permiso: ${etiqueta}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setUpdating(id);
|
setUpdating(id);
|
||||||
// Datos del hueco que se libera (para avisar a la lista de espera)
|
// Datos del hueco que se libera (para avisar a la lista de espera)
|
||||||
@@ -1168,6 +1213,7 @@ const Agenda: FC = () => {
|
|||||||
|
|
||||||
const saveAptEdit = async () => {
|
const saveAptEdit = async () => {
|
||||||
if (!detailApt) return;
|
if (!detailApt) return;
|
||||||
|
if (exigePermiso('editar')) return;
|
||||||
if (!editForm.date || !editForm.time) {
|
if (!editForm.date || !editForm.time) {
|
||||||
toast.error('Fecha y hora son obligatorias');
|
toast.error('Fecha y hora son obligatorias');
|
||||||
return;
|
return;
|
||||||
@@ -1268,6 +1314,7 @@ const Agenda: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePackageFinished = async (id: number, finished: boolean) => {
|
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||||
|
if (exigePermiso('editar')) return;
|
||||||
try {
|
try {
|
||||||
setPkgUpdating(id);
|
setPkgUpdating(id);
|
||||||
await odooApi.packageFinished(id, finished);
|
await odooApi.packageFinished(id, finished);
|
||||||
@@ -1379,6 +1426,7 @@ const Agenda: FC = () => {
|
|||||||
|
|
||||||
// Editar una cita existente: abre el modal de creación precargado y guarda con PUT.
|
// Editar una cita existente: abre el modal de creación precargado y guarda con PUT.
|
||||||
const openEditCita = (apt: Appointment) => {
|
const openEditCita = (apt: Appointment) => {
|
||||||
|
if (exigePermiso('editar')) return;
|
||||||
setQuickMode(false);
|
setQuickMode(false);
|
||||||
setDetailApt(null);
|
setDetailApt(null);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
@@ -1428,6 +1476,7 @@ const Agenda: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
if (exigePermiso('agendar')) return;
|
||||||
setQuickMode(false);
|
setQuickMode(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setPickedPatient(null);
|
setPickedPatient(null);
|
||||||
@@ -1449,6 +1498,7 @@ const Agenda: FC = () => {
|
|||||||
// justDroppedRef: tras un drop real el click posterior se ignora).
|
// justDroppedRef: tras un drop real el click posterior se ignora).
|
||||||
const justDroppedRef = useRef(false);
|
const justDroppedRef = useRef(false);
|
||||||
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
|
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
|
||||||
|
if (exigePermiso('agendar')) return;
|
||||||
setQuickMode(false);
|
setQuickMode(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setPickedPatient(null);
|
setPickedPatient(null);
|
||||||
@@ -1586,7 +1636,7 @@ const Agenda: FC = () => {
|
|||||||
|
|
||||||
const ActionButtons = ({ apt }: { apt: Appointment }) => (
|
const ActionButtons = ({ apt }: { apt: Appointment }) => (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
{puedeEditar && apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1597,18 +1647,18 @@ const Agenda: FC = () => {
|
|||||||
<CheckCircle size={16} className="text-theme-heading" />
|
<CheckCircle size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{apt.state === 'confirmed' && (
|
{puedeVisitas && apt.state === 'confirmed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleStatus(apt.id, 'arrive')}
|
onClick={() => handleStatus(apt.id, 'arrive')}
|
||||||
disabled={updating === apt.id}
|
disabled={updating === apt.id}
|
||||||
title="Llegada"
|
title="Llegada (genera la visita del día)"
|
||||||
>
|
>
|
||||||
<UserCheck size={16} className="text-blue-600" />
|
<UserCheck size={16} className="text-blue-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{apt.state === 'arrived' && (
|
{puedeEditar && apt.state === 'arrived' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1619,7 +1669,7 @@ const Agenda: FC = () => {
|
|||||||
<Play size={16} className="text-violet-600" />
|
<Play size={16} className="text-violet-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{apt.state !== 'done' && apt.state !== 'cancelled' && (
|
{puedeEditar && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1630,7 +1680,7 @@ const Agenda: FC = () => {
|
|||||||
<CheckCheck size={16} className="text-blue-600" />
|
<CheckCheck size={16} className="text-blue-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(apt.state === 'confirmed' || apt.state === 'arrived') && (
|
{puedeEditar && (apt.state === 'confirmed' || apt.state === 'arrived') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1641,7 +1691,7 @@ const Agenda: FC = () => {
|
|||||||
<UserX size={16} className="text-rose-500" />
|
<UserX size={16} className="text-rose-500" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
{puedeEditar && apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
||||||
apt.package_finished ? (
|
apt.package_finished ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1664,7 +1714,7 @@ const Agenda: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{apt.state !== 'cancelled' && (
|
{puedeEliminar && apt.state !== 'cancelled' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1699,10 +1749,12 @@ const Agenda: FC = () => {
|
|||||||
<Sparkles size={16} className="mr-2" />
|
<Sparkles size={16} className="mr-2" />
|
||||||
Valoración express
|
Valoración express
|
||||||
</Button>
|
</Button>
|
||||||
|
{puedeAgendar && (
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Nueva cita
|
Nueva cita
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1979,6 +2031,7 @@ const Agenda: FC = () => {
|
|||||||
entries={listaEspera.filter((e) => e.state === 'esperando')}
|
entries={listaEspera.filter((e) => e.state === 'esperando')}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingAgendaId={listaAgendando}
|
loadingAgendaId={listaAgendando}
|
||||||
|
puedeGestionar={puedeAgendar}
|
||||||
onAgendar={agendarDesdeLista}
|
onAgendar={agendarDesdeLista}
|
||||||
onQuitar={(e) => void quitarDeListaEspera(e)}
|
onQuitar={(e) => void quitarDeListaEspera(e)}
|
||||||
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}
|
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}
|
||||||
@@ -2599,6 +2652,7 @@ const Agenda: FC = () => {
|
|||||||
isMedico={isMedico}
|
isMedico={isMedico}
|
||||||
ownDoctorId={medicoEmployeeId}
|
ownDoctorId={medicoEmployeeId}
|
||||||
initial={bloqueoQuick}
|
initial={bloqueoQuick}
|
||||||
|
puedeGestionar={puedeAgendar}
|
||||||
onChanged={() => { void load({ silent: true }); }}
|
onChanged={() => { void load({ silent: true }); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2983,6 +3037,8 @@ const Agenda: FC = () => {
|
|||||||
apt={detailApt}
|
apt={detailApt}
|
||||||
updating={updating === detailApt.id}
|
updating={updating === detailApt.id}
|
||||||
history={stateHistory}
|
history={stateHistory}
|
||||||
|
puedeCancelar={puedeEliminar}
|
||||||
|
puedeNoShow={puedeEditar}
|
||||||
onChange={(action, state) => handleStatus(detailApt.id, action, state)}
|
onChange={(action, state) => handleStatus(detailApt.id, action, state)}
|
||||||
/>
|
/>
|
||||||
<div className="p-3 bg-theme-bg rounded-xl">
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
@@ -3009,12 +3065,16 @@ const Agenda: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
|
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
|
||||||
|
{puedeEditar && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
||||||
<CalendarClock size={16} className="mr-1.5" /> Reagendar
|
<CalendarClock size={16} className="mr-1.5" /> Reagendar
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{puedeEditar && (
|
||||||
<Button size="sm" variant="outline" onClick={() => openEditCita(detailApt)}>
|
<Button size="sm" variant="outline" onClick={() => openEditCita(detailApt)}>
|
||||||
<Pencil size={16} className="mr-1.5" /> Editar
|
<Pencil size={16} className="mr-1.5" /> Editar
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
|
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
|
||||||
Solo en estados cobrables (oculto en cancelada/no show). */}
|
Solo en estados cobrables (oculto en cancelada/no show). */}
|
||||||
{!['cancelled', 'no_show'].includes(detailApt.state) && (
|
{!['cancelled', 'no_show'].includes(detailApt.state) && (
|
||||||
|
|||||||
@@ -884,6 +884,17 @@ export interface WacrmLead {
|
|||||||
|
|
||||||
export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'gerencia' | 'marketing' | 'lectura';
|
export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'gerencia' | 'marketing' | 'lectura';
|
||||||
|
|
||||||
|
// Acciones de la Agenda (espejo de AGENDA_PERMS en skeen_whatsapp/roles.py).
|
||||||
|
export type AgendaPerm = 'ver' | 'agendar' | 'editar' | 'eliminar' | 'visitas';
|
||||||
|
|
||||||
|
export const AGENDA_PERM_ITEMS: { key: AgendaPerm; label: string; nota: string }[] = [
|
||||||
|
{ key: 'ver', label: 'Consultar agenda', nota: 'Siempre activa: sin ella no hay pantalla que ver.' },
|
||||||
|
{ key: 'agendar', label: 'Agendar cita', nota: 'Cita normal, cita rápida, lista de espera y cierres de agenda.' },
|
||||||
|
{ key: 'editar', label: 'Editar citas', nota: 'Reprogramar, mover de horario y marcar confirmaciones.' },
|
||||||
|
{ key: 'eliminar', label: 'Eliminar citas', nota: 'Borrar una cita o cancelarla.' },
|
||||||
|
{ key: 'visitas', label: 'Generar visitas', nota: 'Check-in (En cabina) y todo el expediente del día.' },
|
||||||
|
];
|
||||||
|
|
||||||
export interface FrontendUser {
|
export interface FrontendUser {
|
||||||
id: number;
|
id: number;
|
||||||
login: string;
|
login: string;
|
||||||
@@ -893,6 +904,10 @@ export interface FrontendUser {
|
|||||||
last_login?: string | null;
|
last_login?: string | null;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
allowed_menus?: string[] | null;
|
allowed_menus?: string[] | null;
|
||||||
|
// Acciones de Agenda que el backend resolvió (lista efectiva del usuario).
|
||||||
|
agenda_perms?: AgendaPerm[] | null;
|
||||||
|
// Lista propia del usuario; null = hereda del rol. La usa solo el editor.
|
||||||
|
agenda_perms_custom?: AgendaPerm[] | null;
|
||||||
// Solo rol 'medico': hr.employee vinculado (null si no hay vínculo)
|
// Solo rol 'medico': hr.employee vinculado (null si no hay vínculo)
|
||||||
employee_id?: number | null;
|
employee_id?: number | null;
|
||||||
employee_name?: string | null;
|
employee_name?: string | null;
|
||||||
@@ -1305,7 +1320,7 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateFrontendUser(id: number, payload: { name?: string; login?: string; role?: FrontendRole; active?: boolean; password?: string; must_change_password?: boolean; allowed_menus?: string[] | null }): Promise<{ status: string; user: FrontendUser; message?: string }> {
|
async updateFrontendUser(id: number, payload: { name?: string; login?: string; role?: FrontendRole; active?: boolean; password?: string; must_change_password?: boolean; allowed_menus?: string[] | null; agenda_perms?: AgendaPerm[] | null }): Promise<{ status: string; user: FrontendUser; message?: string }> {
|
||||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import time
|
|||||||
|
|
||||||
from odoo.http import request, Response
|
from odoo.http import request, Response
|
||||||
|
|
||||||
from ..roles import ROLE_RANKS, get_menu_roles
|
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms
|
||||||
|
|
||||||
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
||||||
SECRET_KEY = 'skeen.frontend.secret'
|
SECRET_KEY = 'skeen.frontend.secret'
|
||||||
@@ -182,3 +182,46 @@ def require_role(min_role='lectura'):
|
|||||||
return func(self, *args, **kw)
|
return func(self, *args, **kw)
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Permisos de ACCION dentro de la Agenda (selector por usuario).
|
||||||
|
#
|
||||||
|
# require_role sigue siendo el guard de rango: el token no lleva permisos,
|
||||||
|
# asi que no hay listas cacheadas que se puedan desincronizar. Este decorador
|
||||||
|
# REFINA dentro de la agenda y solo puede QUITAR acceso, nunca darlo por
|
||||||
|
# encima del rango del rol.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AGENDA_PERM_LABELS = dict(AGENDA_PERMS)
|
||||||
|
|
||||||
|
|
||||||
|
def agenda_perm_error(user, perm):
|
||||||
|
"""Response 403 si la accion no esta permitida; None si si lo esta."""
|
||||||
|
if user is None or perm not in resolve_agenda_perms(user):
|
||||||
|
label = AGENDA_PERM_LABELS.get(perm, perm)
|
||||||
|
return _json({'status': 'error', 'message': 'Sin permiso: %s' % label}, 403)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_agenda(perm):
|
||||||
|
"""Decorator: exige token valido y la accion de agenda indicada.
|
||||||
|
|
||||||
|
Se apila por DEBAJO de require_role (decorador interno), de modo que el
|
||||||
|
control de rango del rol ya corrió y solo falta refinar la acción concreta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(self, *args, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
user = current_user(request.env)
|
||||||
|
if not user:
|
||||||
|
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||||
|
denied = agenda_perm_error(user, perm)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
request.frontend_user = user
|
||||||
|
return func(self, *args, **kw)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
|
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
|
||||||
|
from .auth import require_agenda, agenda_perm_error
|
||||||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||||||
|
from ..roles import AGENDA_PERM_KEYS
|
||||||
|
|
||||||
|
|
||||||
def json_response(data, status=200):
|
def json_response(data, status=200):
|
||||||
@@ -33,6 +35,35 @@ STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
|
|||||||
# Estados que liberan el horario (no ocupan agenda)
|
# Estados que liberan el horario (no ocupan agenda)
|
||||||
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
|
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Permiso de agenda que exige cada accion del checklist de estado.
|
||||||
|
# 'visitas' = pasar a En cabina (arrive), que es lo que genera la visita del dia.
|
||||||
|
# 'cancel' es la baja de la cita -> eliminar. 'waitlist' mete a la lista de
|
||||||
|
# espera -> agendar. El resto son ediciones sobre la cita.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AGENDA_STATUS_PERM = {
|
||||||
|
'arrive': 'visitas',
|
||||||
|
'cancel': 'eliminar',
|
||||||
|
'waitlist': 'agendar',
|
||||||
|
'confirm': 'editar',
|
||||||
|
'start': 'editar',
|
||||||
|
'done': 'editar',
|
||||||
|
'no_show': 'editar',
|
||||||
|
'back': 'editar',
|
||||||
|
}
|
||||||
|
# Estado destino -> permiso, para 'next' y 'set_state' (no nombran la accion).
|
||||||
|
AGENDA_STATE_PERM = {'arrived': 'visitas', 'cancelled': 'eliminar'}
|
||||||
|
|
||||||
|
|
||||||
|
def _agenda_status_perm(action, target=None):
|
||||||
|
"""Permiso de agenda requerido por un cambio de estado de la cita."""
|
||||||
|
perm = AGENDA_STATUS_PERM.get(action)
|
||||||
|
if perm:
|
||||||
|
return perm
|
||||||
|
if target:
|
||||||
|
return AGENDA_STATE_PERM.get(target, 'editar')
|
||||||
|
return 'editar'
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_body():
|
def _parse_json_body():
|
||||||
"""Lee y parsea el body JSON de la petición"""
|
"""Lee y parsea el body JSON de la petición"""
|
||||||
@@ -306,6 +337,12 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if menus is not None and not isinstance(menus, list):
|
if menus is not None and not isinstance(menus, list):
|
||||||
return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
|
return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
|
||||||
vals['allowed_menus'] = json.dumps(menus) if menus else False
|
vals['allowed_menus'] = json.dumps(menus) if menus else False
|
||||||
|
if 'agenda_perms' in data:
|
||||||
|
perms = data.get('agenda_perms')
|
||||||
|
if perms is not None and not isinstance(perms, list):
|
||||||
|
return json_response({'status': 'error', 'message': 'agenda_perms debe ser una lista o null'}, 400)
|
||||||
|
# None = heredar del rol (dejar el campo vacío)
|
||||||
|
vals['agenda_perms'] = json.dumps(perms) if perms is not None else False
|
||||||
if vals:
|
if vals:
|
||||||
previos = {k: getattr(user, k) for k in vals}
|
previos = {k: getattr(user, k) for k in vals}
|
||||||
user.write(vals)
|
user.write(vals)
|
||||||
@@ -565,6 +602,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def create_appointment(self, **kw):
|
def create_appointment(self, **kw):
|
||||||
"""Crear una o más citas (soporta paquetes de sesiones)"""
|
"""Crear una o más citas (soporta paquetes de sesiones)"""
|
||||||
try:
|
try:
|
||||||
@@ -632,6 +670,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def create_quick_appointment(self, **kw):
|
def create_quick_appointment(self, **kw):
|
||||||
"""Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto).
|
"""Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto).
|
||||||
|
|
||||||
@@ -743,6 +782,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('editar')
|
||||||
def update_appointment(self, appointment_id, **kw):
|
def update_appointment(self, appointment_id, **kw):
|
||||||
try:
|
try:
|
||||||
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
||||||
@@ -778,6 +818,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('eliminar')
|
||||||
def delete_appointment(self, appointment_id, **kw):
|
def delete_appointment(self, appointment_id, **kw):
|
||||||
try:
|
try:
|
||||||
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
||||||
@@ -806,6 +847,19 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
|
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
action = data.get('action')
|
action = data.get('action')
|
||||||
|
# El permiso depende del estado destino: 'next' y 'set_state' no
|
||||||
|
# nombran la accion, asi que se resuelve contra la cita real.
|
||||||
|
_target = None
|
||||||
|
if action == 'next':
|
||||||
|
_idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
|
||||||
|
if 0 <= _idx < len(STATE_FLOW) - 1:
|
||||||
|
_target = STATE_FLOW[_idx + 1]
|
||||||
|
elif action == 'set_state':
|
||||||
|
_target = data.get('state')
|
||||||
|
denied = agenda_perm_error(
|
||||||
|
request.frontend_user, _agenda_status_perm(action, _target))
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
user = request.frontend_user.login
|
user = request.frontend_user.login
|
||||||
note = (data.get('reason') or '').strip()
|
note = (data.get('reason') or '').strip()
|
||||||
if action == 'confirm':
|
if action == 'confirm':
|
||||||
@@ -880,6 +934,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('editar')
|
||||||
def appointment_phone_confirm(self, appointment_id, **kw):
|
def appointment_phone_confirm(self, appointment_id, **kw):
|
||||||
"""Toggle de la 1ra confirmación (telefónica) de una cita.
|
"""Toggle de la 1ra confirmación (telefónica) de una cita.
|
||||||
|
|
||||||
@@ -913,6 +968,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('editar')
|
||||||
def appointment_package_finished(self, appointment_id, **kw):
|
def appointment_package_finished(self, appointment_id, **kw):
|
||||||
try:
|
try:
|
||||||
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
||||||
@@ -931,6 +987,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('editar')
|
||||||
def bulk_phone_confirm(self, **kw):
|
def bulk_phone_confirm(self, **kw):
|
||||||
"""Confirmación telefónica en lote.
|
"""Confirmación telefónica en lote.
|
||||||
|
|
||||||
@@ -1099,6 +1156,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def create_bloqueo(self, **kw):
|
def create_bloqueo(self, **kw):
|
||||||
"""Crea uno o varios bloqueos de agenda.
|
"""Crea uno o varios bloqueos de agenda.
|
||||||
|
|
||||||
@@ -1213,6 +1271,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def update_bloqueo(self, bloqueo_id, **kw):
|
def update_bloqueo(self, bloqueo_id, **kw):
|
||||||
"""Edita un bloqueo existente (fecha, horario, motivo, médico)."""
|
"""Edita un bloqueo existente (fecha, horario, motivo, médico)."""
|
||||||
try:
|
try:
|
||||||
@@ -1255,6 +1314,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def delete_bloqueo(self, bloqueo_id, **kw):
|
def delete_bloqueo(self, bloqueo_id, **kw):
|
||||||
try:
|
try:
|
||||||
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
|
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
|
||||||
@@ -1269,6 +1329,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/bloqueos/grupo/<string:grupo>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/bloqueos/grupo/<string:grupo>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def delete_bloqueo_grupo(self, grupo, **kw):
|
def delete_bloqueo_grupo(self, grupo, **kw):
|
||||||
"""Elimina la serie completa (mismo `grupo`) de bloqueos."""
|
"""Elimina la serie completa (mismo `grupo`) de bloqueos."""
|
||||||
try:
|
try:
|
||||||
@@ -1370,6 +1431,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def create_lista_espera(self, **kw):
|
def create_lista_espera(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -1402,6 +1464,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def update_lista_espera(self, registro_id, **kw):
|
def update_lista_espera(self, registro_id, **kw):
|
||||||
"""Edita la espera o la resuelve (state agendado/descartado, cita_id)."""
|
"""Edita la espera o la resuelve (state agendado/descartado, cita_id)."""
|
||||||
try:
|
try:
|
||||||
@@ -1445,6 +1508,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('agendar')
|
||||||
def delete_lista_espera(self, registro_id, **kw):
|
def delete_lista_espera(self, registro_id, **kw):
|
||||||
try:
|
try:
|
||||||
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
|
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
|
||||||
@@ -1552,6 +1616,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def update_visita(self, visita_id, **kw):
|
def update_visita(self, visita_id, **kw):
|
||||||
try:
|
try:
|
||||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||||
@@ -1567,6 +1632,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/complete', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/complete', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def complete_visita(self, visita_id, **kw):
|
def complete_visita(self, visita_id, **kw):
|
||||||
try:
|
try:
|
||||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||||
@@ -1579,6 +1645,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def cancel_visita(self, visita_id, **kw):
|
def cancel_visita(self, visita_id, **kw):
|
||||||
try:
|
try:
|
||||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||||
@@ -1591,6 +1658,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/consultas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/consultas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def create_consulta(self, **kw):
|
def create_consulta(self, **kw):
|
||||||
"""Consulta médica directa: visita completada sin cita origen"""
|
"""Consulta médica directa: visita completada sin cita origen"""
|
||||||
try:
|
try:
|
||||||
@@ -1618,6 +1686,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def add_visita_insumo(self, visita_id, **kw):
|
def add_visita_insumo(self, visita_id, **kw):
|
||||||
try:
|
try:
|
||||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||||
@@ -1644,6 +1713,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos/<int:line_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos/<int:line_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def delete_visita_insumo(self, visita_id, line_id, **kw):
|
def delete_visita_insumo(self, visita_id, line_id, **kw):
|
||||||
try:
|
try:
|
||||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||||
@@ -1661,6 +1731,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def add_visita_adjunto(self, visita_id, **kw):
|
def add_visita_adjunto(self, visita_id, **kw):
|
||||||
try:
|
try:
|
||||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||||
@@ -1695,6 +1766,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_agenda('visitas')
|
||||||
def delete_visita_adjunto(self, adj_id, **kw):
|
def delete_visita_adjunto(self, adj_id, **kw):
|
||||||
try:
|
try:
|
||||||
adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
|
adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
from odoo import models, fields, api, _
|
from odoo import models, fields, api, _
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
from ..roles import ROLE_SELECTION, ROLE_RANKS
|
from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
@@ -34,6 +34,10 @@ class SkeenFrontendUser(models.Model):
|
|||||||
notes = fields.Text(string='Notas')
|
notes = fields.Text(string='Notas')
|
||||||
allowed_menus = fields.Text(string='Menús Permitidos',
|
allowed_menus = fields.Text(string='Menús Permitidos',
|
||||||
help='JSON array de hrefs visibles; vacío = defaults por rol')
|
help='JSON array de hrefs visibles; vacío = defaults por rol')
|
||||||
|
agenda_perms = fields.Text(
|
||||||
|
string='Permisos de Agenda',
|
||||||
|
help="JSON array con las acciones permitidas dentro de la Agenda "
|
||||||
|
"(ver, agendar, editar, eliminar, visitas). Vacío = heredar del rol.")
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
('unique_login', 'unique(login)', 'El usuario ya existe.'),
|
('unique_login', 'unique(login)', 'El usuario ya existe.'),
|
||||||
@@ -72,6 +76,22 @@ class SkeenFrontendUser(models.Model):
|
|||||||
return None
|
return None
|
||||||
return val if isinstance(val, list) and val else None
|
return val if isinstance(val, list) and val else None
|
||||||
|
|
||||||
|
def _agenda_perms_list(self):
|
||||||
|
"""Lista explícita de acciones de agenda, o None si el usuario hereda del rol.
|
||||||
|
|
||||||
|
A diferencia de allowed_menus, una lista vacía SÍ es un valor válido
|
||||||
|
("desmarcar todo" = solo consultar); por eso solo se devuelve None
|
||||||
|
cuando el campo está vacío o el JSON es inválido.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.agenda_perms:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
val = json.loads(self.agenda_perms)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return val if isinstance(val, list) else None
|
||||||
|
|
||||||
def to_public_dict(self):
|
def to_public_dict(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return {
|
return {
|
||||||
@@ -82,4 +102,8 @@ class SkeenFrontendUser(models.Model):
|
|||||||
'must_change_password': self.must_change_password,
|
'must_change_password': self.must_change_password,
|
||||||
'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None,
|
'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None,
|
||||||
'allowed_menus': self._allowed_menus_list(),
|
'allowed_menus': self._allowed_menus_list(),
|
||||||
|
# Lista resuelta (lo que realmente puede hacer) + la lista propia
|
||||||
|
# cruda, para que el editor de usuarios sepa si está heredando.
|
||||||
|
'agenda_perms': resolve_agenda_perms(self),
|
||||||
|
'agenda_perms_custom': self._agenda_perms_list(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,57 @@ ROLE_RANKS = {
|
|||||||
|
|
||||||
MENU_ROLES_KEY = 'skeen.menu_roles'
|
MENU_ROLES_KEY = 'skeen.menu_roles'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Permisos de ACCIÓN dentro de la Agenda (selector por usuario).
|
||||||
|
#
|
||||||
|
# No son visibilidad de menú: deciden qué se puede HACER una vez dentro.
|
||||||
|
# Claves estables (se guardan como JSON en skeen.frontend.user.agenda_perms).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
AGENDA_PERMS = [
|
||||||
|
('ver', 'Consultar agenda'),
|
||||||
|
('agendar', 'Agendar cita'),
|
||||||
|
('editar', 'Editar citas'),
|
||||||
|
('eliminar', 'Eliminar citas'),
|
||||||
|
('visitas', 'Generar visitas'),
|
||||||
|
]
|
||||||
|
AGENDA_PERM_KEYS = tuple(k for k, _ in AGENDA_PERMS)
|
||||||
|
|
||||||
|
# Acciones que requieren rango de escritura (recepción para arriba): quien no
|
||||||
|
# tiene lista propia configurada hereda esto, que es exactamente lo que ya
|
||||||
|
# permitía @require_role('recepcion'). Así el selector puede QUITAR sin
|
||||||
|
# configurar nada y no cambia el comportamiento actual de nadie.
|
||||||
|
WRITE_RANK_MIN = 'recepcion'
|
||||||
|
|
||||||
|
# Roles sin rango propio en ROLE_RANKS no existen; el default se resuelve por
|
||||||
|
# rango, no por nombre, para no duplicar la lista de roles.
|
||||||
|
|
||||||
|
|
||||||
|
def agenda_defaults_for(user):
|
||||||
|
"""Permisos de agenda por defecto de un usuario (sin lista propia)."""
|
||||||
|
ver = ['ver']
|
||||||
|
if user is None:
|
||||||
|
return list(ver)
|
||||||
|
if user.role == 'admin':
|
||||||
|
return list(AGENDA_PERM_KEYS)
|
||||||
|
if user.role_rank() >= ROLE_RANKS.get(WRITE_RANK_MIN, 50):
|
||||||
|
return list(AGENDA_PERM_KEYS)
|
||||||
|
return list(ver)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_agenda_perms(user):
|
||||||
|
"""Permisos de agenda vigentes: los del usuario si tiene lista, si no el default.
|
||||||
|
|
||||||
|
'ver' nunca se quita: sin él no hay pantalla que mostrar y el resto de las
|
||||||
|
acciones carece de sentido. Se fuerza al subir.
|
||||||
|
"""
|
||||||
|
custom = user._agenda_perms_list() if user is not None else None
|
||||||
|
if custom is None:
|
||||||
|
return agenda_defaults_for(user)
|
||||||
|
valid = [k for k in AGENDA_PERM_KEYS if k in custom]
|
||||||
|
if 'ver' not in valid:
|
||||||
|
valid.insert(0, 'ver')
|
||||||
|
return valid
|
||||||
|
|
||||||
# Defaults = MENU_ROLES de src/lib/auth.tsx (4 roles originales).
|
# Defaults = MENU_ROLES de src/lib/auth.tsx (4 roles originales).
|
||||||
# 'gerencia' (gerencia de la clínica) ve toda la
|
# 'gerencia' (gerencia de la clínica) ve toda la
|
||||||
# operación EXCEPTO /configuracion (solo admin del sistema), /mis-ventas,
|
# operación EXCEPTO /configuracion (solo admin del sistema), /mis-ventas,
|
||||||
|
|||||||
Reference in New Issue
Block a user