From 28a690aa0f44212dc23032803947e05e6a3cd851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 07:32:29 +0000 Subject: [PATCH] fase 2.1: roles editables (modelo skeen.role) y permisos por accion (43 claves, 9 grupos) con enforcement en backend y gating en frontend; editor de roles/permisos/menus en Configuracion --- frontend/src/components/RolesPanel.tsx | 538 +++++++++++++++--- frontend/src/components/UsuariosPanel.tsx | 71 ++- frontend/src/lib/auth.tsx | 24 +- frontend/src/lib/permissions.ts | 75 +++ frontend/src/pages/Configuracion.tsx | 23 +- frontend/src/pages/Cortes.tsx | 5 +- frontend/src/pages/Pacientes.tsx | 5 +- frontend/src/pages/Pos.tsx | 10 +- frontend/src/pages/Reportes.tsx | 6 +- frontend/src/pages/Servicios.tsx | 29 +- frontend/src/pages/Tickets.tsx | 7 +- frontend/src/pages/Ventas.tsx | 15 +- frontend/src/services/odoo.ts | 83 ++- odoo-addons/skeen_whatsapp/__manifest__.py | 1 + .../skeen_whatsapp/controllers/auth.py | 38 +- .../skeen_whatsapp/controllers/frontend.py | 332 ++++++++++- .../skeen_whatsapp/controllers/wacrm_proxy.py | 16 +- odoo-addons/skeen_whatsapp/data/roles.xml | 89 +++ odoo-addons/skeen_whatsapp/models/__init__.py | 1 + .../skeen_whatsapp/models/frontend_user.py | 27 +- odoo-addons/skeen_whatsapp/models/role.py | 36 ++ odoo-addons/skeen_whatsapp/permissions.py | 296 ++++++++++ odoo-addons/skeen_whatsapp/roles.py | 66 ++- .../security/ir.model.access.csv | 1 + 24 files changed, 1637 insertions(+), 157 deletions(-) create mode 100644 frontend/src/lib/permissions.ts create mode 100644 odoo-addons/skeen_whatsapp/data/roles.xml create mode 100644 odoo-addons/skeen_whatsapp/models/role.py create mode 100644 odoo-addons/skeen_whatsapp/permissions.py diff --git a/frontend/src/components/RolesPanel.tsx b/frontend/src/components/RolesPanel.tsx index 059e4b9..4ce02ce 100644 --- a/frontend/src/components/RolesPanel.tsx +++ b/frontend/src/components/RolesPanel.tsx @@ -1,62 +1,187 @@ import type { FC } from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { Save, Info, Lock } from 'lucide-react'; -import { Button, Skeleton, toast } from './ui'; -import { odooApi, type FrontendRole } from '../services/odoo'; -import { ROLE_LABELS } from '../lib/auth'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Save, Info, Lock, Plus, Pencil, Ban, RotateCcw } from 'lucide-react'; +import { Button, Input, Select, Modal, Badge, Skeleton, toast } from './ui'; +import { odooApi, type FrontendRole, type Role, type PermGroup, type PermsResponse } from '../services/odoo'; +import { useAuth } from '../lib/auth'; import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items'; import { useGlosario } from '../lib/glosario'; -const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[]; - type Matrix = Record; +type Tab = 'roles' | 'permisos' | 'menus'; + +// Roles del sistema protectores: no se pueden desactivar. +const PROTEGIDOS = ['admin', 'lectura']; const RolesPanel: FC = () => { const { term } = useGlosario(); - const [matrix, setMatrix] = useState(null); - const [saved, setSaved] = useState(null); + const { user } = useAuth(); + const [tab, setTab] = useState('roles'); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + + // ---- Estado de la lista de roles ---- + const [roles, setRoles] = useState([]); + + // ---- Estado de permisos ---- + const [perms, setPerms] = useState(null); + const [permRole, setPermRole] = useState('recepcion'); + const [permDraft, setPermDraft] = useState>({}); + const [permSaved, setPermSaved] = useState>({}); + const [permSaving, setPermSaving] = useState(false); + + // ---- Estado de la matriz de menús ---- + const [matrix, setMatrix] = useState(null); + const [matrixSaved, setMatrixSaved] = useState(null); + const [menuSaving, setMenuSaving] = useState(false); + + // ---- Modal de rol ---- + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [form, setForm] = useState({ name: '', rank: 50, sequence: 10, description: '', active: true }); const load = useCallback(async () => { try { setLoading(true); - const res = await odooApi.getRolePermissions(); - if (res.status === 'success') { - setMatrix(res.matrix); - setSaved(res.matrix); - } else { - toast.error(res.message || 'No se pudieron cargar los permisos'); + const [rr, rp] = await Promise.all([ + odooApi.listRoles({ includeInactive: true }), + odooApi.getRolePermissions(), + ]); + if (rr.status === 'success') setRoles(rr.roles); + if (rp.status === 'success') { + setMatrix(rp.matrix); + setMatrixSaved(rp.matrix); + } + // Los permisos por acción solo los puede leer admin/gerencia. + try { + const rperm = await odooApi.getPermisos(); + if (rperm.status === 'success') { + setPerms(rperm); + const draft = rperm.por_rol[permRole] || {}; + setPermDraft(draft); + setPermSaved(draft); + } + } catch { + // rol sin permiso de edición de roles: la pestaña Permisos avisa } } catch (err) { - toast.error('Error al cargar permisos por rol'); + toast.error('Error al cargar roles y permisos'); console.error(err); } finally { setLoading(false); } - }, []); + }, [permRole]); useEffect(() => { load(); }, [load]); - const dirty = matrix !== null && saved !== null && JSON.stringify(matrix) !== JSON.stringify(saved); + // Al cambiar de rol seleccionado, cargar su borrador de permisos. + useEffect(() => { + if (!perms) return; + const draft = perms.por_rol[permRole] || {}; + setPermDraft(draft); + setPermSaved(draft); + }, [permRole, perms]); - const toggle = (href: string, role: FrontendRole) => { - if (!matrix) return; - if (href === '/configuracion' && role === 'admin') return; // siempre requerido - const current = matrix[href] || []; - const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role]; - setMatrix({ ...matrix, [href]: next }); + const activeRoles = useMemo(() => roles.filter((r) => r.active), [roles]); + + // --------------------------------------------------------------- + // Pestaña Roles + // --------------------------------------------------------------- + const openCreate = () => { + setEditing(null); + setForm({ name: '', rank: 50, sequence: 10, description: '', active: true }); + setModalOpen(true); }; - const save = async () => { - if (!matrix) return; + const openEdit = (r: Role) => { + setEditing(r); + setForm({ + name: r.name, + rank: r.rank, + sequence: r.sequence, + description: r.description || '', + active: r.active, + }); + setModalOpen(true); + }; + + const submitRole = async () => { + if (!form.name.trim()) { + toast.error('El nombre del rol es obligatorio'); + return; + } try { - setSaving(true); - const res = await odooApi.updateRolePermissions(matrix); + setSubmitting(true); + if (editing) { + await odooApi.updateRole(editing.id, { + name: form.name.trim(), + rank: form.rank, + sequence: form.sequence, + description: form.description, + active: form.active, + }); + toast.success('Rol actualizado'); + } else { + await odooApi.createRole({ + name: form.name.trim(), + rank: form.rank, + sequence: form.sequence, + description: form.description, + active: form.active, + }); + toast.success('Rol creado (arranca con permisos de Lectura)'); + } + setModalOpen(false); + await load(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al guardar el rol'); + console.error(err); + } finally { + setSubmitting(false); + } + }; + + const desactivarRol = async (r: Role) => { + if (!window.confirm(`¿Desactivar el rol "${r.name}"? Los usuarios que lo tengan conservarán el rol.`)) return; + try { + const res = await odooApi.deleteRole(r.id); + toast.success(res.message || 'Rol desactivado'); + await load(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo desactivar el rol'); + console.error(err); + } + }; + + // --------------------------------------------------------------- + // Pestaña Permisos + // --------------------------------------------------------------- + const permDirty = JSON.stringify(permDraft) !== JSON.stringify(permSaved); + + const togglePerm = (key: string) => { + setPermDraft((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const setAllPerms = (value: boolean, group: PermGroup | null) => { + setPermDraft((prev) => { + const next = { ...prev }; + const grupos = group ? [group] : (perms?.catalogo || []); + for (const g of grupos) for (const p of g.permisos) next[p.key] = value; + return next; + }); + }; + + const savePerms = async () => { + if (!perms) return; + try { + setPermSaving(true); + const res = await odooApi.putPermisos({ role_code: permRole, permisos: permDraft }); if (res.status === 'success') { - setMatrix(res.matrix); - setSaved(res.matrix); - toast.success('Permisos por rol guardados'); + setPerms({ ...perms, por_rol: res.por_rol }); + setPermSaved(permDraft); + toast.success('Permisos guardados (aplican al recargar la sesión de cada usuario)'); } else { toast.error(res.message || 'No se pudieron guardar los permisos'); } @@ -65,68 +190,315 @@ const RolesPanel: FC = () => { toast.error(msg || 'Error al guardar permisos'); console.error(err); } finally { - setSaving(false); + setPermSaving(false); } }; - if (loading || !matrix) { + // --------------------------------------------------------------- + // Pestaña Menús + // --------------------------------------------------------------- + const menuDirty = matrix !== null && matrixSaved !== null && JSON.stringify(matrix) !== JSON.stringify(matrixSaved); + + const toggleMenu = (href: string, role: FrontendRole) => { + if (!matrix) return; + if (href === '/configuracion' && role === 'admin') return; // siempre requerido + const current = matrix[href] || []; + const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role]; + setMatrix({ ...matrix, [href]: next }); + }; + + const saveMenus = async () => { + if (!matrix) return; + try { + setMenuSaving(true); + const res = await odooApi.updateRolePermissions(matrix); + if (res.status === 'success') { + setMatrix(res.matrix); + setMatrixSaved(res.matrix); + toast.success('Permisos de menú guardados'); + } else { + toast.error(res.message || 'No se pudieron guardar los menús'); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al guardar menús'); + console.error(err); + } finally { + setMenuSaving(false); + } + }; + + if (loading) { return ; } + const puedeEditar = user?.role === 'admin' || user?.role === 'gerencia'; + return (
-
-

- - Aplica a todos los usuarios del rol al recargar su sesión. Los permisos personales de un usuario van encima. -

-
- {dirty && Cambios sin guardar} - -
+
+ {([['roles', 'Roles'], ['permisos', 'Permisos'], ['menus', 'Menús']] as [Tab, string][]).map(([k, label]) => ( + + ))}
-
- - - - - {ROLES.map((r) => ( - - ))} - - - - {MENU_ITEMS.map((item) => ( - - - {ROLES.map((role) => { - const locked = item.href === '/configuracion' && role === 'admin'; - const checked = (matrix[item.href] || []).includes(role); - return ( - + + + + + ))} + +
Sección{ROLE_LABELS[r]}
- {menuItemLabel(item, term)} - - toggle(item.href, role)} - title={locked ? 'El admin siempre requiere Configuración' : `${item.label} — ${ROLE_LABELS[role]}`} - className="rounded border-theme-border-strong disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" - /> - {locked && } + {/* ---------------- Roles ---------------- */} + {tab === 'roles' && ( + <> +
+

+ + Los roles se pueden crear, renombrar y ajustar de rango. Los del sistema + (marcados) no se pueden eliminar; nunca se cambia su código porque los usuarios + ya guardados lo usan. +

+ {puedeEditar && ( + + )} +
+
+ + + + + + + + + + + + {roles.map((r) => ( + + - ); - })} - - ))} - -
RolCódigoRangoEstadoAcciones
+ {r.name} + {r.is_system && Sistema}
-
+
{r.code}{r.rank} + {r.active ? 'Activo' : 'Inactivo'} + +
+ {puedeEditar && ( + + )} + {puedeEditar && r.active && !r.is_system && !PROTEGIDOS.includes(r.code) && ( + + )} +
+
+
+ + )} + + {/* ---------------- Permisos ---------------- */} + {tab === 'permisos' && ( + !perms ? ( +

No tienes permiso para ver o editar los permisos por acción.

+ ) : ( + <> +
+
+
+ togglePerm(p.key)} + className="rounded border-theme-border-strong" + /> + {p.label} + + ))} +
+
+ ))} +
+ + ) + )} + + {/* ---------------- Menús ---------------- */} + {tab === 'menus' && ( + matrix === null ? ( + + ) : ( + <> +
+

+ + Qué secciones ve cada rol. Aplica al recargar su sesión. Los permisos personales de un usuario van encima. +

+
+ {menuDirty && Cambios sin guardar} + +
+
+
+ + + + + {roles.filter((r) => r.active).map((r) => ( + + ))} + + + + {MENU_ITEMS.map((item) => ( + + + {roles.filter((r) => r.active).map((r) => { + const locked = item.href === '/configuracion' && r.code === 'admin'; + const checked = (matrix[item.href] || []).includes(r.code); + return ( + + ); + })} + + ))} + +
Sección{r.name}
+ {menuItemLabel(item, term)} + + toggleMenu(item.href, r.code)} + title={locked ? 'El admin siempre requiere Configuración' : `${menuItemLabel(item, term)} — ${r.name}`} + className="rounded border-theme-border-strong disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" + /> + {locked && } +
+
+ + ) + )} + + {/* Modal crear/editar rol */} + setModalOpen(false)} + title={editing ? `Editar rol — ${editing.name}` : 'Nuevo rol'} + maxWidth="md" + footer={ + <> + + + + } + > +
+ setForm({ ...form, name: e.target.value })} + placeholder="ej. Terapeuta" + /> + {editing && ( +

+ Código: {editing.code} (no se puede cambiar) +

+ )} +
+ setForm({ ...form, rank: parseInt(e.target.value || '0', 10) })} + /> + setForm({ ...form, sequence: parseInt(e.target.value || '0', 10) })} + /> +
+ setForm({ ...form, description: e.target.value })} + /> + + {!perms && ( +

+ Los roles nuevos heredan los permisos de Lectura; ajústalos en la pestaña Permisos. +

+ )} +
+
); }; diff --git a/frontend/src/components/UsuariosPanel.tsx b/frontend/src/components/UsuariosPanel.tsx index 77f4272..6776792 100644 --- a/frontend/src/components/UsuariosPanel.tsx +++ b/frontend/src/components/UsuariosPanel.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { Plus, Pencil, KeyRound, ShieldCheck, Trash2, AlertTriangle } from 'lucide-react'; import { Button, @@ -12,15 +12,11 @@ import { MobileCard, toast, } from './ui'; -import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo'; -import { ROLE_LABELS, useAuth } from '../lib/auth'; +import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type Role, type PermGroup, AGENDA_PERM_ITEMS } from '../services/odoo'; +import { ROLE_LABELS, roleLabel, useAuth } from '../lib/auth'; import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items'; import { useGlosario } from '../lib/glosario'; -const roleOptions: { value: FrontendRole; label: string }[] = ( - Object.keys(ROLE_LABELS) as FrontendRole[] -).map((r) => ({ value: r, label: ROLE_LABELS[r] })); - const roleBadge = (role: FrontendRole) => { switch (role) { case 'admin': @@ -50,12 +46,32 @@ const UsuariosPanel: FC = () => { const [agendaCustom, setAgendaCustom] = useState(false); const [agendaSelected, setAgendaSelected] = useState([]); const [permSaving, setPermSaving] = useState(false); + // Roles configurables (para el selector) y permisos que hereda cada rol + // (solo lectura, informativo). + const [roles, setRoles] = useState([]); + const [permCatalogo, setPermCatalogo] = useState([]); + const [permsByRole, setPermsByRole] = useState>>({}); + const [permDefaults, setPermDefaults] = useState>>({}); const load = useCallback(async () => { try { setLoading(true); const res = await odooApi.listFrontendUsers(); if (res.status === 'success') setUsers(res.users); + // Roles disponibles para asignar (solo activos). + try { + const rr = await odooApi.listRoles(); + if (rr.status === 'success') setRoles(rr.roles); + } catch { /* sin roles dinámicos: fallback a ROLE_LABELS */ } + // Permisos por rol (informativo). Requiere ser admin/gerencia. + try { + const rp = await odooApi.getPermisos(); + if (rp.status === 'success') { + setPermCatalogo(rp.catalogo); + setPermsByRole(rp.por_rol); + setPermDefaults(rp.defaults); + } + } catch { /* sin permiso: solo se oculta la leyenda */ } } catch (err) { toast.error('Error al cargar usuarios'); console.error(err); @@ -66,6 +82,22 @@ const UsuariosPanel: FC = () => { useEffect(() => { load(); }, [load]); + const roleOptions: { value: string; label: string }[] = useMemo(() => { + const activos = roles.filter((r) => r.active); + if (activos.length) return activos.map((r) => ({ value: r.code, label: r.name })); + return Object.keys(ROLE_LABELS).map((r) => ({ value: r, label: ROLE_LABELS[r] })); + }, [roles]); + + // Permisos que hereda un rol: del endpoint o del espejo de defaults. + const inheritedPerms = useCallback((role: string): { key: string; label: string }[] => { + const valores = permsByRole[role] || permDefaults[role] || {}; + const out: { key: string; label: string }[] = []; + for (const g of permCatalogo) { + for (const p of g.permisos) if (valores[p.key]) out.push(p); + } + return out; + }, [permsByRole, permDefaults, permCatalogo]); + const openCreate = () => { setEditing(null); setForm({ login: '', name: '', password: '', role: 'recepcion', active: true }); @@ -225,7 +257,7 @@ const UsuariosPanel: FC = () => { {u.login} {u.name} - {ROLE_LABELS[u.role]} + {roleLabel(u.role, u.role_name)} {u.allowed_menus && u.allowed_menus.length > 0 ? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones` @@ -264,7 +296,7 @@ const UsuariosPanel: FC = () => { title={u.name} subtitle={u.login} rows={[ - { label: 'Rol', value: {ROLE_LABELS[u.role]} }, + { label: 'Rol', value: {roleLabel(u.role, u.role_name)} }, { label: 'Estado', value: {u.active !== false ? 'Activo' : 'Inactivo'} }, { label: 'Secciones', value: u.allowed_menus && u.allowed_menus.length > 0 ? `${u.allowed_menus.length} de ${MENU_ITEMS.length}` : 'Por rol' }, ]} @@ -320,6 +352,27 @@ const UsuariosPanel: FC = () => { value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })} /> + {permCatalogo.length > 0 && ( +
+

+ Permisos que hereda este rol ({inheritedPerms(form.role).length}) +

+ {inheritedPerms(form.role).length === 0 ? ( +

Sin permisos de escritura; solo consulta básica.

+ ) : ( +
+ {inheritedPerms(form.role).map((p) => ( + + {p.label} + + ))} +
+ )} +

+ Se editan en Configuración › Roles › Permisos. Los permisos de Agenda de abajo son un ajuste fino por usuario. +

+
+ )} = { +const ROLE_RANK: Record = { admin: 100, medico: 50, recepcion: 50, @@ -78,6 +79,9 @@ interface AuthContextValue { hasRole: (min: FrontendRole) => boolean; canSee: (href: string) => boolean; canAgenda: (perm: AgendaPerm) => boolean; + // Permiso por acción (catálogo skeen_whatsapp/permissions.py). La UI oculta + // o deshabilita según esto; el backend sigue siendo la autoridad. + can: (permKey: string) => boolean; // Brand de la clínica (white-label). BRAND_DEFAULT hasta que /brand responde. brand: ClinicBrand; setBrand: (b: ClinicBrand) => void; @@ -271,8 +275,18 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { return perms.includes(perm); }; + // Permisos por acción. El backend manda user.perms en /auth/me y /auth/login; + // si no viniera (sesión cacheada previa al despliegue) se usa el espejo de + // defaults de lib/permissions.ts. El admin siempre puede. + const can = (permKey: string): boolean => { + if (!user) return false; + if (user.role === 'admin') return true; + if (user.perms && typeof user.perms[permKey] === 'boolean') return user.perms[permKey]; + return defaultPermFor(user.role, permKey); + }; + return ( - + {children} ); @@ -284,7 +298,7 @@ export const useAuth = (): AuthContextValue => { return ctx; }; -export const ROLE_LABELS: Record = { +export const ROLE_LABELS: Record = { admin: 'Administrador', medico: 'Médico', recepcion: 'Recepción', @@ -296,3 +310,7 @@ export const ROLE_LABELS: Record = { marketing: 'Marketing', lectura: 'Lectura', }; + +/** Nombre visible de un rol: el configurado (si se conoce) o el código. */ +export const roleLabel = (role: string, name?: string): string => + name || ROLE_LABELS[role] || role; diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts new file mode 100644 index 0000000..b616e96 --- /dev/null +++ b/frontend/src/lib/permissions.ts @@ -0,0 +1,75 @@ +// Espejo de skeen_whatsapp/permissions.py. Sirve de respaldo mientras /auth/me +// no ha respondido o hay una sesión cacheada de antes del despliegue, y para +// que el editor de roles muestre algo si /permisos falla. El backend es la +// autoridad: si algo no cuadra, la UI esconde de más, nunca de menos. + +export const PERM_KEYS: string[] = [ + 'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar', + 'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear', + 'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar', + 'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos', + 'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos', + 'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.cancelar', + 'ventas.devolver', 'ventas.reimprimir', + 'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos', + 'servicios.ver', 'servicios.editar', + 'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar', + 'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar', + 'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria', +]; + +const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', + 'psicologa', 'farmacia', 'gerencia', 'marketing']; +const WN = W.filter((r) => r !== 'marketing'); +const MONEY = W.filter((r) => r !== 'medico' && r !== 'marketing'); +const AG = ['admin', 'gerencia']; +const AD = ['admin']; +const ALL = [...W, 'lectura']; + +const READ_PERM_KEYS = new Set([ + 'agenda.ver', 'agenda.todas', + 'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos', + 'visitas.ver', + 'inventario.ver', + 'servicios.ver', + 'reportes.ver', 'reportes.exportar', 'caja.ver', + 'ventas.reimprimir', + 'whatsapp.ver', +]); + +const DEFAULT_PERM_ROLES: Record = { + 'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W, + 'agenda.mover': W, 'agenda.visitas': W, 'agenda.todas': W, 'agenda.bloquear': W, + 'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W, + 'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL, + 'pacientes.saldos': ALL, + 'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W, + 'visitas.eliminar': W, 'visitas.archivos': W, + 'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY, + 'ventas.cancelar': AG, 'ventas.devolver': MONEY, 'ventas.reimprimir': ALL, + 'inventario.ver': ALL, 'inventario.ajustar': WN, 'inventario.mover': WN, + 'inventario.costos': WN, + 'servicios.ver': ALL, 'servicios.editar': AD, + 'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY, + 'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W, + 'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD, +}; + +export const BASE_ROLES = ['admin', 'medico', 'recepcion', 'enfermeria', + 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura']; + +export const DEFAULT_PERMS_BY_ROLE: Record> = + Object.fromEntries(BASE_ROLES.map((role) => [ + role, + Object.fromEntries(PERM_KEYS.map((key) => [ + key, + role === 'lectura' ? READ_PERM_KEYS.has(key) : (DEFAULT_PERM_ROLES[key] || []).includes(role), + ])), + ])); + +/** Default de un permiso para un rol (incluye roles personalizados = lectura). */ +export const defaultPermFor = (role: string, key: string): boolean => { + const base = BASE_ROLES.includes(role) ? role : 'lectura'; + return DEFAULT_PERMS_BY_ROLE[base]?.[key] + ?? (READ_PERM_KEYS.has(key) && base === 'lectura'); +}; diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index 3dc3f45..e99c1b1 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -43,17 +43,24 @@ const SUBCATALOGOS = [ const LEGACY_STORAGE_KEY = 'skeen_clinic_settings'; const Configuracion: FC = () => { - const { user, brand, setBrand } = useAuth(); + const { user, brand, setBrand, can } = useAuth(); // Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos // de contacto, tipo de cambio y comisiones) queda para administración del sistema. const esAdmin = user?.role === 'admin'; + // Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos). + const TABS_PERM: Record = { + usuarios: 'config.usuarios', + roles: 'config.roles', + auditoria: 'config.auditoria', + }; const tabsVisibles = useMemo( - () => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))), - [esAdmin], + () => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))) + .filter((t) => can(TABS_PERM[t.key] || 'config.editar')), + [esAdmin, can], ); const [searchParams, setSearchParams] = useSearchParams(); const tabPedida = searchParams.get('tab') || 'clinica'; - const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key; + const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : (tabsVisibles[0]?.key || 'clinica'); const setTab = (t: string) => setSearchParams({ tab: t }); const [subcatalogo, setSubcatalogo] = useState(() => { const sub = searchParams.get('sub'); @@ -284,7 +291,13 @@ const Configuracion: FC = () => { ))}
- {tab === 'usuarios' ? ( + {tabsVisibles.length === 0 ? ( + + +

No tienes permiso para ninguna sección de Configuración.

+
+
+ ) : tab === 'usuarios' ? ( diff --git a/frontend/src/pages/Cortes.tsx b/frontend/src/pages/Cortes.tsx index 15c4e48..7d98a6d 100644 --- a/frontend/src/pages/Cortes.tsx +++ b/frontend/src/pages/Cortes.tsx @@ -34,7 +34,7 @@ const PASOS = [ ] as const; const Cortes: FC = () => { - const { brand } = useAuth(); + const { brand, can } = useAuth(); const [closings, setClosings] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -479,7 +479,8 @@ const Cortes: FC = () => { ) : ( - diff --git a/frontend/src/pages/Pacientes.tsx b/frontend/src/pages/Pacientes.tsx index 663e779..8ba55e3 100644 --- a/frontend/src/pages/Pacientes.tsx +++ b/frontend/src/pages/Pacientes.tsx @@ -122,7 +122,7 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [ ]; const Pacientes: FC = () => { - const { brand } = useAuth(); + const { brand, can } = useAuth(); const { term } = useGlosario(); const { labelFor: branchLabel } = useSucursales(); const [patients, setPatients] = useState([]); @@ -525,7 +525,8 @@ const Pacientes: FC = () => { return ( - diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index afe181b..eefa65f 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -20,6 +20,7 @@ import { } from '../components/ui'; import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo'; import type { PatientPackage } from '../services/odoo'; +import { useAuth } from '../lib/auth'; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); @@ -52,6 +53,7 @@ interface TicketLine { const Pos: FC = () => { const { openPatient } = usePatientDrawer(); + const { can } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); // Catálogo const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios'); @@ -818,7 +820,9 @@ const Pos: FC = () => { value={discount} onChange={(e) => setDiscount(e.target.value)} placeholder="0" - className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right" + disabled={!can('ventas.descuentos')} + title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'} + className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed" /> @@ -840,8 +844,8 @@ const Pos: FC = () => { )} - {puedeTab('exportar') && ( + {puedeTab('exportar') && can('reportes.exportar') && ( + {puedeEditar && ( + + )}
@@ -404,12 +409,16 @@ const Servicios: FC = () => { > - - + {puedeEditar && ( + <> + + + + )}
diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx index 0c412a8..74c8dfa 100644 --- a/frontend/src/pages/Tickets.tsx +++ b/frontend/src/pages/Tickets.tsx @@ -47,8 +47,9 @@ const PAGE_SIZE = 50; * Ventas. Permiso de cancelación: admin y gerencia (el API lo valida). */ const Tickets: FC = () => { - const { user } = useAuth(); + const { user, can } = useAuth(); const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia'; + const puedeCancelar = can('ventas.cancelar'); const prefs = readDateFilterPrefs(PREF_KEY); const [preset, setPreset] = useState(prefs?.preset || 'hoy'); const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start); @@ -304,7 +305,7 @@ const Tickets: FC = () => { )} - {s.state !== 'cancelled' && ( + {s.state !== 'cancelled' && puedeCancelar && ( @@ -350,7 +351,7 @@ const Tickets: FC = () => { Corregir )} - {detalle && detalle.state !== 'cancelled' && ( + {detalle && detalle.state !== 'cancelled' && puedeCancelar && ( diff --git a/frontend/src/pages/Ventas.tsx b/frontend/src/pages/Ventas.tsx index 8f5fc65..f17031e 100644 --- a/frontend/src/pages/Ventas.tsx +++ b/frontend/src/pages/Ventas.tsx @@ -27,7 +27,6 @@ import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs // Rango de fechas recordado entre visitas (igual que los presets de la agenda) const FILTERS_KEY = 'skeen_ventas_filtros'; -const ROLES_CANCELAN = ['admin', 'gerencia']; interface LineForm { service_id: string; @@ -47,8 +46,10 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago']; const Ventas: FC = () => { const { openPatient } = usePatientDrawer(); - const { user, brand } = useAuth(); - const puedeCancelar = ROLES_CANCELAN.includes(user?.role || ''); + const { brand, can } = useAuth(); + const puedeCancelar = can('ventas.cancelar'); + const puedeCobrar = can('ventas.cobrar'); + const puedeDevolver = can('ventas.devolver'); const [sales, setSales] = useState([]); const [services, setServices] = useState([]); const [doctors, setDoctors] = useState([]); @@ -491,13 +492,13 @@ const Ventas: FC = () => { - {s.amount_due > 0 && !cancelada && ( + {s.amount_due > 0 && !cancelada && puedeCobrar && ( )} - {s.amount_paid > 0 && !cancelada && ( + {s.amount_paid > 0 && !cancelada && puedeDevolver && ( - {s.amount_due > 0 && s.state !== 'cancelled' && ( + {s.amount_due > 0 && s.state !== 'cancelled' && puedeCobrar && ( )} - {s.amount_paid > 0 && s.state !== 'cancelled' && ( + {s.amount_paid > 0 && s.state !== 'cancelled' && puedeDevolver && ( diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index cb66b2d..be25d40 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -885,7 +885,9 @@ export interface WacrmLead { created_at: string | null; } -export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'gerencia' | 'marketing' | 'lectura'; +// Los roles son configurables (modelo skeen.role): el código es libre, por eso +// el tipo ya no es una unión cerrada. ROLE_LABELS queda solo como respaldo. +export type FrontendRole = string; // Acciones de la Agenda (espejo de AGENDA_PERMS en skeen_whatsapp/roles.py). export type AgendaPerm = 'ver' | 'agendar' | 'editar' | 'eliminar' | 'visitas'; @@ -996,6 +998,8 @@ export interface FrontendUser { login: string; name: string; role: FrontendRole; + // Nombre del rol configurado en skeen.role (para roles personalizados). + role_name?: string; must_change_password?: boolean; last_login?: string | null; active?: boolean; @@ -1004,11 +1008,54 @@ export interface FrontendUser { agenda_perms?: AgendaPerm[] | null; // Lista propia del usuario; null = hereda del rol. La usa solo el editor. agenda_perms_custom?: AgendaPerm[] | null; + // Permisos por acción vigentes (rol + overrides), resueltos por el backend. + perms?: Record | null; // Solo rol 'medico': hr.employee vinculado (null si no hay vínculo) employee_id?: number | null; employee_name?: string | null; } +// ---- Roles configurables (skeen.role) ---- +export interface Role { + id: number; + code: string; + name: string; + rank: number; + sequence: number; + active: boolean; + is_system: boolean; + description: string; +} + +export interface RoleUserRef { + code: string; + name: string; + rank: number; + active: boolean; + is_system: boolean; +} + +// ---- Permisos por acción ---- +export interface PermItem { + key: string; + label: string; +} + +export interface PermGroup { + grupo: string; + label: string; + permisos: PermItem[]; +} + +export interface PermsResponse { + status: string; + catalogo: PermGroup[]; + por_rol: Record>; + defaults: Record>; + roles: RoleUserRef[]; + message?: string; +} + export interface Receta { id: number; name: string; @@ -1552,6 +1599,40 @@ export const odooApi = { return data; }, + // ---- Roles configurables ---- + async listRoles(opts?: { includeInactive?: boolean }): Promise<{ status: string; roles: Role[]; total: number; message?: string }> { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/roles`, { + params: opts?.includeInactive ? { include_inactive: 1 } : undefined, + }); + return data; + }, + + async createRole(payload: { name: string; rank?: number; sequence?: number; description?: string; active?: boolean }): Promise<{ status: string; role: Role; message?: string }> { + const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/roles`, payload); + return data; + }, + + async updateRole(id: number, payload: { name?: string; rank?: number; sequence?: number; active?: boolean; description?: string }): Promise<{ status: string; role: Role; message?: string }> { + const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/${id}`, payload); + return data; + }, + + async deleteRole(id: number): Promise<{ status: string; role?: Role; en_uso?: number; message?: string }> { + const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/roles/${id}`); + return data; + }, + + // ---- Permisos por acción ---- + async getPermisos(): Promise { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/permisos`); + return data; + }, + + async putPermisos(payload: { role_code?: string; permisos?: Record; por_rol?: Record> }): Promise<{ status: string; por_rol: Record>; guardados: number; message?: string }> { + const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/permisos`, payload); + return data; + }, + // Health async healthCheck(): Promise { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`); diff --git a/odoo-addons/skeen_whatsapp/__manifest__.py b/odoo-addons/skeen_whatsapp/__manifest__.py index 24ee639..118cfcb 100644 --- a/odoo-addons/skeen_whatsapp/__manifest__.py +++ b/odoo-addons/skeen_whatsapp/__manifest__.py @@ -13,6 +13,7 @@ 'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'], 'data': [ 'security/ir.model.access.csv', + 'data/roles.xml', 'data/frontend_users.xml', ], 'installable': True, diff --git a/odoo-addons/skeen_whatsapp/controllers/auth.py b/odoo-addons/skeen_whatsapp/controllers/auth.py index b2ee1e4..9e9377f 100644 --- a/odoo-addons/skeen_whatsapp/controllers/auth.py +++ b/odoo-addons/skeen_whatsapp/controllers/auth.py @@ -15,7 +15,8 @@ import time from odoo.http import request, Response -from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms +from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms, min_rank +from ..permissions import has_perm, PERM_LABELS TTL_SECONDS = 60 * 60 * 12 # 12 horas SECRET_KEY = 'skeen.frontend.secret' @@ -158,10 +159,10 @@ def forbid_roles(*roles): def require_role(min_role='lectura'): """Decorator: exige token válido y un rol con rango >= min_role. - Deja pasar OPTIONS (preflight CORS) sin autenticación. + Deja pasar OPTIONS (preflight CORS) sin autenticación. El rango mínimo se + resuelve en cada petición desde skeen.role (editable), con respaldo en + ROLE_RANKS. """ - required = RANK.get(min_role, 10) - def decorator(func): @functools.wraps(func) def wrapper(self, *args, **kw): @@ -170,7 +171,7 @@ def require_role(min_role='lectura'): user = current_user(request.env) if not user: return _json({'status': 'error', 'message': 'No autenticado'}, 401) - if user.role_rank() < required: + if user.role_rank() < min_rank(request.env, min_role): return _json({'status': 'error', 'message': 'Sin permiso'}, 403) if user.role == 'medico' and any( request.httprequest.path == p or request.httprequest.path.startswith( @@ -184,6 +185,33 @@ def require_role(min_role='lectura'): return decorator +def require_perm(perm): + """Decorator: exige token válido y el permiso por acción `perm`. + + Se apila junto a @require_role: el rango sigue siendo el guard base y este + decorador refina la acción concreta contra 'skeen.role_perms' (defaults en + permissions.py). El rol admin siempre pasa. Un permiso ausente = acceso + denegado (403), nunca 200. + """ + 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) + if not has_perm(user, perm): + return _json({ + 'status': 'error', + 'message': 'Sin permiso: %s' % PERM_LABELS.get(perm, perm), + }, 403) + request.frontend_user = user + return func(self, *args, **kw) + return wrapper + return decorator + + # --------------------------------------------------------------------------- # Permisos de ACCION dentro de la Agenda (selector por usuario). # diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 5ff1b9a..ebb8fb6 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -9,9 +9,12 @@ 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 .auth import require_agenda, agenda_perm_error, require_perm from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles -from ..roles import AGENDA_PERM_KEYS +from ..roles import AGENDA_PERM_KEYS, role_keys, role_selection +from ..permissions import (PERMISSION_GROUPS, PERM_KEYS, PERM_LABELS, + ROLE_PERMS_KEY, has_perm, all_roles_perms, + default_role_perms, resolve_role_perms, set_role_perms) from odoo.addons.skeen_citas.clinic_settings import ( agenda_bounds as clinic_agenda_bounds, get_settings as get_clinic_settings, @@ -278,6 +281,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('config.usuarios') def auth_users_list(self, **kw): try: if not self._puede_gestionar_usuarios(): @@ -289,6 +293,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('config.usuarios') def auth_users_create(self, **kw): try: if not self._puede_gestionar_usuarios(): @@ -302,7 +307,7 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400) if len(password) < 8: return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400) - if role not in ROLE_KEYS: + if role not in role_keys(request.env): return json_response({'status': 'error', 'message': 'Rol inválido'}, 400) if role == 'admin' and not self._puede_asignar_admin(): return json_response({'status': 'error', 'message': 'Solo un administrador puede crear administradores'}, 403) @@ -319,6 +324,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/auth/users/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('config.usuarios') def auth_users_update(self, user_id, **kw): try: if not self._puede_gestionar_usuarios(): @@ -348,7 +354,7 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': 'Ese usuario ya existe'}, 409) vals['login'] = login if 'role' in data: - if data['role'] not in ROLE_KEYS: + if data['role'] not in role_keys(request.env): return json_response({'status': 'error', 'message': 'Rol inválido'}, 400) if not self._puede_gestionar_roles(): return json_response({'status': 'error', 'message': 'Sin permiso para cambiar el rol'}, 403) @@ -393,6 +399,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/auth/users/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_perm('config.usuarios') def auth_users_delete(self, user_id, **kw): try: if not self._puede_gestionar_usuarios(): @@ -420,13 +427,18 @@ class SkeenFrontendController(http.Controller): return json_response({ 'status': 'success', 'matrix': get_menu_roles(request.env), - 'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION], + 'roles': [{'key': r.code, 'label': r.name, 'active': r.active, 'rank': r.rank} + for r in request.env['skeen.role'].sudo().with_context( + active_test=False).search([], order='sequence, name')] or + [{'key': k, 'label': l, 'active': True, 'rank': 0} + for k, l in ROLE_SELECTION], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('config.roles') def roles_permissions_put(self, **kw): try: if not self._puede_gestionar_roles(): @@ -435,11 +447,12 @@ class SkeenFrontendController(http.Controller): matrix = data.get('matrix') if not isinstance(matrix, dict): return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400) + valid_roles = role_keys(request.env) clean = {} for href, roles in matrix.items(): if not isinstance(href, str): return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400) - if not isinstance(roles, list) or any(not isinstance(r, str) or r not in ROLE_KEYS for r in roles): + if not isinstance(roles, list) or any(not isinstance(r, str) or r not in valid_roles for r in roles): return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400) clean[href] = list(dict.fromkeys(roles)) if 'admin' not in clean.get('/configuracion', []): @@ -455,6 +468,264 @@ class SkeenFrontendController(http.Controller): except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) + # ============================================================ + # Roles configurables (skeen.role) + # ============================================================ + # 'admin' y 'lectura' no se pueden desactivar: sin admin nadie administra y + # sin lectura no queda el rol base de solo consulta. + ROLES_PROTEGIDOS = ('admin', 'lectura') + + def _role_to_dict(self, r): + return { + 'id': r.id, + 'code': r.code, + 'name': r.name, + 'rank': r.rank, + 'sequence': r.sequence, + 'active': r.active, + 'is_system': r.is_system, + 'description': r.description or '', + } + + @http.route('/skeen/frontend/v1/roles', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + @require_role('lectura') + def list_roles(self, **kw): + """Roles configurados. ?include_inactive=1 trae también los inactivos.""" + try: + incluir = kw.get('include_inactive') in ('1', 'true', 'True') + Role = request.env['skeen.role'].sudo() + if incluir: + Role = Role.with_context(active_test=False) + recs = Role.search([], order='sequence, name') + return json_response({'status': 'success', + 'roles': [self._role_to_dict(r) for r in recs], + 'total': len(recs)}) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/roles', type='http', auth='none', + methods=['POST'], csrf=False) + @require_role('gerencia') + @require_perm('config.roles') + def create_role(self, **kw): + """Crea un rol. El `code` se genera como slug único del nombre.""" + try: + if not self._puede_gestionar_roles(): + return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) + data = _parse_json_body() + name = (data.get('name') or '').strip() + if not name: + return json_response({'status': 'error', + 'message': 'El nombre es obligatorio'}, 400) + Role = request.env['skeen.role'].sudo() + codigo = (data.get('code') or '').strip() + if codigo: + codigo = catalog_slugify(codigo) or codigo + if Role.with_context(active_test=False).search_count([('code', '=', codigo)]): + return json_response({ + 'status': 'error', + 'message': 'Ya existe un rol con el código %s' % codigo}, 409) + else: + codigo = catalog_unique_slug(Role, name) + try: + rank = int(data.get('rank', 50)) + except (TypeError, ValueError): + return json_response({'status': 'error', + 'message': 'El rango debe ser numérico'}, 400) + try: + sequence = int(data.get('sequence') or 10) + except (TypeError, ValueError): + sequence = 10 + rol = Role.create({ + 'name': name, + 'code': codigo, + 'rank': rank, + 'sequence': sequence, + 'active': bool(data.get('active', True)), + 'description': data.get('description') or '', + 'is_system': False, + }) + self._auditar('roles', 'role:%s' % rol.id, 'crear', + new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500], + entity_label='Rol %s' % rol.name, + notes=(data.get('motivo') or 'creado').strip()) + return json_response({'status': 'success', + 'role': self._role_to_dict(rol)}, 201) + except Exception as e: + request.env.cr.rollback() + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/roles/', type='http', auth='none', + methods=['PUT', 'OPTIONS'], csrf=False) + @require_role('gerencia') + @require_perm('config.roles') + def update_role(self, role_id, **kw): + """Renombra / ajusta rango, secuencia, estado y descripción de un rol. + + El `code` NUNCA se cambia: es el valor guardado en los usuarios. + """ + try: + if not self._puede_gestionar_roles(): + return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) + rol = request.env['skeen.role'].sudo().with_context( + active_test=False).browse(role_id) + if not rol.exists(): + return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404) + data = _parse_json_body() + vals = {} + if 'name' in data and str(data.get('name') or '').strip(): + vals['name'] = str(data['name']).strip() + if 'description' in data: + vals['description'] = data.get('description') or '' + if 'rank' in data: + try: + vals['rank'] = int(data['rank']) + except (TypeError, ValueError): + return json_response({'status': 'error', + 'message': 'El rango debe ser numérico'}, 400) + if 'sequence' in data: + try: + vals['sequence'] = int(data['sequence']) + except (TypeError, ValueError): + return json_response({'status': 'error', + 'message': 'La secuencia debe ser numérica'}, 400) + if 'active' in data: + activo = bool(data['active']) + if not activo and rol.code in self.ROLES_PROTEGIDOS: + return json_response({ + 'status': 'error', + 'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400) + vals['active'] = activo + if not vals: + return json_response({'status': 'error', + 'message': 'Nada que actualizar'}, 400) + rol.write(vals) + self._auditar('roles', 'role:%s' % rol.id, 'editar', + new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500], + entity_label='Rol %s' % rol.name, + notes=(data.get('motivo') or 'actualizado').strip()) + return json_response({'status': 'success', + 'role': self._role_to_dict(rol)}) + except Exception as e: + request.env.cr.rollback() + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/roles/', type='http', auth='none', + methods=['DELETE'], csrf=False) + @require_role('gerencia') + @require_perm('config.roles') + def delete_role(self, role_id, **kw): + """Desactiva un rol (nunca se borra: los usuarios lo referencian). + + Los roles de sistema no se pueden eliminar (400). + """ + try: + if not self._puede_gestionar_roles(): + return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) + rol = request.env['skeen.role'].sudo().with_context( + active_test=False).browse(role_id) + if not rol.exists(): + return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404) + if rol.is_system: + return json_response({ + 'status': 'error', + 'message': ('"%s" es un rol del sistema: no se puede eliminar, ' + 'solo desactivar (si aplica) o renombrar.' % rol.name)}, 400) + if rol.code in self.ROLES_PROTEGIDOS: + return json_response({ + 'status': 'error', + 'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400) + en_uso = request.env['skeen.frontend.user'].sudo().search_count( + [('role', '=', rol.code)]) + rol.write({'active': False}) + self._auditar('roles', 'role:%s' % rol.id, 'desactivar', + old_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500], + entity_label='Rol %s' % rol.name, + notes='desactivado (%d usuarios)' % en_uso) + mensaje = 'Rol desactivado.' + if en_uso: + mensaje = ('El rol tiene %d usuario(s); quedó desactivado y sus ' + 'usuarios conservan el rol.' % en_uso) + return json_response({'status': 'success', + 'role': self._role_to_dict(rol), + 'en_uso': en_uso, 'message': mensaje}) + except Exception as e: + request.env.cr.rollback() + return json_response({'status': 'error', 'message': str(e)}, 500) + + # ============================================================ + # Permisos por acción (catálogo + por rol) + # ============================================================ + def _perm_catalogo(self): + return [{'grupo': g, 'label': label, + 'permisos': [{'key': k, 'label': l} for k, l in perms]} + for g, label, perms in PERMISSION_GROUPS] + + @http.route('/skeen/frontend/v1/permisos', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + @require_role('gerencia') + @require_perm('config.roles') + def permisos_get(self, **kw): + """Catálogo de permisos + permisos vigentes por rol + defaults.""" + try: + if not self._puede_gestionar_roles(): + return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) + Role = request.env['skeen.role'].sudo().with_context(active_test=False) + return json_response({ + 'status': 'success', + 'catalogo': self._perm_catalogo(), + 'por_rol': all_roles_perms(request.env), + 'defaults': default_role_perms(), + 'roles': [{'code': r.code, 'name': r.name, 'rank': r.rank, + 'active': r.active, 'is_system': r.is_system} + for r in Role.search([], order='sequence, name')], + }) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) + + @http.route('/skeen/frontend/v1/permisos', type='http', auth='none', + methods=['PUT', 'OPTIONS'], csrf=False) + @require_role('gerencia') + @require_perm('config.roles') + def permisos_put(self, **kw): + """Guarda permisos de uno o varios roles. + + Body: {role_code, permisos:{clave:bool}} o {por_rol:{role_code:{...}}}. + """ + try: + if not self._puede_gestionar_roles(): + return json_response({'status': 'error', 'message': 'Sin permiso'}, 403) + data = _parse_json_body() + validos = role_keys(request.env) + guardados = 0 + if isinstance(data.get('por_rol'), dict): + por_rol = data['por_rol'] + elif data.get('role_code'): + por_rol = {data['role_code']: data.get('permisos') or {}} + else: + return json_response({'status': 'error', + 'message': 'Se espera role_code+permisos o por_rol'}, 400) + for code, perms in por_rol.items(): + if code not in validos: + return json_response({'status': 'error', + 'message': 'Rol inválido: %s' % code}, 400) + if not isinstance(perms, dict): + return json_response({'status': 'error', + 'message': 'permisos de %s debe ser un objeto' % code}, 400) + if set_role_perms(request.env, code, perms): + guardados += 1 + self._auditar('roles', 'role_perms', 'permisos', + new_value=json.dumps(por_rol, ensure_ascii=False)[:2000], + entity_label='Permisos por rol', + notes='%d rol(es)' % guardados) + return json_response({'status': 'success', + 'por_rol': all_roles_perms(request.env), + 'guardados': guardados}) + except Exception as e: + request.env.cr.rollback() + return json_response({'status': 'error', 'message': str(e)}, 500) + # ============================================================ # Dashboard # ============================================================ @@ -1932,6 +2203,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/patients/export', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') + @require_perm('pacientes.exportar') def export_patients(self, **kw): """Todos los pacientes activos que cumplen los filtros, SIN paginar. @@ -1973,6 +2245,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('pacientes.crear') def create_patient(self, **kw): try: data = _parse_json_body() @@ -2048,6 +2321,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/patients/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') + @require_perm('pacientes.editar') def update_patient(self, patient_id, **kw): try: patient = request.env['res.partner'].sudo().browse(patient_id) @@ -2642,6 +2916,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/patients//deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('ventas.cobrar') @forbid_roles('marketing') def patient_deposit(self, patient_id, **kw): """Recarga de saldo a favor (anticipo): dinero a cuenta del paciente. @@ -2740,6 +3015,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/patients//pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('ventas.cobrar') @forbid_roles('marketing') def pay_patient_adeudo(self, patient_id, **kw): """Registra un pago del adeudo (parcial o total) y devuelve el recibo. @@ -3450,7 +3726,8 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False) - @require_role('admin') + @require_role('gerencia') + @require_perm('servicios.editar') def create_service(self, **kw): try: data = _parse_json_body() @@ -3492,7 +3769,8 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) - @require_role('admin') + @require_role('gerencia') + @require_perm('servicios.editar') def reorder_services(self, **kw): """Reordena el catálogo y renumera las secuencias (10, 20, 30...). @@ -3533,7 +3811,8 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/services/', type='http', auth='none', methods=['PUT'], csrf=False) - @require_role('admin') + @require_role('gerencia') + @require_perm('servicios.editar') def update_service(self, service_id, **kw): try: service = request.env['skeen.servicio'].sudo().browse(service_id) @@ -4345,6 +4624,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('ventas.cobrar') @forbid_roles('marketing') def create_payment(self, **kw): try: @@ -4362,6 +4642,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/payments//confirm', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('ventas.cobrar') @forbid_roles('marketing') def confirm_payment(self, payment_id, **kw): try: @@ -4530,6 +4811,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sales//pay', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('ventas.cobrar') @forbid_roles('marketing') def pay_sale(self, sale_id, **kw): try: @@ -4568,6 +4850,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sales//refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('ventas.devolver') @forbid_roles('marketing') def refund_sale(self, sale_id, **kw): try: @@ -4584,6 +4867,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sales//cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('ventas.cancelar') @forbid_roles('marketing') def cancel_sale(self, sale_id, **kw): """Cancelación de venta con nota de crédito (motivo obligatorio). @@ -4747,6 +5031,7 @@ class SkeenFrontendController(http.Controller): # ============================================================ @http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('ventas.vender') @forbid_roles('marketing') def pos_checkout(self, **kw): """Crea la venta confirmada, aplica canje de puntos y/o registra el pago. @@ -4767,6 +5052,11 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404) discount = float(data.get('discount', 0) or 0) + if discount > 0 and not has_perm(request.frontend_user, 'ventas.descuentos'): + return json_response({ + 'status': 'error', + 'message': 'Sin permiso: %s' % PERM_LABELS.get('ventas.descuentos', 'Aplicar descuentos'), + }, 403) line_vals = [] item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente @@ -5016,6 +5306,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/cash_closings//close', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('caja.cerrar') @forbid_roles('marketing') def close_cash_closing(self, closing_id, **kw): try: @@ -6181,6 +6472,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False) @require_role('admin') + @require_perm('config.editar') def set_exchange_rate(self, **kw): try: data = _parse_json_body() @@ -6238,6 +6530,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def set_sales_goal(self, **kw): try: data = _parse_json_body() @@ -6262,6 +6555,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def set_commission_default(self, **kw): try: data = _parse_json_body() @@ -6285,6 +6579,7 @@ class SkeenFrontendController(http.Controller): # ============================================================ @http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def config_impact_simulation(self, **kw): """Qué habría pasado con los valores configurados (o unos propuestos). @@ -6381,6 +6676,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.auditoria') def config_audit_list(self, **kw): """Bitácora de cambios de configuración (filtrable y paginada).""" try: @@ -6492,6 +6788,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/brand', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def put_brand(self, **kw): """Guarda el brand. Solo admin del sistema; queda en la bitácora.""" try: @@ -6571,6 +6868,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/glosario', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def put_glosario(self, **kw): """Guarda pack y overrides. Los overrides pisan al pack. @@ -6632,6 +6930,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/clinic-settings', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def put_clinic_settings_api(self, **kw): """Guarda los ajustes mergeando sobre lo actual. @@ -6699,6 +6998,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sucursales', type='http', auth='none', methods=['POST'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def create_sucursal(self, **kw): """Crea una sucursal. Sin `code` se genera un slug del nombre.""" try: @@ -6750,6 +7050,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sucursales/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def update_sucursal(self, sucursal_id, **kw): try: if not self._puede_configurar_catalogo(): @@ -6806,6 +7107,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/sucursales/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def delete_sucursal(self, sucursal_id, **kw): """Desactiva la sucursal (nunca se borra: las citas la referencian).""" try: @@ -6868,6 +7170,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/service-categories', type='http', auth='none', methods=['POST'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def create_service_category(self, **kw): """Crea una categoría (las creadas por API nunca son de sistema).""" try: @@ -6914,6 +7217,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/service-categories/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def update_service_category(self, category_id, **kw): """Edita una categoría. `is_system` no se puede cambiar.""" try: @@ -6964,6 +7268,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/service-categories/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') + @require_perm('config.editar') def delete_service_category(self, category_id, **kw): """Desactiva la categoría. Las de sistema no se pueden eliminar.""" try: @@ -7018,6 +7323,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/ticket_config', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def put_ticket_config(self, **kw): """Guarda el diseño del ticket. Requiere motivo para la bitácora.""" try: @@ -7048,6 +7354,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/ticket_config/clinica', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def put_ticket_clinica(self, **kw): """Perfil de la clínica que sale en el encabezado del ticket (y logo).""" try: @@ -7081,6 +7388,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/ticket_config/reset', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('admin') + @require_perm('config.editar') def reset_ticket_config(self, **kw): """Devuelve el diseño al formato original (el del ticket de siempre).""" try: @@ -7244,6 +7552,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('inventario.mover') @forbid_roles('marketing') def inventory_create(self, **kw): try: @@ -7271,6 +7580,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/inventory/items/', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('recepcion') + @require_perm('inventario.ajustar') @forbid_roles('marketing') def inventory_update(self, item_id, **kw): try: @@ -7303,6 +7613,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/inventory/items//moves', type='http', auth='none', methods=['POST'], csrf=False) @require_role('recepcion') + @require_perm('inventario.mover') @forbid_roles('marketing') def inventory_move_create(self, item_id, **kw): try: @@ -7366,6 +7677,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('inventario.mover') @forbid_roles('marketing') def inventario_compra(self, **kw): """Registrar compra de consumible (sube stock; actualiza costo si viene)""" @@ -7455,6 +7767,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('inventario.mover') @forbid_roles('marketing') def inventario_baja(self, **kw): """Registrar baja/merma de consumible (baja stock)""" @@ -7532,6 +7845,7 @@ class SkeenFrontendController(http.Controller): @http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('inventario.ajustar') @forbid_roles('marketing') def create_conteo(self, **kw): """Registra un conteo físico y aplica los ajustes por diferencia. diff --git a/odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py b/odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py index 06caa80..cb05b85 100644 --- a/odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py +++ b/odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py @@ -7,7 +7,7 @@ Expone endpoints REST para el frontend SKEEN. from odoo import http, fields from odoo.http import request from .frontend import json_response, _parse_json_body -from .auth import require_role +from .auth import require_role, require_perm import os import logging import requests @@ -397,6 +397,8 @@ class SkeenWacrmProxyController(http.Controller): # Mensajes # ============================================================ @http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + @require_perm('whatsapp.responder') def send_message(self, **kw): try: data = _parse_json_body() @@ -547,6 +549,8 @@ class SkeenWacrmProxyController(http.Controller): return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/conversations//assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + @require_perm('whatsapp.asignar') def assign_conversation(self, external_id, **kw): try: data = _parse_json_body() @@ -641,6 +645,8 @@ class SkeenWacrmProxyController(http.Controller): return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/leads//status', type='http', auth='none', methods=['PUT'], csrf=False) + @require_role('lectura') + @require_perm('whatsapp.asignar') def update_lead_status(self, lead_id, **kw): try: deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id) @@ -679,6 +685,8 @@ class SkeenWacrmProxyController(http.Controller): return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/leads//convert', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('lectura') + @require_perm('whatsapp.asignar') def convert_lead(self, lead_id, **kw): """Convierte el lead en paciente SKEEN (crea o liga por teléfono) y lo marca ganado""" try: @@ -805,6 +813,7 @@ class SkeenWacrmProxyController(http.Controller): @http.route('/skeen/frontend/v1/wacrm/conversations//notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('whatsapp.responder') def create_note(self, external_id, **kw): try: data = _parse_json_body() @@ -827,6 +836,7 @@ class SkeenWacrmProxyController(http.Controller): @http.route('/skeen/frontend/v1/wacrm/notes/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('whatsapp.responder') def update_note(self, note_id, **kw): """Marca/desmarca una nota como atendida, o edita su texto.""" try: @@ -854,6 +864,7 @@ class SkeenWacrmProxyController(http.Controller): @http.route('/skeen/frontend/v1/wacrm/notes/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_perm('whatsapp.responder') def delete_note(self, note_id, **kw): try: note = request.env['skeen.wacrm.note'].sudo().browse(note_id) @@ -876,6 +887,7 @@ class SkeenWacrmProxyController(http.Controller): @http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('whatsapp.responder') def create_tag(self, **kw): try: data = _parse_json_body() @@ -906,6 +918,7 @@ class SkeenWacrmProxyController(http.Controller): @http.route('/skeen/frontend/v1/wacrm/tags/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') + @require_perm('whatsapp.responder') def update_tag(self, tag_id, **kw): try: tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id) @@ -936,6 +949,7 @@ class SkeenWacrmProxyController(http.Controller): @http.route('/skeen/frontend/v1/wacrm/tags/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') + @require_perm('whatsapp.responder') def delete_tag(self, tag_id, **kw): """Desactiva la etiqueta (conserva el histórico de asignaciones).""" try: diff --git a/odoo-addons/skeen_whatsapp/data/roles.xml b/odoo-addons/skeen_whatsapp/data/roles.xml new file mode 100644 index 0000000..6868dc5 --- /dev/null +++ b/odoo-addons/skeen_whatsapp/data/roles.xml @@ -0,0 +1,89 @@ + + + + + Administrador + admin + 100 + 10 + + Acceso total al sistema y a la configuración. + + + Médico + medico + 50 + 20 + + Atención clínica; sin pantallas de dinero. + + + Recepción + recepcion + 50 + 30 + + Agenda, pacientes, ventas y cobros. + + + Enfermería + enfermeria + 50 + 40 + + Apoyo clínico y visitas. + + + Cosmetología + cosmetologia + 50 + 50 + + Servicios de cosmetología. + + + Psicóloga + psicologa + 50 + 60 + + Servicios de psicología. + + + Farmacia + farmacia + 50 + 70 + + Dispensación de productos y medicamentos. + + + Gerencia + gerencia + 50 + 80 + + Administra usuarios, roles y catálogos. + + + Marketing + marketing + 50 + 90 + + Agenda, pacientes y WhatsApp; sin caja. + + + Lectura + lectura + 10 + 100 + + Solo consulta, sin escritura. + + diff --git a/odoo-addons/skeen_whatsapp/models/__init__.py b/odoo-addons/skeen_whatsapp/models/__init__.py index b90e98f..f5aa55d 100644 --- a/odoo-addons/skeen_whatsapp/models/__init__.py +++ b/odoo-addons/skeen_whatsapp/models/__init__.py @@ -1,5 +1,6 @@ from . import main from . import wacrm_sync +from . import role from . import frontend_user from . import config_audit from . import wacrm_notes diff --git a/odoo-addons/skeen_whatsapp/models/frontend_user.py b/odoo-addons/skeen_whatsapp/models/frontend_user.py index 6d080d3..f4879c2 100644 --- a/odoo-addons/skeen_whatsapp/models/frontend_user.py +++ b/odoo-addons/skeen_whatsapp/models/frontend_user.py @@ -5,7 +5,8 @@ import json from odoo import models, fields, api, _ from odoo.exceptions import ValidationError -from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms +from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms, role_rank_map +from ..permissions import resolve_role_perms_for_user try: from werkzeug.security import check_password_hash, generate_password_hash @@ -23,7 +24,9 @@ class SkeenFrontendUser(models.Model): login = fields.Char(string='Usuario', required=True, index=True) name = fields.Char(string='Nombre', required=True) password_hash = fields.Char(string='Hash de contraseña', required=True) - role = fields.Selection(ROLE_SELECTION, string='Rol', default='recepcion', required=True) + role = fields.Selection( + selection='_get_role_selection', string='Rol', default='recepcion', + required=True) active = fields.Boolean(string='Activo', default=True) must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False) last_login = fields.Datetime(string='Último acceso', readonly=True) @@ -63,7 +66,14 @@ class SkeenFrontendUser(models.Model): def role_rank(self): """Jerarquía simple para permisos: mayor número = más acceso.""" self.ensure_one() - return ROLE_RANKS.get(self.role, 0) + return role_rank_map(self.env).get(self.role, ROLE_RANKS.get(self.role, 0)) + + @api.model + def _get_role_selection(self): + """Selección de roles configurados (incluye inactivos para no invalidar + usuarios históricos); si aún no hay roles usa la lista de siempre.""" + from ..roles import role_selection + return role_selection(self.env) def _allowed_menus_list(self): """Devuelve la lista de hrefs permitidos o None (usar defaults por rol)""" @@ -99,6 +109,7 @@ class SkeenFrontendUser(models.Model): 'login': self.login, 'name': self.name, 'role': self.role, + 'role_name': self._role_name(), '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(), @@ -106,4 +117,14 @@ class SkeenFrontendUser(models.Model): # cruda, para que el editor de usuarios sepa si está heredando. 'agenda_perms': resolve_agenda_perms(self), 'agenda_perms_custom': self._agenda_perms_list(), + # Permisos por acción vigentes (rol + overrides), para que el + # frontend sepa qué ocultar sin consultar la configuración. + 'perms': resolve_role_perms_for_user(self), } + + def _role_name(self): + """Nombre del rol configurado (o el código si no existe).""" + self.ensure_one() + rol = self.env['skeen.role'].sudo().with_context(active_test=False).search( + [('code', '=', self.role)], limit=1) + return rol.name if rol else self.role diff --git a/odoo-addons/skeen_whatsapp/models/role.py b/odoo-addons/skeen_whatsapp/models/role.py new file mode 100644 index 0000000..10004b5 --- /dev/null +++ b/odoo-addons/skeen_whatsapp/models/role.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields + + +class SkeenRole(models.Model): + """Rol del frontend React. + + Antes los roles eran un Selection hardcodeado en skeen.frontend.user. Ahora + son datos: el `code` es el valor que se guarda en los usuarios, por eso los + roles semilla conservan los códigos actuales y ningún usuario histórico se + invalida. Los roles de sistema (is_system) se pueden renombrar pero no + eliminar. + """ + _name = 'skeen.role' + _description = 'Rol del frontend' + _order = 'sequence, name' + + name = fields.Char(string='Nombre', required=True, translate=True) + code = fields.Char( + string='Código', required=True, copy=False, + help='Identificador estable que se guarda en los usuarios.') + rank = fields.Integer( + string='Rango', default=50, + help='Jerarquía: a mayor rango, más acceso de lectura/escritura. ' + 'Administrador=100, roles operativos=50, solo lectura=10.') + sequence = fields.Integer(string='Secuencia', default=10) + active = fields.Boolean(string='Activo', default=True) + is_system = fields.Boolean( + string='De sistema', default=False, + help='Los roles de sistema no se pueden eliminar, solo renombrar.') + description = fields.Text(string='Descripción') + + _sql_constraints = [ + ('unique_code', 'unique(code)', 'El código del rol debe ser único!') + ] diff --git a/odoo-addons/skeen_whatsapp/permissions.py b/odoo-addons/skeen_whatsapp/permissions.py new file mode 100644 index 0000000..6fb8a30 --- /dev/null +++ b/odoo-addons/skeen_whatsapp/permissions.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +"""Catálogo de permisos por ACCIÓN y defaults por rol. + +A diferencia de la matriz de menús (qué secciones ve un rol) y de los permisos +de agenda por usuario, esto responde a "qué puede HACER" cada rol dentro de las +pantallas: cancelar un ticket, aplicar descuentos, cerrar caja, exportar, +administrar usuarios, etc. + +Sin dependencias de Odoo para poder importarse desde modelos y controladores. + +Persistencia: ir.config_parameter 'skeen.role_perms' = JSON +{role_code: {perm_key: true/false}}. Lo guardado manda sobre los defaults; no +hay que persistir los defaults (se calculan). + +REGLA DE ORO: con la configuración por defecto TODO se comporta como antes. +Los defaults solo son restrictivos donde ya lo era el guard viejo +(@require_role) o donde el rol es claramente de solo lectura. Todo lo demás +queda permitido para los roles de escritura y son los guards existentes +(veto de 'medico' a dinero, forbid_roles('marketing'), _puede_gestionar_*) +los que siguen mandando. Así ningún usuario pierde acceso al desplegar. +""" + +import json + +ROLE_PERMS_KEY = 'skeen.role_perms' + +# --------------------------------------------------------------------------- +# Catálogo: grupos -> [(clave, etiqueta)]. El orden es el de la UI. +# --------------------------------------------------------------------------- +PERMISSION_GROUPS = [ + ('agenda', 'Agenda', [ + ('agenda.ver', 'Consultar la agenda'), + ('agenda.agendar', 'Agendar citas'), + ('agenda.editar', 'Editar / reprogramar citas'), + ('agenda.eliminar', 'Eliminar o cancelar citas'), + ('agenda.mover', 'Mover citas de horario'), + ('agenda.visitas', 'Generar visitas (check-in)'), + ('agenda.todas', 'Ver la agenda de todos (no solo la propia)'), + ('agenda.bloquear', 'Bloquear / cerrar agenda'), + ]), + ('pacientes', 'Pacientes', [ + ('pacientes.ver', 'Ver pacientes'), + ('pacientes.expediente', 'Ver expediente completo'), + ('pacientes.crear', 'Crear pacientes'), + ('pacientes.editar', 'Editar pacientes'), + ('pacientes.eliminar', 'Eliminar pacientes'), + ('pacientes.exportar', 'Exportar pacientes'), + ('pacientes.saldos', 'Ver saldos y adeudos'), + ]), + ('visitas', 'Visitas y expedientes', [ + ('visitas.ver', 'Ver visitas'), + ('visitas.crear', 'Crear visitas / consultas'), + ('visitas.editar', 'Editar visitas'), + ('visitas.eliminar', 'Eliminar visitas'), + ('visitas.archivos', 'Subir archivos al expediente'), + ]), + ('ventas', 'Ventas y cobros', [ + ('ventas.vender', 'Vender (punto de venta)'), + ('ventas.cobrar', 'Cobrar y registrar pagos'), + ('ventas.descuentos', 'Aplicar descuentos'), + ('ventas.cancelar', 'Cancelar tickets / ventas'), + ('ventas.devolver', 'Hacer devoluciones'), + ('ventas.reimprimir', 'Reimprimir tickets'), + ]), + ('inventario', 'Inventario', [ + ('inventario.ver', 'Ver inventario'), + ('inventario.ajustar', 'Ajustar existencias'), + ('inventario.mover', 'Registrar movimientos / compras / bajas'), + ('inventario.costos', 'Editar costos'), + ]), + ('servicios', 'Servicios y catálogo', [ + ('servicios.ver', 'Ver servicios y catálogo'), + ('servicios.editar', 'Editar servicios y precios'), + ]), + ('reportes', 'Reportes y caja', [ + ('reportes.ver', 'Ver reportes'), + ('reportes.exportar', 'Exportar reportes'), + ('caja.ver', 'Ver cortes de caja'), + ('caja.cerrar', 'Cerrar caja'), + ]), + ('whatsapp', 'WhatsApp y leads', [ + ('whatsapp.ver', 'Ver conversaciones y leads'), + ('whatsapp.responder', 'Responder conversaciones'), + ('whatsapp.asignar', 'Asignar conversaciones y leads'), + ]), + ('config', 'Usuarios y configuración', [ + ('config.usuarios', 'Administrar usuarios'), + ('config.roles', 'Administrar roles y menús'), + ('config.editar', 'Editar la configuración de la clínica'), + ('config.auditoria', 'Ver la auditoría'), + ]), +] + +PERM_KEYS = tuple( + key for _g, _label, perms in PERMISSION_GROUPS for key, _l in perms +) + +PERM_LABELS = { + key: label for _g, _label, perms in PERMISSION_GROUPS for key, label in perms +} + +# Permisos que sólo son lectura: son los que conserva el rol 'lectura'. +READ_PERM_KEYS = ( + 'agenda.ver', 'agenda.todas', + 'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos', + 'visitas.ver', + 'inventario.ver', + 'servicios.ver', + 'reportes.ver', 'reportes.exportar', 'caja.ver', + 'ventas.reimprimir', + 'whatsapp.ver', +) + +# --------------------------------------------------------------------------- +# Conjuntos de roles para los defaults. +# W = roles con rango de escritura (todo menos 'lectura'). +# WN = W sin marketing (a quien forbid_roles cierra los módulos de dinero). +# MONEY = W sin medico ni marketing (veto de medico a las pantallas de dinero). +# AG = admin + gerencia (los que hoy administran usuarios/roles/catálogos). +# AD = admin (lo que hoy exige @require_role('admin')). +# ALL = W + lectura. +# --------------------------------------------------------------------------- +W = ('admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', + 'psicologa', 'farmacia', 'gerencia', 'marketing') +WN = tuple(r for r in W if r != 'marketing') +MONEY = tuple(r for r in W if r not in ('medico', 'marketing')) +AG = ('admin', 'gerencia') +AD = ('admin',) +ALL = W + ('lectura',) + +# --------------------------------------------------------------------------- +# Defaults por permiso. Tabla explicable: +# - lectura -> solo los READ_PERM_KEYS. +# - W -> escritura general (agenda, pacientes, visitas, visitas...). +# - WN / MONEY -> bloques de dinero: coinciden con require_role('recepcion') +# + forbid_roles('marketing') y con el veto de 'medico'. +# - AG / AD -> administración (hoy restringida a admin/gerencia o admin). +# --------------------------------------------------------------------------- +DEFAULT_PERM_ROLES = { + # Agenda (hoy: require_role('recepcion') para escribir) + 'agenda.ver': ALL, + 'agenda.agendar': W, + 'agenda.editar': W, + 'agenda.eliminar': W, + 'agenda.mover': W, + 'agenda.visitas': W, + 'agenda.todas': W, + 'agenda.bloquear': W, + # Pacientes + 'pacientes.ver': ALL, + 'pacientes.expediente': ALL, + 'pacientes.crear': W, + 'pacientes.editar': W, + 'pacientes.eliminar': W, + 'pacientes.exportar': ALL, + 'pacientes.saldos': ALL, + # Visitas + 'visitas.ver': ALL, + 'visitas.crear': W, + 'visitas.editar': W, + 'visitas.eliminar': W, + 'visitas.archivos': W, + # Ventas y cobros + 'ventas.vender': MONEY, + 'ventas.cobrar': WN, # medico también cobra depósitos de paciente hoy + 'ventas.descuentos': MONEY, + 'ventas.cancelar': AG, # hoy: admin/gerencia (chequeo explícito en el endpoint) + 'ventas.devolver': MONEY, + 'ventas.reimprimir': ALL, + # Inventario (hoy: require_role('recepcion') + forbid_roles('marketing')) + 'inventario.ver': ALL, + 'inventario.ajustar': WN, + 'inventario.mover': WN, + 'inventario.costos': WN, + # Servicios y catálogo (hoy services POST/PUT/DELETE exigen admin) + 'servicios.ver': ALL, + 'servicios.editar': AD, + # Reportes y caja + 'reportes.ver': ALL, + 'reportes.exportar': ALL, + 'caja.ver': ALL, + 'caja.cerrar': MONEY, + # WhatsApp y leads (menú WACRM: admin, recepcion, gerencia, marketing) + 'whatsapp.ver': W, + 'whatsapp.responder': W, + 'whatsapp.asignar': W, + # Usuarios y configuración + 'config.usuarios': AG, + 'config.roles': AG, + 'config.editar': AG, + 'config.auditoria': AD, +} + +# Los roles nuevos (creados desde la UI) arrancan como 'lectura': el admin +# decide después qué permisos les da. Si no, cualquier rol nuevo tendría acceso +# de escritura sin que nadie lo pidiera. +NEW_ROLE_BASE = 'lectura' + + +def default_perms_for(role_code): + """Dict {perm_key: bool} por defecto para un rol (base por rango).""" + base_role = role_code if role_code in W or role_code == 'lectura' else NEW_ROLE_BASE + perms = {} + for key in PERM_KEYS: + if base_role == 'lectura': + perms[key] = key in READ_PERM_KEYS + else: + perms[key] = base_role in (DEFAULT_PERM_ROLES.get(key) or ()) + return perms + + +def default_role_perms(): + """Defaults completos {role_code: {perm_key: bool}} para los 10 roles base. + + No incluye roles personalizados (no se conocen sin consultar Odoo); el + frontend usa su propio espejo para eso. + """ + return {code: default_perms_for(code) for code in + ('admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', + 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura')} + + +def _stored(env): + """Overrides guardados {role_code: {perm_key: bool}} (o {}).""" + raw = env['ir.config_parameter'].sudo().get_param(ROLE_PERMS_KEY) + if not raw: + return {} + try: + val = json.loads(raw) + except Exception: + return {} + if not isinstance(val, dict): + return {} + clean = {} + for role, perms in val.items(): + if isinstance(perms, dict): + clean[role] = {k: bool(v) for k, v in perms.items() if k in PERM_KEYS} + return clean + + +def resolve_role_perms(env, role_code): + """Permisos vigentes de un rol: defaults + overrides guardados.""" + perms = default_perms_for(role_code) + perms.update(_stored(env).get(role_code) or {}) + return perms + + +def resolve_role_perms_for_user(user): + """Permisos vigentes del usuario (por su rol). None -> todo False.""" + if user is None: + return {k: False for k in PERM_KEYS} + return resolve_role_perms(user.env, user.role) + + +def has_perm(user, perm_key): + """True si el usuario puede ejecutar la acción. Admin siempre puede.""" + if user is None: + return False + if user.role == 'admin': + return True + return bool(resolve_role_perms_for_user(user).get(perm_key)) + + +def all_roles_perms(env): + """{role_code: {perm_key: bool}} para todos los roles en skeen.role.""" + roles = env['skeen.role'].sudo().with_context(active_test=False).search([]) + codes = [r.code for r in roles if r.code] + if not codes: + return default_role_perms() + return {code: resolve_role_perms(env, code) for code in codes} + + +def set_role_perms(env, role_code, perms): + """Guarda permisos de un rol (merge con lo ya guardado). False si no aplica.""" + rol = env['skeen.role'].sudo().with_context(active_test=False).search( + [('code', '=', role_code)], limit=1) + if not rol: + return False + clean = {k: bool(v) for k, v in (perms or {}).items() if k in PERM_KEYS} + if not clean: + return False + stored = _stored(env) + current = dict(stored.get(role_code) or {}) + current.update(clean) + stored[role_code] = current + env['ir.config_parameter'].sudo().set_param(ROLE_PERMS_KEY, json.dumps(stored)) + return True + + +def reset_role_perms(env, role_code): + """Quita los overrides de un rol (vuelve a los defaults).""" + stored = _stored(env) + if role_code in stored: + stored.pop(role_code) + env['ir.config_parameter'].sudo().set_param(ROLE_PERMS_KEY, json.dumps(stored)) + return True diff --git a/odoo-addons/skeen_whatsapp/roles.py b/odoo-addons/skeen_whatsapp/roles.py index 9482e79..f4715be 100644 --- a/odoo-addons/skeen_whatsapp/roles.py +++ b/odoo-addons/skeen_whatsapp/roles.py @@ -42,6 +42,42 @@ ROLE_RANKS = { 'lectura': 10, } +# ------------------------------------------------------------------ +# Roles dinámicos (modelo skeen.role). ROLE_SELECTION/ROLE_RANKS quedan como +# respaldo para el arranque (antes de que la semilla XML esté disponible) y para +# no romper instalaciones viejas. +# ------------------------------------------------------------------ + + +def role_records(env): + """Registros de skeen.role (incluye inactivos) o lista vacía si no existe.""" + try: + return env['skeen.role'].sudo().with_context(active_test=False).search([]) + except KeyError: + return [] + + +def role_selection(env): + """[(code, name)] de los roles configurados, con respaldo estático.""" + seleccion = [(r.code, r.name) for r in role_records(env) if r.code] + return seleccion or ROLE_SELECTION + + +def role_keys(env): + """Códigos de rol válidos (incluye inactivos, para no invalidar históricos).""" + return tuple(k for k, _ in role_selection(env)) + + +def role_rank_map(env): + """{code: rank} de los roles configurados, con respaldo estático.""" + mapa = {r.code: r.rank for r in role_records(env) if r.code} + return mapa or dict(ROLE_RANKS) + + +def min_rank(env, code): + """Rango mínimo exigido por un código ('recepcion' -> 50 por defecto).""" + return role_rank_map(env).get(code, ROLE_RANKS.get(code, 10)) + MENU_ROLES_KEY = 'skeen.menu_roles' # ------------------------------------------------------------------ @@ -69,16 +105,30 @@ WRITE_RANK_MIN = 'recepcion' # rango, no por nombre, para no duplicar la lista de roles. +# Mapa de cada acción de agenda al permiso por rol equivalente. El permiso de +# rol es el default; la lista propia del usuario (agenda_perms) lo pisa. +AGENDA_ROLE_PERM = { + 'ver': 'agenda.ver', + 'agendar': 'agenda.agendar', + 'editar': 'agenda.editar', + 'eliminar': 'agenda.eliminar', + 'visitas': 'agenda.visitas', +} + + def agenda_defaults_for(user): - """Permisos de agenda por defecto de un usuario (sin lista propia).""" - ver = ['ver'] + """Permisos de agenda por defecto de un usuario (sin lista propia). + + Se derivan de los permisos por acción del rol; 'ver' siempre se conserva. + """ 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) + return ['ver'] + from .permissions import resolve_role_perms_for_user + role_perms = resolve_role_perms_for_user(user) + acciones = [a for a, key in AGENDA_ROLE_PERM.items() if role_perms.get(key)] + if 'ver' not in acciones: + acciones.insert(0, 'ver') + return acciones def resolve_agenda_perms(user): diff --git a/odoo-addons/skeen_whatsapp/security/ir.model.access.csv b/odoo-addons/skeen_whatsapp/security/ir.model.access.csv index 8db7042..d3a0834 100644 --- a/odoo-addons/skeen_whatsapp/security/ir.model.access.csv +++ b/odoo-addons/skeen_whatsapp/security/ir.model.access.csv @@ -6,6 +6,7 @@ access_skeen_wacrm_pipeline,skeen.wacrm.pipeline,model_skeen_wacrm_pipeline,base access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1 access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1 access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1 +access_skeen_role,Roles del frontend,model_skeen_role,base.group_user,1,1,1,1 access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1 access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1 access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1