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:
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user