fase 3.1: recursos fisicos en agenda (modelo skeen.recurso con semilla cabina/consultoria, asignacion multiple a citas con validacion de solape, bloqueos por recurso, lista de espera desde recursos, vista/filtro por recurso, reporte de ocupacion); apagado por defecto

This commit is contained in:
2026-10-11 11:33:59 +00:00
parent 8ff13b9c2a
commit ee8a844d96
29 changed files with 1986 additions and 78 deletions

View File

@@ -20,6 +20,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
get_impuestos,
get_recursos,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -930,6 +931,19 @@ class SkeenFrontendController(http.Controller):
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
}
# Recursos físicos (cabina, sala, equipo). Solo se atienden cuando la
# clínica tiene el módulo activo; con recursos.activo=False se ignora.
cfg_recursos = get_recursos(request.env)
recurso_ids = self._recursos_payload(data) if cfg_recursos['activo'] else []
if cfg_recursos['activo']:
if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1:
return json_response({'status': 'error',
'message': 'Solo se permite un recurso por cita'}, 400)
if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids:
return json_response({
'status': 'error',
'message': 'Selecciona al menos un recurso para agendar la cita'}, 400)
base_vals['recurso_ids'] = [(6, 0, recurso_ids)]
first_date = data.get('date')
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
@@ -1047,6 +1061,7 @@ class SkeenFrontendController(http.Controller):
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
**self._recurso_vals_cita(data),
})
return json_response({
'status': 'success',
@@ -1092,6 +1107,17 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state', 'duration']
vals = {k: data[k] for k in allowed if k in data}
cfg_recursos = get_recursos(request.env)
if cfg_recursos['activo'] and 'recurso_ids' in data:
recurso_ids = self._recursos_payload(data)
if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1:
return json_response({'status': 'error',
'message': 'Solo se permite un recurso por cita'}, 400)
if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids:
return json_response({
'status': 'error',
'message': 'Selecciona al menos un recurso para agendar la cita'}, 400)
vals['recurso_ids'] = [(6, 0, recurso_ids)]
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 (%s)' % self._agenda_horario_txt()}, 400)
@@ -1420,8 +1446,11 @@ class SkeenFrontendController(http.Controller):
def _bloqueo_to_dict(self, b):
return {
'id': b.id,
'doctor_id': b.doctor_id.id,
'doctor': b.doctor_id.name,
'doctor_id': b.doctor_id.id if b.doctor_id else None,
'doctor': b.doctor_id.name if b.doctor_id else None,
'recurso_ids': b.recurso_ids.ids,
'recursos': [{'id': r.id, 'name': r.name, 'code': r.code,
'color': r.color or ''} for r in b.recurso_ids],
'date': b.date.strftime('%Y-%m-%d') if b.date else None,
'all_day': b.all_day,
'time_from': b.time_from,
@@ -1485,11 +1514,15 @@ class SkeenFrontendController(http.Controller):
except (TypeError, ValueError):
continue
doctor_ids = list(dict.fromkeys(doctor_ids))
if not doctor_ids:
return json_response({'status': 'error', 'message': 'Selecciona al menos un médico'}, 400)
doctores = request.env['hr.employee'].sudo().browse(doctor_ids).exists()
if len(doctores) != len(doctor_ids):
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
# Recursos (opcional): bloquear una cabina/sala/equipo sin médico.
recurso_ids = self._recursos_payload(data) if get_recursos(request.env)['activo'] else []
recursos = request.env['skeen.recurso'].sudo().browse(recurso_ids).exists()
if not doctores and not recursos:
return json_response(
{'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400)
date_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from
@@ -1542,12 +1575,7 @@ class SkeenFrontendController(http.Controller):
grupo = str(uuid.uuid4())[:8]
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
creados = Bloqueo.browse()
creados = creados.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).create([{
'doctor_id': doc.id,
'date': f.strftime('%Y-%m-%d'),
base = {
'all_day': all_day,
'time_from': time_from,
'time_to': time_to,
@@ -1555,10 +1583,25 @@ class SkeenFrontendController(http.Controller):
'motivo': motivo,
'grupo': grupo,
'created_by': request.frontend_user.login,
} for doc in doctores for f in fechas])
}
filas = []
# Un bloqueo por médico (sin recurso) y uno por recurso (sin médico):
# seleccionar médicos y recursos bloquea la unión de ambos.
for doc in doctores:
for f in fechas:
filas.append({**base, 'date': f.strftime('%Y-%m-%d'), 'doctor_id': doc.id})
for rec in recursos:
for f in fechas:
filas.append({**base, 'date': f.strftime('%Y-%m-%d'),
'recurso_ids': [(6, 0, [rec.id])]})
creados = Bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).create(filas)
etiqueta = ', '.join(doctores.mapped('name') + recursos.mapped('name'))
self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name')))
entity_label=etiqueta)
return json_response({
'status': 'success',
'grupo': grupo,
@@ -1585,8 +1628,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'doctor_id' in data and data['doctor_id']:
vals['doctor_id'] = int(data['doctor_id'])
if 'doctor_id' in data:
vals['doctor_id'] = int(data['doctor_id']) if data.get('doctor_id') else False
if data.get('date'):
vals['date'] = data['date']
if 'all_day' in data:
@@ -1599,13 +1642,21 @@ class SkeenFrontendController(http.Controller):
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data:
vals['motivo'] = data['motivo'] or ''
if 'recurso_ids' in data and get_recursos(request.env)['activo']:
vals['recurso_ids'] = [(6, 0, self._recursos_payload(data))]
if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400)
if not vals.get('doctor_id') and 'recurso_ids' not in vals and not bloqueo.recurso_ids:
return json_response(
{'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400)
bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).write(vals)
etiqueta = bloqueo.doctor_id.name if bloqueo.doctor_id else \
', '.join(bloqueo.recurso_ids.mapped('name'))
self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name,
entity_label=etiqueta,
new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e:
@@ -1623,7 +1674,10 @@ class SkeenFrontendController(http.Controller):
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
etiqueta = '%s · %s' % (bloqueo.doctor_id.name, bloqueo.date)
etiqueta = '%s · %s' % (
bloqueo.doctor_id.name if bloqueo.doctor_id
else ', '.join(bloqueo.recurso_ids.mapped('name')) or 'bloqueo',
bloqueo.date)
bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
@@ -1651,6 +1705,14 @@ class SkeenFrontendController(http.Controller):
# ============================================================
# Lista de espera de pacientes
# ============================================================
def _sala_codigo(self, data):
"""Código de sala válido (de los recursos) o False si viene vacío/ajeno."""
value = data.get('sala')
if not value:
return False
validos = [c for c, _l in request.env['skeen.lista.espera']._get_sala_selection()]
return value if value in validos else False
def _lista_espera_to_dict(self, r):
return {
'id': r.id,
@@ -1666,7 +1728,7 @@ class SkeenFrontendController(http.Controller):
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'sala': r.sala or '',
'sala_label': dict(r._fields['sala'].selection).get(r.sala, '') if r.sala else '',
'sala_label': r.sala_label(r.sala) if r.sala else '',
'date_from': r.date_from.strftime('%Y-%m-%d') if r.date_from else None,
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '',
@@ -1751,7 +1813,7 @@ class SkeenFrontendController(http.Controller):
'servicio_id': int(data['service_id'] or data['servicio_id']) if (data.get('service_id') or data.get('servicio_id')) else False,
'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '',
'sala': data.get('sala') if data.get('sala') in ('consultoria', 'cabina') else False,
'sala': self._sala_codigo(data),
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
@@ -1793,9 +1855,9 @@ class SkeenFrontendController(http.Controller):
vals[field] = data[key] or False
if 'sala' in data:
# Toggle de sala desde la etiqueta: '' vuelve a "Sin asignar"
vals['sala'] = data['sala'] if data['sala'] in ('consultoria', 'cabina') else False
vals['sala'] = self._sala_codigo(data)
if vals['sala']:
record._log('Sala: %s' % dict(record._fields['sala'].selection).get(vals['sala'], vals['sala']),
record._log('Sala: %s' % record.sala_label(vals['sala']),
user=request.frontend_user.login)
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
@@ -1857,6 +1919,8 @@ class SkeenFrontendController(http.Controller):
'service_duration': cita.servicio_duration,
'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
'doctor': cita.doctor_id.name if cita.doctor_id else None,
'recurso_ids': cita.recurso_ids.ids,
'recursos': [self._recurso_to_dict(r) for r in cita.recurso_ids],
'branch': cita.branch,
'medium': cita.medium,
'booking_source': cita.booking_source or 'internal',
@@ -5854,7 +5918,7 @@ class SkeenFrontendController(http.Controller):
])
bloqueo_horas = {}
for b in bloqueos:
if b.date.weekday() == 6:
if not b.doctor_id or b.date.weekday() == 6:
continue
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from)
bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas
@@ -7113,6 +7177,301 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Recursos de la agenda (configurables: cabinas, salas, equipos)
# ============================================================
def _recursos_payload(self, data):
"""IDs de recurso de un body (lista `recurso_ids` o CSV `recurso_ids`).
Descarta valores no numéricos o inexistentes para no romper el guardado.
"""
raw = data.get('recurso_ids')
if raw is None:
raw = data.get('recurso_ids_csv')
ids = []
if isinstance(raw, (list, tuple)):
for v in raw:
try:
ids.append(int(v))
except (TypeError, ValueError):
continue
elif raw not in (None, ''):
for v in str(raw).split(','):
v = v.strip()
if v.isdigit():
ids.append(int(v))
ids = list(dict.fromkeys(ids))
if not ids:
return []
existentes = request.env['skeen.recurso'].sudo().search([('id', 'in', ids)]).ids
return [i for i in ids if i in existentes]
def _recurso_vals_cita(self, data):
"""`{recurso_ids: [(6,0,ids)]}` si el módulo está activo y hay recursos."""
cfg = get_recursos(request.env)
if not cfg['activo']:
return {}
ids = self._recursos_payload(data)
if not cfg['permitir_multiples']:
ids = ids[:1]
return {'recurso_ids': [(6, 0, ids)]} if ids else {}
def _recurso_to_dict(self, r):
return {
'id': r.id,
'code': r.code,
'name': r.name,
'tipo': r.tipo,
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''),
'activo': r.activo,
'sequence': r.sequence,
'sucursales': [{'id': s.id, 'code': s.code, 'name': s.name} for s in r.sucursales],
'capacidad': r.capacidad or 0,
'costo_hora': r.costo_hora or 0.0,
'color': r.color or '',
'notas': r.notas or '',
}
@http.route('/skeen/frontend/v1/recursos', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('recursos.ver')
def list_recursos(self, **kw):
"""Recursos. Por defecto solo activos; ?include_inactive=1 trae todos."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
domain = [] if incluir else [('activo', '=', True)]
recs = request.env['skeen.recurso'].sudo().search(domain, order='sequence, name')
return json_response({'status': 'success',
'recursos': [self._recurso_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/recursos', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def create_recurso(self, **kw):
"""Crea un recurso. 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)
Recurso = request.env['skeen.recurso'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Recurso.search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe un recurso con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Recurso, name)
vals = self._recurso_vals(data)
vals.update({'name': name, 'code': codigo})
recurso = Recurso.create(vals)
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'crear',
new_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes=(data.get('motivo') or 'creado').strip())
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _recurso_vals(self, data):
"""Campos comunes de alta/edición de un recurso (sin name/code)."""
vals = {}
if 'tipo' in data:
tipos = dict(request.env['skeen.recurso']._fields['tipo'].selection)
vals['tipo'] = data['tipo'] if data.get('tipo') in tipos else 'otro'
for campo in ('notas', 'color'):
if campo in data:
vals[campo] = str(data.get(campo) or '').strip()
if 'sequence' in data:
try:
vals['sequence'] = int(data['sequence'])
except (TypeError, ValueError):
raise ValueError('La secuencia debe ser numérica')
if 'capacidad' in data:
try:
vals['capacidad'] = max(0, int(data['capacidad'] or 0))
except (TypeError, ValueError):
raise ValueError('La capacidad debe ser un número entero')
if 'costo_hora' in data:
try:
vals['costo_hora'] = max(0.0, float(data['costo_hora'] or 0))
except (TypeError, ValueError):
raise ValueError('El costo por hora debe ser un número')
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'sucursal_ids' in data:
ids = data.get('sucursal_ids') or []
if not isinstance(ids, list):
raise ValueError('sucursal_ids debe ser una lista')
vals['sucursales'] = [(6, 0, [int(i) for i in ids if str(i).isdigit()])]
return vals
@http.route('/skeen/frontend/v1/recursos/<int:recurso_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def update_recurso(self, recurso_id, **kw):
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
recurso = request.env['skeen.recurso'].sudo().browse(recurso_id)
if not recurso.exists():
return json_response({'status': 'error',
'message': 'Recurso 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 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != recurso.code:
dup = request.env['skeen.recurso'].sudo().search_count(
[('code', '=', codigo), ('id', '!=', recurso.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe un recurso con el código %s' % codigo}, 409)
vals['code'] = codigo
try:
vals.update(self._recurso_vals(data))
except ValueError as e:
return json_response({'status': 'error', 'message': str(e)}, 400)
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
recurso.write(vals)
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'editar',
new_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recursos/<int:recurso_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def delete_recurso(self, recurso_id, **kw):
"""Desactiva el recurso (nunca se borra: las citas lo referencian)."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
recurso = request.env['skeen.recurso'].sudo().browse(recurso_id)
if not recurso.exists():
return json_response({'status': 'error',
'message': 'Recurso no encontrado'}, 404)
en_uso = request.env['skeen.cita'].sudo().search_count(
[('recurso_ids', 'in', recurso.id)])
recurso.write({'activo': False})
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'desactivar',
old_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes='desactivado (%d citas)' % en_uso)
mensaje = 'Recurso desactivado.'
if en_uso:
mensaje = ('El recurso tiene %d cita(s) registradas y no se puede '
'eliminar; quedó desactivado.' % en_uso)
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/recursos', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_recursos(self, **kw):
"""Ocupación por recurso: citas, horas ocupadas, % y costo estimado.
Horas disponibles = días del rango × jornada de la clínica. El % de
ocupación es horas ocupadas / disponibles. `ingresos_estimados` usa el
`costo_hora` del recurso cuando está definido (0 si no).
"""
try:
start = kw.get('start', date.today().strftime('%Y-%m-%d'))
end = kw.get('end', start)
try:
d_ini = datetime.strptime(str(start)[:10], '%Y-%m-%d').date()
d_fin = datetime.strptime(str(end)[:10], '%Y-%m-%d').date()
except ValueError:
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if d_fin < d_ini:
d_ini, d_fin = d_fin, d_ini
dias = (d_fin - d_ini).days + 1
ini_h, fin_h = self._agenda_bounds()
horas_dia = max(0.0, fin_h - ini_h)
disponibles = horas_dia * dias
citas = request.env['skeen.cita'].sudo().search([
('date', '>=', d_ini.strftime('%Y-%m-%d')),
('date', '<=', d_fin.strftime('%Y-%m-%d')),
('state', 'not in', INACTIVE_STATES),
])
acumulado = {}
for c in citas:
for r in c.recurso_ids:
acc = acumulado.setdefault(r.id, {'citas': 0, 'minutos': 0})
acc['citas'] += 1
acc['minutos'] += c._duracion()
recursos = request.env['skeen.recurso'].sudo().search(
[('activo', '=', True)], order='sequence, name')
filas = []
for r in recursos:
acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0})
horas = round(acc['minutos'] / 60.0, 2)
ocupacion = round((horas / disponibles * 100.0), 1) if disponibles else 0.0
filas.append({
'recurso_id': r.id,
'recurso': r.name,
'code': r.code,
'tipo': r.tipo,
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''),
'color': r.color or '',
'citas': acc['citas'],
'horas_ocupadas': horas,
'horas_disponibles': round(disponibles, 2),
'ocupacion_pct': min(ocupacion, 100.0),
'costo_hora': r.costo_hora or 0.0,
'ingresos_estimados': round(horas * (r.costo_hora or 0.0), 2),
})
total_horas = round(sum(f['horas_ocupadas'] for f in filas), 2)
total_citas = sum(f['citas'] for f in filas)
total_ingresos = round(sum(f['ingresos_estimados'] for f in filas), 2)
return json_response({
'status': 'success',
'start': d_ini.strftime('%Y-%m-%d'),
'end': d_fin.strftime('%Y-%m-%d'),
'dias': dias,
'horas_disponibles': round(disponibles, 2),
'recursos': filas,
'totales': {'citas': total_citas, 'horas_ocupadas': total_horas,
'ingresos_estimados': total_ingresos},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Sucursales (configurables)
# ============================================================

View File

@@ -283,7 +283,9 @@ class SkeenPublicBookingController(http.Controller):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
# 200 con activo=false (en vez de 404): el frontend lo trata como
# "no configurado" y no deja un 404 en la consola del login.
return _json({'status': 'success', 'activo': False})
cfg = self._config()
settings = self._settings()
env = request.env