Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/public_booking.py

684 lines
29 KiB
Python

# -*- coding: utf-8 -*-
"""Portal público de reservas en línea (auto-agendado del cliente final).
Endpoints anónimos (sin token de sesión) bajo `/skeen/frontend/v1/public/booking/`.
nginx los sirve con el mismo proxy que el resto del API (`/api/odoo/...`).
Reglas de oro:
- Con `reservas_online.activo=False` TODO responde 404: nada cambia en la
instancia ni se expone información.
- No se expone ningún dato personal de pacientes: solo disponibilidad
(horarios libres) y la propia cita vía token HMAC.
- Rate limiting básico por IP y honeypot en la creación, para frenar bots.
La disponibilidad se delega por completo en `skeen.cita.get_available_slots`
(bloqueos, duración del servicio, solapes por médico, horario de la clínica y
del médico): no se reimplementan reglas de agenda.
"""
import base64
import hashlib
import hmac
import json
import logging
import re
import threading
import time
from datetime import datetime, time as dtime, timedelta
import pytz
from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request, Response
from .auth import _get_secret
from odoo.addons.skeen_citas.clinic_settings import (
get_settings as get_clinic_settings,
get_reservas_online,
brand_dict,
)
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
PREFIX = '/skeen/frontend/v1/public/booking'
# Tiempo de vida del token de gestión de una cita (suficiente para ver/cancelar).
TOKEN_TTL = 60 * 60 * 24 * 180 # 180 días
TOKEN_KIND = 'booking'
# Marcador visible/auditable del origen de la reserva.
ORIGEN_MARCA = 'Reserva en línea'
# Rate limiting en memoria por proceso. `workers=0` => un único proceso, por lo
# que un dict con lock basta y evita meter Redis/tabla para algo tan básico.
RATE_LIMITS = {
'slots': (20, 60),
'create': (5, 60),
'cita': (30, 60),
'cancel': (10, 60),
}
_RATE_LOCK = threading.Lock()
_RATE_HITS = {}
_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
)
_logger = logging.getLogger(__name__)
# Mensaje de error genérico para fallos inesperados: en un endpoint público no
# se filtran mensajes internos (nombres de modelos/campos o trazas).
_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.'
def _error_interno(exc):
_logger.exception('Error en el portal público de reservas: %s', exc)
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
def _b64url_encode(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(s):
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
def _client_ip():
"""IP del cliente. nginx reenvía X-Forwarded-For; si no, remote_addr."""
xff = request.httprequest.headers.get('X-Forwarded-For') or ''
if xff:
return xff.split(',')[0].strip()
return request.httprequest.remote_addr or 'desconocida'
def _rate_limited(bucket):
"""True si la IP ya agotó el cupo del bucket en la ventana. Registra el hit."""
limit, window = RATE_LIMITS.get(bucket, (30, 60))
key = (_client_ip(), bucket)
ahora = time.time()
with _RATE_LOCK:
hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window]
if len(hits) >= limit:
_RATE_HITS[key] = hits
return True
hits.append(ahora)
_RATE_HITS[key] = hits
# Poda ligera para que el dict no crezca sin control con muchas IPs.
if len(_RATE_HITS) > 5000:
for k in list(_RATE_HITS):
if not [t for t in _RATE_HITS[k] if ahora - t < window]:
del _RATE_HITS[k]
return False
def _parse_fecha(valor):
try:
return datetime.strptime(str(valor).strip(), '%Y-%m-%d').date()
except (TypeError, ValueError):
return None
def _parse_hora(valor):
"""'HH:MM' -> hora float. None si el formato no es válido."""
m = re.match(r'^(\d{1,2}):(\d{2})$', str(valor or '').strip())
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if not (0 <= h <= 23 and 0 <= mi <= 59):
return None
return h + mi / 60.0
def _normaliza_telefono(valor):
"""Teléfono normalizado para guardar/buscar. Conserva un '+' inicial."""
raw = str(valor or '').strip()
mas = raw.startswith('+')
digitos = re.sub(r'\D', '', raw)
return ('+' + digitos) if (mas and digitos) else digitos
def _selection_label(env, model_name, fname, value):
"""Etiqueta legible de un Selection, incluidos los dinámicos (método).
`branch` resuelve su selección con un método, así que `dict(field.selection)`
no sirve: se usa `_description_selection`, que siempre devuelve las tuplas.
"""
field = env[model_name]._fields.get(fname)
if not field:
return value
try:
opciones = field._description_selection(env)
except Exception:
opciones = field.selection if isinstance(field.selection, (list, tuple)) else []
return dict(opciones).get(value, value)
def _tz_clinica(settings):
nombre = (settings.get('zona_horaria') or 'America/Tijuana').strip()
try:
return pytz.timezone(nombre)
except Exception:
return pytz.timezone('America/Tijuana')
class SkeenPublicBookingController(http.Controller):
"""Portal público de reservas en línea."""
# ------------------------------------------------------------------
# Infra interna
# ------------------------------------------------------------------
def _activo(self):
try:
return bool(get_reservas_online(request.env).get('activo'))
except Exception:
return False
def _deshabilitado(self):
return _json({'status': 'error',
'message': 'Reservas en línea no disponibles'}, 404)
def _config(self):
return get_reservas_online(request.env)
def _settings(self):
return get_clinic_settings(request.env)
def _brand(self):
"""Brand vigente con defaults, para el portal sin sesión."""
guardado = brand_dict(request.env)
out = {
'nombre': self._settings().get('nombre') or 'Clínica',
'logo_claro': '/brand/logo-blanco.svg',
'logo_oscuro': '/brand/logo-negro.svg',
'colores': {'primario': '', 'acento': ''},
}
if isinstance(guardado, dict):
if guardado.get('nombre'):
out['nombre'] = str(guardado['nombre'])
for k in ('logo_claro', 'logo_oscuro'):
if guardado.get(k):
out[k] = str(guardado[k])
colores = guardado.get('colores')
if isinstance(colores, dict):
for c in ('primario', 'acento'):
if colores.get(c):
out['colores'][c] = str(colores[c])
return out
def _sign_token(self, cita):
payload = {'cid': cita.id, 'kind': TOKEN_KIND,
'exp': int(time.time()) + TOKEN_TTL}
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
sig = hmac.new(_get_secret(request.env).encode('utf-8'),
body.encode('ascii'), hashlib.sha256).hexdigest()
return '%s.%s' % (body, sig)
def _verify_token(self, token):
if not token or '.' not in str(token):
return None
try:
body, sig = str(token).rsplit('.', 1)
expected = hmac.new(_get_secret(request.env).encode('utf-8'),
body.encode('ascii'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
payload = json.loads(_b64url_decode(body).decode('utf-8'))
except Exception:
return None
if payload.get('kind') != TOKEN_KIND:
return None
if int(payload.get('exp', 0)) < int(time.time()):
return None
return payload
def _cita_de_token(self, token):
payload = self._verify_token(token)
if not payload:
return None
cita = request.env['skeen.cita'].sudo().browse(int(payload.get('cid') or 0))
if not cita.exists() or cita.booking_source != 'public_portal':
return None
return cita
def _encolar_confirmacion(self, cita):
"""Encola y dispara la confirmación al paciente que reservó en línea.
Solo actúa si `notificaciones.activo` y `confirmacion_al_crear` están
encendidos; el motor decide si envía o simula (modo prueba). Nunca debe
tumbar la reserva: cualquier fallo se registra y se ignora.
"""
try:
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
cfg = get_notificaciones(request.env)
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
return False
service = request.env['skeen.notificacion.service'].sudo()
creadas = service.encolar(cita, 'confirmacion')
for notif in creadas:
service._intentar(notif, cfg)
return bool(creadas)
except Exception:
_logger.exception('No se pudo encolar la confirmación de la reserva en línea')
return False
# ------------------------------------------------------------------
# 1. Configuración pública del portal
# ------------------------------------------------------------------
@http.route(PREFIX + '/config', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def booking_config(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
# 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
sucursales = env['skeen.sucursal'].sudo().search([('active', '=', True)],
order='sequence, name')
categorias = env['skeen.service.category'].sudo().search([('active', '=', True)],
order='sequence, name')
servicios = env['skeen.servicio'].sudo().search([('active', '=', True)],
order='sequence, name')
medicos = []
if cfg['permitir_eleccion_doctor']:
empleados = env['hr.employee'].sudo().search([
('active', '=', True),
('staff_type', 'in', ('doctor', 'cosmetologa')),
], order='name')
medicos = [{'id': e.id, 'nombre': e.name,
'staff_type': e.staff_type or 'doctor'} for e in empleados]
_pack, terms = resolver_glosario(env)
return _json({
'activo': True,
'brand': self._brand(),
'terms': terms,
'sucursales': [{'code': s.code, 'name': s.name,
'address': s.address or '', 'phone': s.phone or ''}
for s in sucursales],
'categorias': [{'code': c.code, 'name': c.name} for c in categorias],
'servicios': [{'id': s.id, 'nombre': s.name, 'categoria': s.category,
'precio': s.price, 'duracion_min': s.duration_min}
for s in servicios],
'medicos': medicos,
'config': {
'max_dias_futuro': cfg['max_dias_futuro'],
'min_horas_anticipacion': cfg['min_horas_anticipacion'],
'permite_sucursal': cfg['permitir_sucursal'],
'permite_eleccion_doctor': cfg['permitir_eleccion_doctor'],
'requiere_confirmacion': cfg['requiere_confirmacion'],
'permitir_cancelar': cfg['permitir_cancelar'],
'cancelar_hasta_horas': cfg['cancelar_hasta_horas'],
'telefono_contacto': cfg['telefono_contacto'],
'titulo': cfg['titulo'],
'descripcion': cfg['descripcion'],
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
},
})
# ------------------------------------------------------------------
# 2. Disponibilidad de horarios
# ------------------------------------------------------------------
@http.route(PREFIX + '/slots', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def booking_slots(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
if _rate_limited('slots'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
try:
servicio_id = int(kw.get('servicio_id') or 0)
fecha = _parse_fecha(kw.get('fecha'))
doctor_id = int(kw.get('medico_id') or 0) or None
if not servicio_id or not fecha:
return _json({'status': 'error',
'message': 'Faltan servicio_id o fecha (YYYY-MM-DD)'}, 400)
cfg = self._config()
settings = self._settings()
tz = _tz_clinica(settings)
ahora = datetime.now(tz)
servicio = request.env['skeen.servicio'].sudo().browse(servicio_id)
if not servicio.exists() or not servicio.active:
return _json({'status': 'error', 'message': 'Servicio no disponible'}, 404)
if fecha < ahora.date():
return _json({'status': 'error', 'message': 'La fecha ya pasó'}, 400)
if fecha > ahora.date() + timedelta(days=cfg['max_dias_futuro']):
return _json({'status': 'error',
'message': 'La fecha está fuera del rango permitido'}, 400)
if doctor_id:
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
if not doctor.exists() or not doctor.active or \
doctor.staff_type not in ('doctor', 'cosmetologa'):
return _json({'status': 'error', 'message': 'Especialista no disponible'}, 404)
slots = request.env['skeen.cita'].sudo().get_available_slots(
fecha, servicio_id, doctor_id=doctor_id)
# Respeta el mínimo de anticipación (en la zona horaria de la clínica).
minimo = ahora + timedelta(hours=cfg['min_horas_anticipacion'])
disponibles = []
for s in slots:
hh = int(s['time'])
mm = int(round((s['time'] - hh) * 60))
inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm)))
if inicio >= minimo:
disponibles.append(s['time_str'])
return _json({
'status': 'success',
'slots': disponibles,
'medico_id': doctor_id,
'duracion_min': servicio.duration_min or 30,
})
except Exception as e:
return _error_interno(e)
# ------------------------------------------------------------------
# 3. Crear la reserva
# ------------------------------------------------------------------
@http.route(PREFIX + '/create', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
def booking_create(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
if _rate_limited('create'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
try:
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
data = {}
# Honeypot: campo oculto que solo un bot rellena.
if str(data.get('hp') or '').strip():
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
try:
cfg = self._config()
settings = self._settings()
tz = _tz_clinica(settings)
ahora = datetime.now(tz)
nombre = str(data.get('nombre') or '').strip()
if not (2 <= len(nombre) <= 120):
return _json({'status': 'error',
'message': 'Escribe tu nombre (2 a 120 caracteres)'}, 400)
telefono = _normaliza_telefono(data.get('telefono'))
digitos = re.sub(r'\D', '', telefono)
if not (10 <= len(digitos) <= 15):
return _json({'status': 'error',
'message': 'Escribe un teléfono válido (10 dígitos)'}, 400)
email = str(data.get('email') or '').strip()
if email and not _EMAIL_RE.match(email):
return _json({'status': 'error', 'message': 'El email no es válido'}, 400)
notas = str(data.get('notas') or '').strip()[:500]
servicio_id = int(data.get('servicio_id') or 0)
fecha = _parse_fecha(data.get('fecha'))
hora = _parse_hora(data.get('hora'))
doctor_id = int(data.get('medico_id') or 0) or None
if not servicio_id or not fecha or hora is None:
return _json({'status': 'error',
'message': 'Faltan servicio, fecha u hora'}, 400)
servicio = request.env['skeen.servicio'].sudo().browse(servicio_id)
if not servicio.exists() or not servicio.active:
return _json({'status': 'error', 'message': 'Servicio no disponible'}, 404)
if fecha < ahora.date():
return _json({'status': 'error', 'message': 'La fecha ya pasó'}, 400)
if fecha > ahora.date() + timedelta(days=cfg['max_dias_futuro']):
return _json({'status': 'error',
'message': 'La fecha está fuera del rango permitido'}, 400)
hh = int(hora)
mm = int(round((hora - hh) * 60))
inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm)))
if inicio < ahora + timedelta(hours=cfg['min_horas_anticipacion']):
return _json({'status': 'error',
'message': 'La hora elegida ya no está disponible'}, 409)
if doctor_id:
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
if not doctor.exists() or not doctor.active or \
doctor.staff_type not in ('doctor', 'cosmetologa'):
return _json({'status': 'error',
'message': 'Especialista no disponible'}, 404)
# El horario debe ser uno de los disponibles según la agenda real.
slots = request.env['skeen.cita'].sudo().get_available_slots(
fecha, servicio_id, doctor_id=doctor_id)
if not any(abs(hora - s['time']) < 1e-6 for s in slots):
return _json({'status': 'error',
'message': 'Ese horario ya no está disponible'}, 409)
# Sucursal: la elegida (si aplica) o la predeterminada de la clínica.
Sucursal = request.env['skeen.sucursal'].sudo()
branch = None
if cfg['permitir_sucursal'] and data.get('sucursal'):
code = str(data.get('sucursal')).strip()
suc = Sucursal.search([('code', '=', code), ('active', '=', True)], limit=1)
if not suc:
return _json({'status': 'error', 'message': 'Sucursal no disponible'}, 404)
branch = suc.code
if not branch:
branch = Sucursal.default_code()
# Paciente: se reutiliza por teléfono; si no existe se crea mínimo.
Partner = request.env['res.partner'].sudo()
variantes = {telefono, digitos, '52' + digitos, '+52' + digitos}
partner = Partner.search([
('is_patient', '=', True),
'|', ('phone', 'in', list(variantes)), ('mobile', 'in', list(variantes)),
], limit=1)
if not partner:
partner = Partner.search([
('is_patient', '=', True),
'|', ('phone', 'ilike', digitos), ('mobile', 'ilike', digitos),
], limit=1)
creado_paciente = False
if not partner:
partner_vals = {'name': nombre, 'phone': telefono,
'is_patient': True, 'source': 'other'}
if email:
partner_vals['email'] = email
try:
tag = request.env['skeen.patient.tag'].sudo().search(
[('name', '=ilike', ORIGEN_MARCA)], limit=1)
if not tag:
tag = request.env['skeen.patient.tag'].sudo().search(
[('name', 'ilike', 'reserva')], limit=1)
if tag:
partner_vals['tag_ids'] = [(6, 0, [tag.id])]
except Exception:
pass
partner = Partner.create(partner_vals)
creado_paciente = True
state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed'
nota = ORIGEN_MARCA
if notas:
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
cita_vals = {
'partner_id': partner.id,
'servicio_id': servicio_id,
'date': fecha.strftime('%Y-%m-%d'),
'time': hora,
'branch': branch,
'medium': 'onsite',
'state': state,
'notes': nota,
'booking_source': 'public_portal',
}
if doctor_id:
cita_vals['doctor_id'] = doctor_id
try:
cita = request.env['skeen.cita'].sudo().create(cita_vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 409)
# Auditoría: quién/cuándo/cómo entró la reserva (nunca tumba la op.).
try:
cita.sudo().message_post(
subject='Reserva en línea',
body=('Reserva creada desde el portal público.<br/>'
'IP: %s<br/>Teléfono: %s' % (_client_ip(), telefono)),
message_type='comment')
cita.sudo()._log_state('', state, user='Portal en línea',
note='Reserva en línea (portal)')
except Exception:
pass
# Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM.
if cfg['requiere_confirmacion']:
self._encolar_confirmacion(cita)
return _json({
'ok': True,
'folio': cita.name,
'fecha': cita.date.strftime('%Y-%m-%d'),
'hora': cita.time_str,
'medico': cita.doctor_id.name if cita.doctor_id else None,
'servicio': cita.servicio_id.name,
'sucursal': _selection_label(request.env, 'skeen.cita', 'branch', cita.branch),
'estado': cita.state,
'requiere_confirmacion': cfg['requiere_confirmacion'],
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
'token_de_gestion': self._sign_token(cita),
}, 201)
except Exception as e:
request.env.cr.rollback()
return _error_interno(e)
# ------------------------------------------------------------------
# 4. Consultar una cita por token
# ------------------------------------------------------------------
@http.route(PREFIX + '/cita', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def booking_cita(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
if _rate_limited('cita'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
try:
cita = self._cita_de_token(kw.get('token'))
if not cita:
return _json({'status': 'error',
'message': 'Enlace no válido o expirado'}, 404)
cfg = self._config()
settings = self._settings()
tz = _tz_clinica(settings)
ahora = datetime.now(tz)
inicio = tz.localize(datetime.combine(
cita.date, dtime(int(cita.time), int(round((cita.time % 1) * 60)))))
puede_cancelar = bool(
cfg['permitir_cancelar']
and cita.state in ('pending', 'confirmed')
and inicio - ahora > timedelta(hours=cfg['cancelar_hasta_horas']))
return _json({
'status': 'success',
'folio': cita.name,
'fecha': cita.date.strftime('%Y-%m-%d'),
'hora': cita.time_str,
'servicio': cita.servicio_id.name,
'medico': cita.doctor_id.name if cita.doctor_id else None,
'sucursal': _selection_label(request.env, 'skeen.cita', 'branch', cita.branch),
'estado': cita.state,
'estado_label': _selection_label(request.env, 'skeen.cita', 'state', cita.state),
'puede_cancelar': puede_cancelar,
'permitir_cancelar': cfg['permitir_cancelar'],
'cancelar_hasta_horas': cfg['cancelar_hasta_horas'],
'telefono_contacto': cfg['telefono_contacto'],
})
except Exception as e:
return _error_interno(e)
# ------------------------------------------------------------------
# 5. Cancelar una cita por token
# ------------------------------------------------------------------
@http.route(PREFIX + '/cancel', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
def booking_cancel(self, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
if _rate_limited('cancel'):
return _json({'status': 'error',
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
try:
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
data = {}
try:
cita = self._cita_de_token(data.get('token'))
if not cita:
return _json({'status': 'error',
'message': 'Enlace no válido o expirado'}, 404)
cfg = self._config()
if not cfg['permitir_cancelar']:
return _json({'status': 'error',
'message': 'La cancelación en línea no está disponible. '
'Contacta a la clínica.'}, 403)
if cita.state not in ('pending', 'confirmed'):
return _json({'status': 'error',
'message': 'Esta cita ya no se puede cancelar en línea.'}, 409)
settings = self._settings()
tz = _tz_clinica(settings)
ahora = datetime.now(tz)
inicio = tz.localize(datetime.combine(
cita.date, dtime(int(cita.time), int(round((cita.time % 1) * 60)))))
if inicio - ahora <= timedelta(hours=cfg['cancelar_hasta_horas']):
return _json({'status': 'error',
'message': 'Ya no es posible cancelar en línea: faltan menos de '
'%d horas. Contacta a la clínica.' % cfg['cancelar_hasta_horas']},
409)
motivo = str(data.get('motivo') or '').strip()[:300]
cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'})
cita.set_state('cancelled', user='Portal en línea',
note=motivo or 'Cancelada en el portal público')
try:
cita.sudo().message_post(
subject='Cancelación en línea',
body='Cancelada desde el portal público. IP: %s' % _client_ip(),
message_type='comment')
except Exception:
pass
return _json({'status': 'success', 'ok': True,
'folio': cita.name, 'estado': 'cancelled'})
except Exception as e:
request.env.cr.rollback()
return _error_interno(e)