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

@@ -12,6 +12,20 @@ from .auth import sign_token, current_user, require_role, forbid_roles, verify_t
from .auth import require_agenda, agenda_perm_error
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
from ..roles import AGENDA_PERM_KEYS
from odoo.addons.skeen_citas.clinic_settings import (
agenda_bounds as clinic_agenda_bounds,
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
get_config as get_glosario_config,
list_packs as list_glosario_packs,
resolve as resolver_glosario,
set_config as set_glosario_config,
)
from odoo.addons.skeen_citas.catalogs import slugify as catalog_slugify
from odoo.addons.skeen_citas.catalogs import unique_slug as catalog_unique_slug
def json_response(data, status=200):
@@ -93,20 +107,35 @@ def _patient_search_domain(search):
class SkeenFrontendController(http.Controller):
"""Controladores API públicos para el Frontend React de SKEEN"""
# Horario de agenda de la clínica (hora de inicio permitida de una cita)
# Horario de agenda de la clínica. Son solo el respaldo: el horario real
# sale de los ajustes de la clínica (clinic-settings). Sin configurar se
# usan estos valores, idénticos al comportamiento de siempre.
AGENDA_START = 8.0 # 8:00 AM
AGENDA_END = 19.0 # 7:00 PM
# Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k)
EXPORT_MAX = 100000
def _agenda_bounds(self):
"""(inicio, fin) de la agenda en horas float, según los ajustes."""
try:
return clinic_agenda_bounds(request.env)
except Exception:
return self.AGENDA_START, self.AGENDA_END
def _agenda_horario_txt(self):
"""'8:00 a 19:00' para los mensajes de validación."""
inicio, fin = self._agenda_bounds()
return '%s a %s' % (self._fmt_horas(inicio), self._fmt_horas(fin))
def _valid_agenda_time(self, value):
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
"""Valida hora de inicio dentro del horario de agenda configurado."""
try:
t = float(value)
except (TypeError, ValueError):
return False
return self.AGENDA_START <= t < self.AGENDA_END
inicio, fin = self._agenda_bounds()
return inicio <= t < fin
# ------------------------------------------------------------------
# Auditoría de configuración
@@ -633,7 +662,7 @@ class SkeenFrontendController(http.Controller):
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
created = []
# Para paquetes, generamos citas separadas con fechas consecutivas
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
@@ -688,7 +717,7 @@ class SkeenFrontendController(http.Controller):
apt_time = data.get('time')
if apt_time is None or not self._valid_agenda_time(apt_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
Partner = request.env['res.partner'].sudo()
digits = re.sub(r'\D', '', phone)
@@ -793,7 +822,7 @@ class SkeenFrontendController(http.Controller):
vals = {k: data[k] for k in allowed if k in data}
if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (%s)' % self._agenda_horario_txt()}, 400)
try:
cita.write(vals)
except ValidationError as e:
@@ -1218,9 +1247,11 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
if not all_day and time_to <= time_from:
return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6):
return json_response(
{'status': 'error', 'message': 'El rango de horas debe estar entre 8:00 y 19:00'}, 400)
if not all_day:
agenda_ini, agenda_fin = self._agenda_bounds()
if time_from < agenda_ini - 1e-6 or time_to > agenda_fin + 1e-6:
return json_response(
{'status': 'error', 'message': 'El rango de horas debe estar entre %s' % self._agenda_horario_txt()}, 400)
motivo_tipo = data.get('motivo_tipo') or 'otro'
if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection):
@@ -6416,20 +6447,33 @@ class SkeenFrontendController(http.Controller):
logo_claro - logo para fondos oscuros (login SKEEN, sidebar)
logo_oscuro - logo para fondos claros (tema HomeNest)
pie - línea del pie (login / documentos impresos)
url_publica - URL pública de la instancia (opcional; la usan los
enlaces de pago y correos)
colores - {'primario': '#hex', 'acento': '#hex'} (opcional)
"""
defaults = {
'nombre': 'SKEEN Derma Experts',
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
'url_publica': '',
'colores': {'primario': '', 'acento': ''},
}
raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY)
if raw:
try:
guardado = json.loads(raw)
if isinstance(guardado, dict):
defaults.update({k: str(v) for k, v in guardado.items()
if k in defaults and v is not None and str(v).strip()})
for k in ('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica'):
v = guardado.get(k)
if v is not None and str(v).strip():
defaults[k] = str(v)
colores = guardado.get('colores')
if isinstance(colores, dict):
for c in ('primario', 'acento'):
v = colores.get(c)
if v is not None and str(v).strip():
defaults['colores'][c] = str(v)
except Exception:
pass # JSON roto: se sirven los defaults
return defaults
@@ -6458,8 +6502,29 @@ class SkeenFrontendController(http.Controller):
'message': 'brand debe ser un objeto'}, 400)
antes = self._brand_dict()
limpio = {k: str(brand[k]).strip() for k in
('nombre', 'logo_claro', 'logo_oscuro', 'pie')
('nombre', 'logo_claro', 'logo_oscuro', 'pie', 'url_publica')
if brand.get(k) is not None and str(brand.get(k)).strip()}
# Colores de marca (opcional). Formato hex básico; se ignora lo vacío.
colores = brand.get('colores')
if colores is not None:
if not isinstance(colores, dict):
return json_response({'status': 'error',
'message': 'colores debe ser un objeto'}, 400)
limpio_colores = {}
for clave in ('primario', 'acento'):
valor = colores.get(clave)
if valor is None:
continue
valor = str(valor).strip()
if not valor:
continue
if not re.match(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', valor):
return json_response({
'status': 'error',
'message': 'El color %s debe ser hex (#RRGGBB)' % clave}, 400)
limpio_colores[clave] = valor
if limpio_colores:
limpio['colores'] = limpio_colores
request.env['ir.config_parameter'].sudo().set_param(
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
nuevo = self._brand_dict()
@@ -6474,6 +6539,464 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Terminología de la clínica (glosario) — white-label
# ============================================================
def _puede_configurar_catalogo(self):
"""Sucursales, categorías y terminología: admin o gerencia."""
u = self._fu_user()
return bool(u and u.role in ('admin', 'gerencia'))
@http.route('/skeen/frontend/v1/glosario', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_glosario(self, **kw):
"""Glosario vigente: siempre las 24 claves resueltas (pack + overrides)."""
try:
pack, terms = resolver_glosario(request.env)
return json_response({'status': 'success', 'pack': pack, 'terms': terms})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/glosario/packs', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_glosario_packs(self, **kw):
"""Packs predefinidos con nombre legible, para el selector."""
try:
return json_response({'status': 'success', 'packs': list_glosario_packs()})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/glosario', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def put_glosario(self, **kw):
"""Guarda pack y overrides. Los overrides pisan al pack.
Body: {"pack": "spa", "overrides": {"patient_singular": "Huésped"}}
Si se omite una de las dos claves, se conserva lo que ya había. Los
overrides enviados reemplazan a los anteriores (no se mezclan).
"""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
pack = data.get('pack')
overrides = data.get('overrides')
if pack is not None and str(pack).strip():
if str(pack).strip() not in [p['code'] for p in list_glosario_packs()]:
return json_response({
'status': 'error',
'message': 'Pack no válido: %s' % pack}, 400)
if overrides is not None and not isinstance(overrides, dict):
return json_response({'status': 'error',
'message': 'overrides debe ser un objeto'}, 400)
if isinstance(overrides, dict):
desconocidas = [k for k in overrides if k not in GLOSARIO_KEY_SET]
if desconocidas:
return json_response({
'status': 'error',
'message': 'Claves desconocidas: %s' % ', '.join(desconocidas)}, 400)
antes = get_glosario_config(request.env)
set_glosario_config(request.env, pack=pack, overrides=overrides)
pack_actual, terms = resolver_glosario(request.env)
self._auditar('clinica', 'glosario', 'terminologia',
old_value=json.dumps(antes, ensure_ascii=False)[:500],
new_value=json.dumps({'pack': pack_actual,
'overrides': get_glosario_config(request.env)['overrides']},
ensure_ascii=False)[:500],
entity_label='Terminología de la clínica (glosario)',
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'pack': pack_actual,
'terms': terms,
'message': 'Terminología actualizada'})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Ajustes de la clínica por instancia
# ============================================================
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_clinic_settings_api(self, **kw):
"""Ajustes de la clínica (nombre, contacto, horario, moneda...)."""
try:
return json_response({'status': 'success',
'settings': get_clinic_settings(request.env)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def put_clinic_settings_api(self, **kw):
"""Guarda los ajustes mergeando sobre lo actual.
Acepta los campos al nivel raíz o dentro de {"settings": {...}}.
"""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
vals = data.get('settings') if isinstance(data.get('settings'), dict) else data
antes = get_clinic_settings(request.env)
nuevo = set_clinic_settings(request.env, vals)
self._auditar('clinica', 'clinic-settings', 'ajustes',
old_value=json.dumps(antes, ensure_ascii=False)[:500],
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
entity_label='Ajustes de la clínica',
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'settings': nuevo,
'message': 'Ajustes actualizados'})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Sucursales (configurables)
# ============================================================
def _sucursal_to_dict(self, s):
return {
'id': s.id,
'code': s.code,
'name': s.name,
'sequence': s.sequence,
'active': s.active,
'is_default': s.is_default,
'address': s.address or '',
'phone': s.phone or '',
'notes': s.notes or '',
}
def _marcar_sucursal_default(self, sucursal):
"""Como mucho una sucursal predeterminada."""
otras = request.env['skeen.sucursal'].sudo().with_context(
active_test=False).search(
[('is_default', '=', True), ('id', '!=', sucursal.id)])
if otras:
otras.write({'is_default': False})
@http.route('/skeen/frontend/v1/sucursales', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_sucursales(self, **kw):
"""Sucursales. Por defecto solo activas; ?include_inactive=1 las trae todas."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
Sucursal = request.env['skeen.sucursal'].sudo()
if incluir:
Sucursal = Sucursal.with_context(active_test=False)
recs = Sucursal.search([], order='sequence, name')
return json_response({'status': 'success',
'sucursales': [self._sucursal_to_dict(s) for s in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sucursales', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
def create_sucursal(self, **kw):
"""Crea una sucursal. Sin `code` se genera un slug del nombre."""
try:
if not self._puede_configurar_catalogo():
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)
Sucursal = request.env['skeen.sucursal'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Sucursal.with_context(active_test=False).search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe una sucursal con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Sucursal, name)
try:
sequence = int(data.get('sequence') or 10)
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
sucursal = Sucursal.create({
'name': name,
'code': codigo,
'sequence': sequence,
'active': bool(data.get('active', True)),
'is_default': bool(data.get('is_default', False)),
'address': data.get('address') or '',
'phone': data.get('phone') or '',
'notes': data.get('notes') or '',
})
if sucursal.is_default:
self._marcar_sucursal_default(sucursal)
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'crear',
new_value=json.dumps(self._sucursal_to_dict(sucursal),
ensure_ascii=False)[:500],
entity_label='Sucursal %s' % sucursal.name,
notes=(data.get('motivo') or 'creada').strip())
return json_response({'status': 'success',
'sucursal': self._sucursal_to_dict(sucursal)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def update_sucursal(self, sucursal_id, **kw):
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id)
if not sucursal.exists():
return json_response({'status': 'error',
'message': 'Sucursal no encontrada'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != sucursal.code:
dup = request.env['skeen.sucursal'].sudo().with_context(
active_test=False).search_count(
[('code', '=', codigo), ('id', '!=', sucursal.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe una sucursal con el código %s' % codigo}, 409)
vals['code'] = codigo
for campo in ('address', 'phone', 'notes'):
if campo in data:
vals[campo] = data.get(campo) or ''
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:
vals['active'] = bool(data['active'])
if 'is_default' in data:
vals['is_default'] = bool(data['is_default'])
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
sucursal.write(vals)
if sucursal.is_default:
self._marcar_sucursal_default(sucursal)
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'editar',
new_value=json.dumps(self._sucursal_to_dict(sucursal),
ensure_ascii=False)[:500],
entity_label='Sucursal %s' % sucursal.name,
notes=(data.get('motivo') or 'actualizada').strip())
return json_response({'status': 'success',
'sucursal': self._sucursal_to_dict(sucursal)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
def delete_sucursal(self, sucursal_id, **kw):
"""Desactiva la sucursal (nunca se borra: las citas la referencian)."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
sucursal = request.env['skeen.sucursal'].sudo().browse(sucursal_id)
if not sucursal.exists():
return json_response({'status': 'error',
'message': 'Sucursal no encontrada'}, 404)
en_uso = request.env['skeen.cita'].sudo().search_count(
[('branch', '=', sucursal.code)])
sucursal.write({'active': False, 'is_default': False})
self._auditar('catalogos', 'sucursal:%s' % sucursal.id, 'desactivar',
old_value=json.dumps(self._sucursal_to_dict(sucursal),
ensure_ascii=False)[:500],
entity_label='Sucursal %s' % sucursal.name,
notes='desactivada (%d citas)' % en_uso)
if en_uso:
mensaje = ('La sucursal tiene %d cita(s) registradas y no se puede '
'eliminar; quedó desactivada.' % en_uso)
else:
mensaje = 'Sucursal desactivada.'
return json_response({'status': 'success',
'sucursal': self._sucursal_to_dict(sucursal),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Categorías de servicio (configurables)
# ============================================================
def _service_category_to_dict(self, c):
return {
'id': c.id,
'code': c.code,
'name': c.name,
'sequence': c.sequence,
'active': c.active,
'is_system': c.is_system,
}
@http.route('/skeen/frontend/v1/service-categories', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_service_categories(self, **kw):
"""Categorías de servicio. ?include_inactive=1 trae también las inactivas."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
Category = request.env['skeen.service.category'].sudo()
if incluir:
Category = Category.with_context(active_test=False)
recs = Category.search([], order='sequence, name')
return json_response({'status': 'success',
'categories': [self._service_category_to_dict(c) for c in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/service-categories', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
def create_service_category(self, **kw):
"""Crea una categoría (las creadas por API nunca son de sistema)."""
try:
if not self._puede_configurar_catalogo():
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)
Category = request.env['skeen.service.category'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Category.with_context(active_test=False).search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe una categoría con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Category, name)
try:
sequence = int(data.get('sequence') or 10)
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
categoria = Category.create({
'name': name,
'code': codigo,
'sequence': sequence,
'active': bool(data.get('active', True)),
'is_system': False,
})
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'crear',
new_value=json.dumps(self._service_category_to_dict(categoria),
ensure_ascii=False)[:500],
entity_label='Categoría %s' % categoria.name,
notes=(data.get('motivo') or 'creada').strip())
return json_response({'status': 'success',
'category': self._service_category_to_dict(categoria)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def update_service_category(self, category_id, **kw):
"""Edita una categoría. `is_system` no se puede cambiar."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
categoria = request.env['skeen.service.category'].sudo().browse(category_id)
if not categoria.exists():
return json_response({'status': 'error',
'message': 'Categoría no encontrada'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != categoria.code:
dup = request.env['skeen.service.category'].sudo().with_context(
active_test=False).search_count(
[('code', '=', codigo), ('id', '!=', categoria.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe una categoría con el código %s' % codigo}, 409)
vals['code'] = codigo
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:
vals['active'] = bool(data['active'])
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
categoria.write(vals)
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'editar',
new_value=json.dumps(self._service_category_to_dict(categoria),
ensure_ascii=False)[:500],
entity_label='Categoría %s' % categoria.name,
notes=(data.get('motivo') or 'actualizada').strip())
return json_response({'status': 'success',
'category': self._service_category_to_dict(categoria)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
def delete_service_category(self, category_id, **kw):
"""Desactiva la categoría. Las de sistema no se pueden eliminar."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
categoria = request.env['skeen.service.category'].sudo().browse(category_id)
if not categoria.exists():
return json_response({'status': 'error',
'message': 'Categoría no encontrada'}, 404)
if categoria.is_system:
return json_response({
'status': 'error',
'message': ('"%s" es una categoría del sistema: no se puede '
'eliminar, solo renombrar.' % categoria.name)}, 400)
en_uso = request.env['skeen.servicio'].sudo().search_count(
[('category', '=', categoria.code)])
categoria.write({'active': False})
self._auditar('catalogos', 'service-category:%s' % categoria.id, 'desactivar',
old_value=json.dumps(self._service_category_to_dict(categoria),
ensure_ascii=False)[:500],
entity_label='Categoría %s' % categoria.name,
notes='desactivada (%d servicios)' % en_uso)
mensaje = 'Categoría desactivada.'
if en_uso:
mensaje = ('La categoría tiene %d servicio(s) y no se puede eliminar; '
'quedó desactivada.' % en_uso)
return json_response({'status': 'success',
'category': self._service_category_to_dict(categoria),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Diseño de tickets (Configuración › Tickets)
# ============================================================