diff --git a/frontend/src/components/UsuariosPanel.tsx b/frontend/src/components/UsuariosPanel.tsx index 5c83b63..023ec84 100644 --- a/frontend/src/components/UsuariosPanel.tsx +++ b/frontend/src/components/UsuariosPanel.tsx @@ -12,7 +12,7 @@ import { MobileCard, toast, } 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 { MENU_ITEMS } from '../lib/menu-items'; @@ -44,6 +44,9 @@ const UsuariosPanel: FC = () => { const [permUser, setPermUser] = useState(null); const [permCustom, setPermCustom] = useState(false); const [permSelected, setPermSelected] = useState([]); + // Permisos de Agenda (independientes de los de menú): lista propia o herencia. + const [agendaCustom, setAgendaCustom] = useState(false); + const [agendaSelected, setAgendaSelected] = useState([]); const [permSaving, setPermSaving] = useState(false); const load = useCallback(async () => { @@ -149,17 +152,30 @@ const UsuariosPanel: FC = () => { setPermUser(u); setPermCustom(custom); 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) => { 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 () => { if (!permUser) return; try { 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)'); setPermUser(null); await load(); @@ -372,6 +388,51 @@ const UsuariosPanel: FC = () => { )} + + {/* Permisos de acción dentro de la Agenda */} +
+
+

Agenda

+ + {agendaCustom ? 'Permisos propios' : 'Hereda del rol'} + +
+ + {agendaCustom && ( +
+ {AGENDA_PERM_ITEMS.map((item) => { + const fijo = item.key === 'ver'; + const activo = fijo || agendaSelected.includes(item.key); + return ( + + ); + })} +
+ )} +
diff --git a/frontend/src/components/agenda/BloqueosModal.tsx b/frontend/src/components/agenda/BloqueosModal.tsx index c4c9b4a..4b3580c 100644 --- a/frontend/src/components/agenda/BloqueosModal.tsx +++ b/frontend/src/components/agenda/BloqueosModal.tsx @@ -53,12 +53,14 @@ interface Props { ownDoctorId?: number | null; /** Precarga del cierre rápido desde la cabecera de una columna */ initial?: BloqueoQuickClose | null; + /** Sin permiso de 'Agendar cita': el modal queda en solo consulta */ + puedeGestionar?: boolean; /** Avisa a la agenda para recargar sus bloqueos */ onChanged: () => void; } const BloqueosModal: FC = ({ - isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, onChanged, + isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, puedeGestionar = true, onChanged, }) => { const [doctorIds, setDoctorIds] = useState([]); const [dateFrom, setDateFrom] = useState(date); @@ -271,7 +273,13 @@ const BloqueosModal: FC = ({ >
{/* Alta / edición */} -
+ {!puedeGestionar && ( +

+ Solo consulta: tu perfil no tiene permiso de Agendar cita, + que es el que habilita cerrar y reabrir agendas. +

+ )} +

{editing ? `Editando bloqueo #${editing.id}` : 'Nuevo bloqueo'} @@ -472,7 +480,7 @@ const BloqueosModal: FC = ({ {b.motivo && {b.motivo}} - {b.grupo && ( + {puedeGestionar && b.grupo && ( )} - - + {puedeGestionar && ( + <> + + + + )} ))} diff --git a/frontend/src/components/agenda/EstadoChecklist.tsx b/frontend/src/components/agenda/EstadoChecklist.tsx index 4625741..83165bc 100644 --- a/frontend/src/components/agenda/EstadoChecklist.tsx +++ b/frontend/src/components/agenda/EstadoChecklist.tsx @@ -24,6 +24,9 @@ interface Props { onChange: (action: string, state?: string) => void; /** Oculta la bitácora (vista compacta) */ 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 * siguen sincronizadas con phone_confirmed / arrived_at desde el backend. */ -export const EstadoChecklist: FC = ({ apt, updating, history, onChange, compact }) => { +export const EstadoChecklist: FC = ({ apt, updating, history, onChange, compact, puedeCancelar = true, puedeNoShow = true }) => { const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null); const idx = stepIndex(apt.state); const fueraDelFlujo = idx < 0; @@ -117,6 +120,7 @@ export const EstadoChecklist: FC = ({ apt, updating, history, onChange, c + {puedeNoShow && ( + )} + {puedeCancelar && ( + )}

diff --git a/frontend/src/components/agenda/ListaEspera.tsx b/frontend/src/components/agenda/ListaEspera.tsx index edea55a..be79979 100644 --- a/frontend/src/components/agenda/ListaEspera.tsx +++ b/frontend/src/components/agenda/ListaEspera.tsx @@ -29,6 +29,8 @@ interface PanelProps { onQuitar: (entry: ListaEspera) => void; onAdd: () => 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. */ export const ListaEsperaPanel: FC = ({ - entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, + entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true, }) => { const [abierto, setAbierto] = useState(true); return ( @@ -59,7 +61,7 @@ export const ListaEsperaPanel: FC = ({ - @@ -106,6 +108,7 @@ export const ListaEsperaPanel: FC = ({ size="sm" onClick={() => onAgendar(e)} loading={loadingAgendaId === e.id} + disabled={!puedeGestionar} title="Agendar con el paciente precargado" > @@ -125,15 +128,17 @@ export const ListaEsperaPanel: FC = ({ ) : ( Sin teléfono )} - + {puedeGestionar && ( + + )}
); diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index fac3c1a..ba772d2 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect, useState, useCallback } 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 USER_KEY = 'skeen_user'; @@ -75,6 +75,7 @@ interface AuthContextValue { logout: () => Promise; hasRole: (min: FrontendRole) => boolean; canSee: (href: string) => boolean; + canAgenda: (perm: AgendaPerm) => boolean; } const AuthContext = createContext(undefined); @@ -219,8 +220,19 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { 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 ( - + {children} ); diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index 00bf7a9..4c3d9e1 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -26,7 +26,7 @@ import { badgeForAppointmentState, } from '../components/ui'; 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 { todayLocal } from '../lib/utils'; import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh'; @@ -139,7 +139,7 @@ const QUARTER_TIME_OPTIONS = (() => { const Agenda: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); - const { user } = useAuth(); + const { user, canAgenda } = useAuth(); const { openPatient } = usePatientDrawer(); // 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 @@ -149,6 +149,21 @@ const Agenda: FC = () => { const isCosmetologa = user?.role === 'cosmetologia'; const medicoEmployeeId = isMedico && 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([]); const [services, setServices] = useState([]); const [doctors, setDoctors] = useState([]); @@ -503,6 +518,8 @@ const Agenda: FC = () => { const DRAGGABLE_STATES = ['pending', 'confirmed']; const handleDrop = async (doctorId: number | null, slot: number) => { if (!dragApt) return; + if (exigePermiso('editar')) { setDragApt(null); return; } + if (exigePermiso('editar')) { setDragApt(null); return; } const apt = dragApt; setDragApt(null); setDropTarget(null); @@ -544,7 +561,7 @@ const Agenda: FC = () => { }; const chipDragProps = (apt: Appointment) => - DRAGGABLE_STATES.includes(apt.state) + DRAGGABLE_STATES.includes(apt.state) && puedeEditar ? { draggable: true, 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. */ const agendarDesdeLista = (entry: ListaEspera) => { + if (exigePermiso('agendar')) return; setPickedPatient({ id: String(entry.patient_id), name: entry.patient }); setForm((f) => ({ ...f, @@ -1100,7 +1118,34 @@ const Agenda: FC = () => { fetchSlots(form.service_id, form.date, form.doctor_id, form.duration); }, [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 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 { setUpdating(id); // Datos del hueco que se libera (para avisar a la lista de espera) @@ -1168,6 +1213,7 @@ const Agenda: FC = () => { const saveAptEdit = async () => { if (!detailApt) return; + if (exigePermiso('editar')) return; if (!editForm.date || !editForm.time) { toast.error('Fecha y hora son obligatorias'); return; @@ -1268,6 +1314,7 @@ const Agenda: FC = () => { }; const handlePackageFinished = async (id: number, finished: boolean) => { + if (exigePermiso('editar')) return; try { setPkgUpdating(id); 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. const openEditCita = (apt: Appointment) => { + if (exigePermiso('editar')) return; setQuickMode(false); setDetailApt(null); setEditMode(false); @@ -1428,6 +1476,7 @@ const Agenda: FC = () => { }; const openCreate = () => { + if (exigePermiso('agendar')) return; setQuickMode(false); setEditingId(null); setPickedPatient(null); @@ -1449,6 +1498,7 @@ const Agenda: FC = () => { // justDroppedRef: tras un drop real el click posterior se ignora). const justDroppedRef = useRef(false); const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => { + if (exigePermiso('agendar')) return; setQuickMode(false); setEditingId(null); setPickedPatient(null); @@ -1586,7 +1636,7 @@ const Agenda: FC = () => { const ActionButtons = ({ apt }: { apt: Appointment }) => (
- {apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && ( + {puedeEditar && apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && ( )} - {apt.state === 'confirmed' && ( + {puedeVisitas && apt.state === 'confirmed' && ( )} - {apt.state === 'arrived' && ( + {puedeEditar && apt.state === 'arrived' && ( )} - {apt.state !== 'done' && apt.state !== 'cancelled' && ( + {puedeEditar && apt.state !== 'done' && apt.state !== 'cancelled' && ( )} - {(apt.state === 'confirmed' || apt.state === 'arrived') && ( + {puedeEditar && (apt.state === 'confirmed' || apt.state === 'arrived') && ( )} - {apt.service_category === 'paquete' && apt.state !== 'cancelled' && ( + {puedeEditar && apt.service_category === 'paquete' && apt.state !== 'cancelled' && ( apt.package_finished ? ( ) )} - {apt.state !== 'cancelled' && ( + {puedeEliminar && apt.state !== 'cancelled' && ( + {puedeAgendar && ( + )} @@ -1979,6 +2031,7 @@ const Agenda: FC = () => { entries={listaEspera.filter((e) => e.state === 'esperando')} loading={loading} loadingAgendaId={listaAgendando} + puedeGestionar={puedeAgendar} onAgendar={agendarDesdeLista} onQuitar={(e) => void quitarDeListaEspera(e)} onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }} @@ -2599,6 +2652,7 @@ const Agenda: FC = () => { isMedico={isMedico} ownDoctorId={medicoEmployeeId} initial={bloqueoQuick} + puedeGestionar={puedeAgendar} onChanged={() => { void load({ silent: true }); }} /> @@ -2983,6 +3037,8 @@ const Agenda: FC = () => { apt={detailApt} updating={updating === detailApt.id} history={stateHistory} + puedeCancelar={puedeEliminar} + puedeNoShow={puedeEditar} onChange={(action, state) => handleStatus(detailApt.id, action, state)} />
@@ -3009,12 +3065,16 @@ const Agenda: FC = () => {
+ {puedeEditar && ( + )} + {puedeEditar && ( + )} {/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados. Solo en estados cobrables (oculto en cancelada/no show). */} {!['cancelled', 'no_show'].includes(detailApt.state) && ( diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index f009c47..eb1d1e8 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -884,6 +884,17 @@ export interface WacrmLead { 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 { id: number; login: string; @@ -893,6 +904,10 @@ export interface FrontendUser { last_login?: string | null; active?: boolean; 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) employee_id?: number | null; employee_name?: string | null; @@ -1305,7 +1320,7 @@ export const odooApi = { 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); return data; }, diff --git a/odoo-addons/skeen_whatsapp/controllers/auth.py b/odoo-addons/skeen_whatsapp/controllers/auth.py index 578a02b..b2ee1e4 100644 --- a/odoo-addons/skeen_whatsapp/controllers/auth.py +++ b/odoo-addons/skeen_whatsapp/controllers/auth.py @@ -15,7 +15,7 @@ import time 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 SECRET_KEY = 'skeen.frontend.secret' @@ -182,3 +182,46 @@ def require_role(min_role='lectura'): return func(self, *args, **kw) return wrapper 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 diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 582f66f..37494e8 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -9,7 +9,9 @@ import re import uuid from datetime import date, datetime, timedelta 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 AGENDA_PERM_KEYS 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) 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(): """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): return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400) 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: previos = {k: getattr(user, k) for k in 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) @require_role('recepcion') + @require_agenda('agendar') def create_appointment(self, **kw): """Crear una o más citas (soporta paquetes de sesiones)""" 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) @require_role('recepcion') + @require_agenda('agendar') def create_quick_appointment(self, **kw): """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/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') + @require_agenda('editar') def update_appointment(self, appointment_id, **kw): try: cita = request.env['skeen.cita'].sudo().browse(appointment_id) @@ -778,6 +818,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/appointments/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_agenda('eliminar') def delete_appointment(self, appointment_id, **kw): try: 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) data = _parse_json_body() 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 note = (data.get('reason') or '').strip() if action == 'confirm': @@ -880,6 +934,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/appointments//phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('editar') def appointment_phone_confirm(self, appointment_id, **kw): """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//package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('editar') def appointment_package_finished(self, appointment_id, **kw): try: 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) @require_role('recepcion') + @require_agenda('editar') def bulk_phone_confirm(self, **kw): """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) @require_role('recepcion') + @require_agenda('agendar') def create_bloqueo(self, **kw): """Crea uno o varios bloqueos de agenda. @@ -1213,6 +1271,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/bloqueos/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('agendar') def update_bloqueo(self, bloqueo_id, **kw): """Edita un bloqueo existente (fecha, horario, motivo, médico).""" try: @@ -1255,6 +1314,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/bloqueos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_agenda('agendar') def delete_bloqueo(self, bloqueo_id, **kw): try: 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/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_agenda('agendar') def delete_bloqueo_grupo(self, grupo, **kw): """Elimina la serie completa (mismo `grupo`) de bloqueos.""" try: @@ -1370,6 +1431,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_agenda('agendar') def create_lista_espera(self, **kw): try: data = _parse_json_body() @@ -1402,6 +1464,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/lista_espera/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('agendar') def update_lista_espera(self, registro_id, **kw): """Edita la espera o la resuelve (state agendado/descartado, cita_id).""" try: @@ -1445,6 +1508,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/lista_espera/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_agenda('agendar') def delete_lista_espera(self, registro_id, **kw): try: record = request.env['skeen.lista.espera'].sudo().browse(registro_id) @@ -1552,6 +1616,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def update_visita(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) @@ -1567,6 +1632,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas//complete', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def complete_visita(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) @@ -1579,6 +1645,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas//cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def cancel_visita(self, visita_id, **kw): try: 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) @require_role('recepcion') + @require_agenda('visitas') def create_consulta(self, **kw): """Consulta médica directa: visita completada sin cita origen""" try: @@ -1618,6 +1686,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas//insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def add_visita_insumo(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) @@ -1644,6 +1713,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas//insumos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def delete_visita_insumo(self, visita_id, line_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) @@ -1661,6 +1731,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas//adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def add_visita_adjunto(self, visita_id, **kw): try: visita = request.env['skeen.visita'].sudo().browse(visita_id) @@ -1695,6 +1766,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/visitas/adjuntos/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_agenda('visitas') def delete_visita_adjunto(self, adj_id, **kw): try: adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id) diff --git a/odoo-addons/skeen_whatsapp/models/frontend_user.py b/odoo-addons/skeen_whatsapp/models/frontend_user.py index aada455..6d080d3 100644 --- a/odoo-addons/skeen_whatsapp/models/frontend_user.py +++ b/odoo-addons/skeen_whatsapp/models/frontend_user.py @@ -5,7 +5,7 @@ import json from odoo import models, fields, api, _ from odoo.exceptions import ValidationError -from ..roles import ROLE_SELECTION, ROLE_RANKS +from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms try: from werkzeug.security import check_password_hash, generate_password_hash @@ -34,6 +34,10 @@ class SkeenFrontendUser(models.Model): notes = fields.Text(string='Notas') allowed_menus = fields.Text(string='Menús Permitidos', 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 = [ ('unique_login', 'unique(login)', 'El usuario ya existe.'), @@ -72,6 +76,22 @@ class SkeenFrontendUser(models.Model): return 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): self.ensure_one() return { @@ -82,4 +102,8 @@ class SkeenFrontendUser(models.Model): 'must_change_password': self.must_change_password, 'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None, '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(), } diff --git a/odoo-addons/skeen_whatsapp/roles.py b/odoo-addons/skeen_whatsapp/roles.py index 0286e88..9482e79 100644 --- a/odoo-addons/skeen_whatsapp/roles.py +++ b/odoo-addons/skeen_whatsapp/roles.py @@ -44,6 +44,57 @@ ROLE_RANKS = { 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). # 'gerencia' (gerencia de la clínica) ve toda la # operación EXCEPTO /configuracion (solo admin del sistema), /mis-ventas,