671 lines
28 KiB
Python
671 lines
28 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):
|
|
"""Hook Fase 2.4: avisar al cliente que la clínica confirmará su cita.
|
|
|
|
A propósito es un no-op: la Fase 2.4 enviará el mensaje de confirmación
|
|
por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del
|
|
paciente) cuando esas credenciales estén configuradas. Se deja el punto
|
|
de extensión listo, sin acoplar el portal público a WhatsApp todavía.
|
|
"""
|
|
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():
|
|
return self._deshabilitado()
|
|
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)
|