fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config

This commit is contained in:
2026-10-11 05:50:18 +00:00
parent 28118ce732
commit f92018f88e
50 changed files with 4314 additions and 210 deletions

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Citas',
'version': '1.0.1',
'version': '1.1.0',
'category': 'Healthcare',
'summary': 'Agendamiento de citas para SKEEN Derma Experts',
'description': """
@@ -14,6 +14,7 @@
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
'data/catalogos.xml',
# Menú padre primero: las vistas de abajo cuelgan de él.
'views/menus.xml',
'views/cita_views.xml',

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""Utilidades para los catálogos configurables (sucursales, categorías)."""
import re
import unicodedata
def slugify(text):
"""'Playa de Rosarito' -> 'playa-de-rosarito' (sin acentos, [a-z0-9-])."""
text = unicodedata.normalize('NFKD', str(text or ''))
text = text.encode('ascii', 'ignore').decode('ascii')
text = re.sub(r'[^a-zA-Z0-9]+', '-', text).strip('-').lower()
return text
def unique_slug(model, base, exclude_id=None):
"""Slug único para `model` (sufija -2, -3... si ya existe).
Cuenta también los registros inactivos: el `code` es único a nivel base de
datos y las bajas son lógicas, así que un código usado por algo desactivado
sigue ocupado.
"""
model = model.with_context(active_test=False)
base = slugify(base) or 'item'
domain = [('code', '=', base)]
if exclude_id:
domain.append(('id', '!=', exclude_id))
if not model.search_count(domain):
return base
n = 2
while True:
cand = '%s-%d' % (base, n)
domain = [('code', '=', cand)]
if exclude_id:
domain.append(('id', '!=', exclude_id))
if not model.search_count(domain):
return cand
n += 1

View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""Ajustes de la clínica por instancia (white-label).
Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos
datos solo existían en el localStorage del navegador de cada equipo; ahora son
compartidos por toda la instancia y los consumen el frontend, la impresión de
tickets y las URLs de pago.
El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import json
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
BRAND_KEY = 'skeen.clinic.brand'
BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts'
HORA_APERTURA_DEFAULT = '08:00'
HORA_CIERRE_DEFAULT = '19:00'
ZONA_HORARIA_DEFAULT = 'America/Tijuana'
MONEDA_DEFAULT = 'MXN'
# Campos del contrato del endpoint.
CAMPOS = (
'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc',
'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica',
'moneda_base', 'nota_ticket',
)
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
def brand_dict(env):
"""Brand vigente (subconjunto suficiente), sin defaults de logos."""
raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
return guardado
return {}
def brand_nombre(env):
nombre = brand_dict(env).get('nombre')
nombre = str(nombre).strip() if nombre is not None else ''
return nombre or BRAND_NOMBRE_DEFAULT
def get_settings(env):
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
out = {
'nombre': brand_nombre(env),
'telefono': '',
'email': '',
'direccion': '',
'web': '',
'rfc': '',
'horario_apertura': HORA_APERTURA_DEFAULT,
'horario_cierre': HORA_CIERRE_DEFAULT,
'zona_horaria': ZONA_HORARIA_DEFAULT,
'url_publica': '',
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
for k in CAMPOS:
if k in guardado and guardado[k] is not None:
out[k] = str(guardado[k])
return out
def set_settings(env, vals):
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
if not isinstance(vals, dict):
raise ValueError('Los ajustes deben ser un objeto')
actual = get_settings(env)
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)
def _hhmm_to_float(valor):
"""'08:00' -> 8.0. None si el formato no es válido."""
try:
partes = str(valor).strip().split(':')
h = int(partes[0])
m = int(partes[1]) if len(partes) > 1 else 0
if not (0 <= h <= 24 and 0 <= m <= 59):
return None
return h + m / 60.0
except (ValueError, TypeError, IndexError):
return None
def agenda_bounds(env):
"""(inicio, fin) de la agenda en horas float.
Lee horario_apertura/horario_cierre de los ajustes; si faltan o son
incoherentes usa el horario de siempre (8:00-19:00), de modo que una
instancia sin configurar se comporta igual que hoy.
"""
s = get_settings(env)
inicio = _hhmm_to_float(s.get('horario_apertura'))
fin = _hhmm_to_float(s.get('horario_cierre'))
if inicio is None:
inicio = AGENDA_START_DEFAULT
if fin is None:
fin = AGENDA_END_DEFAULT
if fin <= inicio:
return AGENDA_START_DEFAULT, AGENDA_END_DEFAULT
return inicio, fin
def _es_url_privada(url):
"""True si el host es local/privado (no sirve para URLs de pago públicas)."""
try:
host = (urlparse(url).hostname or '').lower()
except ValueError:
return True
if not host or host in ('localhost', '127.0.0.1', '::1'):
return True
if host.startswith('10.') or host.startswith('192.168.'):
return True
if host.startswith('172.'):
try:
segundo = int(host.split('.')[1])
except (IndexError, ValueError):
return True
if 16 <= segundo <= 31:
return True
return False
def url_publica(env, fallback=''):
"""URL pública base de la instancia, sin barra final.
Orden: ajustes de la clínica -> brand -> `web.base.url` (solo si es público)
-> `fallback` (el valor que usaba el código antes). El último escalón
preserva el comportamiento actual en instancias sin configurar.
"""
url = (get_settings(env).get('url_publica') or '').strip()
if not url:
url = str(brand_dict(env).get('url_publica') or '').strip()
if not url:
base = env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
if base and not _es_url_privada(base):
url = base
if not url:
url = fallback
return (url or '').rstrip('/')

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Catálogos semilla configurables (white-label).
noupdate="1": se crean una sola vez; los cambios que haga la clínica
(renombrar, desactivar, agregar) sobreviven a los upgrades.
Los `code` coinciden con los valores que ya estaban hardcodeados en
skeen.cita.branch y skeen.servicio.category, para no tocar los datos
históricos. -->
<data noupdate="1">
<!-- Sucursales -->
<record id="sucursal_rosarito" model="skeen.sucursal">
<field name="name">Rosarito</field>
<field name="code">rosarito</field>
<field name="sequence">10</field>
<field name="is_default" eval="True"/>
</record>
<record id="sucursal_tijuana" model="skeen.sucursal">
<field name="name">Tijuana</field>
<field name="code">tijuana</field>
<field name="sequence">20</field>
</record>
<!-- Categorías de servicio -->
<record id="service_category_consulta" model="skeen.service.category">
<field name="name">Consulta</field>
<field name="code">consulta</field>
<field name="sequence">10</field>
<field name="is_system" eval="True"/>
</record>
<record id="service_category_tratamiento" model="skeen.service.category">
<field name="name">Tratamiento</field>
<field name="code">tratamiento</field>
<field name="sequence">20</field>
<field name="is_system" eval="True"/>
</record>
<record id="service_category_procedimiento" model="skeen.service.category">
<field name="name">Procedimiento</field>
<field name="code">procedimiento</field>
<field name="sequence">30</field>
<field name="is_system" eval="True"/>
</record>
<record id="service_category_paquete" model="skeen.service.category">
<field name="name">Paquete</field>
<field name="code">paquete</field>
<field name="sequence">40</field>
<field name="is_system" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
"""Glosario configurable de la clínica (terminología white-label).
Solo lee/escribe el parámetro `skeen.glosario` (JSON). No hay modelo propio:
el pack base y los overrides se resuelven en memoria.
Estructura guardada:
{"pack": "clinica", "overrides": {"patient_singular": "Huésped"}}
Resolución:
terms = PACKS[pack] (o 'clinica' si el pack no existe) + overrides
El pack 'custom' es válido: parte del pack 'clinica' y aplica los overrides.
Así el endpoint siempre devuelve las 24 claves canónicas resueltas.
"""
import json
GLOSARIO_KEY = 'skeen.glosario'
DEFAULT_PACK = 'clinica'
CUSTOM_PACK = 'custom'
# Las 24 claves canónicas, en el orden del brief (contrato del frontend).
GLOSARIO_KEYS = (
'patient_singular', 'patient_plural',
'appointment_singular', 'appointment_plural',
'visit_singular', 'visit_plural',
'record_singular', 'record_plural',
'doctor_singular', 'doctor_plural',
'staff_singular', 'staff_plural',
'branch_singular', 'branch_plural',
'room_singular', 'room_plural',
'service_singular', 'service_plural',
'package_singular', 'package_plural',
'prescription_singular', 'prescription_plural',
'treatment_singular', 'treatment_plural',
)
# Nombre legible de cada pack para el selector de configuración.
PACK_LABELS = {
'clinica': 'Clínica',
'spa': 'Spa / Wellness',
'estetica': 'Estética',
'dental': 'Dental',
}
PACKS = {
'clinica': {
'patient_singular': 'Paciente',
'patient_plural': 'Pacientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Visita',
'visit_plural': 'Visitas',
'record_singular': 'Expediente',
'record_plural': 'Expedientes',
'doctor_singular': 'Médico',
'doctor_plural': 'Médicos',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Cabina',
'room_plural': 'Cabinas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'spa': {
'patient_singular': 'Cliente',
'patient_plural': 'Clientes',
'appointment_singular': 'Reserva',
'appointment_plural': 'Reservas',
'visit_singular': 'Sesión',
'visit_plural': 'Sesiones',
'record_singular': 'Ficha',
'record_plural': 'Fichas',
'doctor_singular': 'Terapeuta',
'doctor_plural': 'Terapeutas',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Sala',
'room_plural': 'Salas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Recomendación',
'prescription_plural': 'Recomendaciones',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'estetica': {
'patient_singular': 'Cliente',
'patient_plural': 'Clientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Sesión',
'visit_plural': 'Sesiones',
'record_singular': 'Ficha',
'record_plural': 'Fichas',
'doctor_singular': 'Especialista',
'doctor_plural': 'Especialistas',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Cabina',
'room_plural': 'Cabinas',
'service_singular': 'Servicio',
'service_plural': 'Servicios',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Tratamiento',
'treatment_plural': 'Tratamientos',
},
'dental': {
'patient_singular': 'Paciente',
'patient_plural': 'Pacientes',
'appointment_singular': 'Cita',
'appointment_plural': 'Citas',
'visit_singular': 'Consulta',
'visit_plural': 'Consultas',
'record_singular': 'Expediente',
'record_plural': 'Expedientes',
'doctor_singular': 'Odontólogo',
'doctor_plural': 'Odontólogos',
'staff_singular': 'Personal',
'staff_plural': 'Personal',
'branch_singular': 'Sucursal',
'branch_plural': 'Sucursales',
'room_singular': 'Consultorio',
'room_plural': 'Consultorios',
'service_singular': 'Tratamiento',
'service_plural': 'Tratamientos',
'package_singular': 'Paquete',
'package_plural': 'Paquetes',
'prescription_singular': 'Receta',
'prescription_plural': 'Recetas',
'treatment_singular': 'Procedimiento',
'treatment_plural': 'Procedimientos',
},
}
VALID_PACKS = tuple(PACKS) + (CUSTOM_PACK,)
def pack_terms(pack):
"""Términos base de un pack (copia). 'custom' parte del pack por defecto."""
base = PACKS.get(pack) or PACKS[DEFAULT_PACK]
return dict(base)
def get_config(env):
"""Config vigente: {'pack': str, 'overrides': {clave: valor}}."""
raw = env['ir.config_parameter'].sudo().get_param(GLOSARIO_KEY)
pack = DEFAULT_PACK
overrides = {}
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
p = guardado.get('pack')
if isinstance(p, str) and p:
pack = p
o = guardado.get('overrides')
if isinstance(o, dict):
overrides = {k: str(v) for k, v in o.items()
if k in GLOSARIO_KEYS and v is not None and str(v).strip()}
return {'pack': pack, 'overrides': overrides}
def resolve(env):
"""(pack, terms): las 24 claves resueltas (pack + overrides)."""
cfg = get_config(env)
terms = pack_terms(cfg['pack'])
terms.update(cfg['overrides'])
return cfg['pack'], terms
def set_config(env, pack=None, overrides=None):
"""Guarda pack/overrides. None = conserva lo actual."""
actual = get_config(env)
nuevo_pack = actual['pack'] if pack is None else (str(pack).strip() or DEFAULT_PACK)
nuevos_overrides = actual['overrides']
if overrides is not None:
if not isinstance(overrides, dict):
raise ValueError('overrides debe ser un objeto')
nuevos_overrides = {k: str(v).strip() for k, v in overrides.items()
if k in GLOSARIO_KEYS and v is not None and str(v).strip()}
env['ir.config_parameter'].sudo().set_param(
GLOSARIO_KEY,
json.dumps({'pack': nuevo_pack, 'overrides': nuevos_overrides},
ensure_ascii=False))
return get_config(env)
def list_packs():
"""Packs disponibles con nombre legible."""
return [{'code': code, 'name': PACK_LABELS.get(code, code.capitalize())}
for code in VALID_PACKS]

View File

@@ -3,3 +3,5 @@
from . import cita
from . import bloqueo
from . import lista_espera
from . import sucursal
from . import service_category

View File

@@ -4,6 +4,8 @@ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from datetime import datetime, timedelta
from ..clinic_settings import agenda_bounds
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
# 'waitlist' (Lista de espera) es una cita tentativa sin hueco reservado.
@@ -34,13 +36,12 @@ class SkeenServicio(models.Model):
package_sessions = fields.Integer(string='Sesiones en Paquete', default=0)
package_notes = fields.Char(string='Notas del Paquete')
# Categoría
category = fields.Selection([
('consulta', 'Consulta'),
('tratamiento', 'Tratamiento'),
('procedimiento', 'Procedimiento'),
('paquete', 'Paquete'),
], string='Categoría', default='tratamiento', required=True)
# Categoría (valores configurables en skeen.service.category; los códigos
# semilla 'consulta', 'tratamiento', 'procedimiento' y 'paquete' conservan
# el comportamiento y los lookups actuales del backend)
category = fields.Selection(
selection='_get_category_selection',
string='Categoría', default='tratamiento', required=True)
# Visual / agrupación
color = fields.Char(string='Color', default='#1abc9c')
@@ -61,6 +62,20 @@ class SkeenServicio(models.Model):
('unique_code', 'unique(code)', 'El código debe ser único!')
]
@api.model
def _get_category_selection(self):
"""Categorías configuradas. Incluye las inactivas para no invalidar
servicios históricos; si aún no hay datos usa las de siempre."""
categorias = self.env['skeen.service.category'].sudo().with_context(
active_test=False).search([])
seleccion = [(c.code, c.name) for c in categorias if c.code]
return seleccion or [
('consulta', 'Consulta'),
('tratamiento', 'Tratamiento'),
('procedimiento', 'Procedimiento'),
('paquete', 'Paquete'),
]
class SkeenServiceTag(models.Model):
_name = 'skeen.service.tag'
@@ -103,10 +118,12 @@ class SkeenCita(models.Model):
datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True)
# Ubicación y médico
branch = fields.Selection([
('rosarito', 'Rosarito'),
('tijuana', 'Tijuana'),
], string='Sucursal', default='rosarito')
# Sucursal: valores configurables en skeen.sucursal. Las semilla conservan
# los códigos 'rosarito'/'tijuana' de las citas existentes.
branch = fields.Selection(
selection='_get_branch_selection',
string='Sucursal',
default=lambda self: self.env['skeen.sucursal'].default_code())
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
medium = fields.Selection([
('videocall', 'Videollamada'),
@@ -177,7 +194,16 @@ class SkeenCita(models.Model):
# Paquete terminado (para servicios con categoría 'paquete')
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
@api.model
def _get_branch_selection(self):
"""Sucursales configuradas. Incluye las inactivas para no invalidar
citas históricas; si aún no hay datos usa las de siempre."""
sucursales = self.env['skeen.sucursal'].sudo().with_context(
active_test=False).search([])
seleccion = [(s.code, s.name) for s in sucursales if s.code]
return seleccion or [('rosarito', 'Rosarito'), ('tijuana', 'Tijuana')]
@api.depends('date', 'time', 'servicio_id', 'duration')
def _compute_datetime(self):
for cita in self:
@@ -430,8 +456,8 @@ class SkeenCita(models.Model):
Con doctor_id, además excluye los horarios bloqueados de ese médico
(el usuario no debe poder elegir una hora bloqueada) y respeta su
horario semanal cuando lo tiene configurado (sin horario configurado
el comportamiento es el de siempre: 8:00 a 19:00).
horario semanal cuando lo tiene configurado. Los límites de la jornada
salen de los ajustes de la clínica; sin configurar son 8:00 a 19:00.
`duration` (min) permite simular una duración distinta a la del
servicio, p. ej. cuando recepción la ajusta en el modal de la cita.
@@ -449,9 +475,8 @@ class SkeenCita(models.Model):
('date', '=', date),
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []))
# Horario de la clínica: 8:00 AM a 7:00 PM
start_time = 8.0 # 8:00 AM
end_time = 19.0 # 7:00 PM
# Horario de la clínica (configurable en los ajustes; por defecto 8:00-19:00)
start_time, end_time = agenda_bounds(self.env)
# Horario del médico (si lo tiene configurado manda sobre el de la clínica)
if doctor_id:

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenServiceCategory(models.Model):
"""Categoría de servicio (consulta, tratamiento, procedimiento, paquete...).
Antes era un Selection fijo en skeen.servicio. Los `code` semilla
('consulta', 'tratamiento', 'procedimiento', 'paquete') son los valores que
el backend ya usa en sus búsquedas, por eso deben conservarse.
"""
_name = 'skeen.service.category'
_description = 'Categoría de servicio'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(string='Código', required=True, copy=False)
sequence = fields.Integer(string='Secuencia', default=10)
active = fields.Boolean(string='Activa', default=True)
is_system = fields.Boolean(
string='De sistema', default=False,
help='Las categorías de sistema no se pueden eliminar, solo renombrar.')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código de la categoría debe ser único!')
]

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SkeenSucursal(models.Model):
"""Sucursal de la clínica.
Antes las sucursales eran un Selection fijo ('rosarito', 'tijuana')
hardcodeado en skeen.cita. Ahora son datos: el `code` es el valor que se
guarda en las citas, por eso las sucursales semilla conservan los códigos
actuales y las citas históricas no se tocan.
"""
_name = 'skeen.sucursal'
_description = 'Sucursal de la clínica'
_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 las citas.')
sequence = fields.Integer(string='Secuencia', default=10)
active = fields.Boolean(string='Activa', default=True)
is_default = fields.Boolean(string='Predeterminada', default=False)
address = fields.Char(string='Dirección')
phone = fields.Char(string='Teléfono')
notes = fields.Text(string='Notas')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código de la sucursal debe ser único!')
]
@api.model
def default_code(self):
"""Código de la sucursal predeterminada (o la primera); 'rosarito' si no hay."""
sucursal = self.search([('is_default', '=', True)], limit=1)
if not sucursal:
sucursal = self.search([], limit=1)
return sucursal.code or 'rosarito'

View File

@@ -5,3 +5,5 @@ access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen_cita_state_log,base.group_user,1,1,1,1
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
access_skeen_sucursal_user,Acceso a Sucursales SKEEN,model_skeen_sucursal,base.group_user,1,1,1,1
access_skeen_service_category_user,Acceso a Categorias de Servicio SKEEN,model_skeen_service_category,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_skeen_cita_bloqueo_user Acceso a Bloqueos de Agenda SKEEN model_skeen_cita_bloqueo base.group_user 1 1 1 1
6 access_skeen_cita_state_log_user Acceso a Bitácora de Estados SKEEN model_skeen_cita_state_log base.group_user 1 1 1 1
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1
8 access_skeen_sucursal_user Acceso a Sucursales SKEEN model_skeen_sucursal base.group_user 1 1 1 1
9 access_skeen_service_category_user Acceso a Categorias de Servicio SKEEN model_skeen_service_category base.group_user 1 1 1 1