commit 38e9e4b91c4b8debc8191fc581b5d0edf2f5df16 Author: consultoria-as Date: Tue Jun 9 21:00:50 2026 -0700 Catalogo de Servicios (builder): codigo + documentacion extensiva Builder multi-proveedor de servicios (tour / A&B / transportacion). Python stdlib + SQLite + vanilla JS SPA. Hereda filosofia del Hub. Secretos y datos (catalogo.db, secret.key, uploads/) excluidos via .gitignore. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cc89f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# ── Secretos y datos (NUNCA subir) ── +# clave de firma de sesiones (login) +secret.key +# base de datos del catálogo +catalogo.db +catalogo.db-wal +catalogo.db-shm +# respaldos +*.bak_* + +# ── Archivos generados / temporales ── +uploads/ +backups/ +__pycache__/ +*.pyc +*.log + +# ── Sistema ── +.DS_Store +Thumbs.db diff --git a/API.md b/API.md new file mode 100644 index 0000000..78375aa --- /dev/null +++ b/API.md @@ -0,0 +1,66 @@ +# API REST — Catálogo de Servicios + +Servidor: `server.py` (Python stdlib `http.server`), puerto **4403**. Respuestas JSON. +Autenticación por **cookie de sesión** firmada (HMAC). Sin sesión: `/api/*` y `/uploads/*` → bloqueados; las páginas muestran login. + +--- + +## Autenticación + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/needs-setup` | ¿Primera vez? (pública) | +| POST | `/api/setup` | Crea la cuenta admin inicial. Body `{username, password, nombre}` | +| POST | `/api/login` | Inicia sesión. Body `{username, password}` → cookie | +| POST | `/api/logout` | Cierra sesión | + +Contraseñas: **PBKDF2-SHA256** + salt. Sesión firmada con HMAC (clave en `secret.key`). + +--- + +## CRUD genérico + +Un handler sirve todas las tablas vía el dict `TABLES` en `server.py`: + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/{tabla}` | Lista registros | +| POST | `/api/{tabla}` | Crea (body = campos) | +| PUT | `/api/{tabla}/{id}` | Actualiza | +| DELETE | `/api/{tabla}/{id}` | Elimina | + +Tablas: `proveedores`, `servicios`. +El dict define `fields`, `int_fields`, `float_fields`, `nullable_fields` por tabla (casteo automático). + +--- + +## Endpoints especiales + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/servicios` | Servicios con `proveedor_nombre` (join) | +| GET | `/api/proveedores` | Proveedores con `servicios_count` | +| GET | `/api/dashboard` | KPIs (conteos, totales) | +| GET | `/api/file-counts` | `{entidad: {count, first_image}}`. `first_image` prefiere `foto_`, excluye docs y `menu` | +| GET | `/api/files/{entidad}` | Lista archivos de un servicio | +| POST | `/api/upload/{entidad}?tipo=foto&label=...` | Sube archivo(s). `tipo`=`foto`/`menu`/`doc` define el prefijo | +| DELETE | `/api/files/{entidad}/{nombre}` | Elimina un archivo | + +`{entidad}` = `servicio_{id}`. + +--- + +## Servir contenido + +| Ruta | Sirve | +|---|---| +| `/` o `/index.html` | La SPA (builder + vista cliente) | +| `/uploads/servicio_{id}/{archivo}` | Archivos del servicio (requiere sesión) | + +--- + +## Notas + +- Sin dependencias externas: `http.server`, `sqlite3`, `json`, `urllib`, `hashlib`, `hmac` (stdlib). +- CORS y preflight `OPTIONS` habilitados. +- Para agregar una tabla: `CREATE TABLE` en `init_db()` + entrada en `TABLES` → CRUD automático. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eddba2a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# Catálogo (borrador) — Contexto para Claude + +> Builder de catálogo multi-proveedor de **servicios** (no productos físicos). +> Hereda la filosofía del Art4Hotel Hub. Proyecto independiente, no toca el Hub. + +## Qué es +Sistema interno para armar la base de datos de un catálogo de servicios de varios +**touroperadores / proveedores**. Tres tipos de servicio: +- **tour** · **ayb** (A&B / banquetes) · **transportacion** + +Objetivo final (fases siguientes): usar esta DB para **propuestas** y una **página web** +de catálogo online optimizado. Por ahora el foco es el **builder** (captura de datos + fotos). + +## ⚠️ Principio clave (del Hub): el servicio = fuente única de verdad +Cada atributo de un servicio impacta hasta 3 funciones. Antes de agregar/cambiar uno, define su impacto en: +1. **Operación** — disponibilidad, horarios, capacidad, precio neto +2. **Propuesta / cotizador** — lo que ve el cliente, precio público, términos +3. **Página web** — catálogo online + +**Neto vs público**: `precio_neto` = lo que cobra el proveedor · `precio_publico` = lo que paga el cliente. +El margen vive en medio y nunca se mezcla con lo que ve el cliente. + +## Stack +- Backend: Python 3 stdlib (`http.server` + `sqlite3`), **zero deps**. WAL + FK on. +- Frontend: SPA vanilla JS (`index.html`), sin frameworks. +- CRUD genérico vía dict `TABLES` en `server.py` (un solo handler POST/PUT/DELETE para todas las tablas). +- Auth: usuario/contraseña (PBKDF2) + cookie de sesión firmada (HMAC). `needs_setup` en primer acceso. + +## Acceso al server +- Host: `claude@100.110.177.1` (Tailscale `iclaude`) / red local `192.168.50.46` +- Path: `/mnt/iclaude/catalogo-borrador/` +- Puerto: **4403** (4401 = Hub, 4402 = airbnb-pricing) +- URL: `http://iclaude:4403` / `http://192.168.50.46:4403` +- Servicio: `sudo systemctl restart catalogo-borrador` +- Deploy: `scp index.html server.py claude@100.110.177.1:/mnt/iclaude/catalogo-borrador/ && ssh claude@100.110.177.1 "sudo systemctl restart catalogo-borrador"` +- Logs: `sudo journalctl -u catalogo-borrador -n 50 --no-pager` + +## Modelo de datos (SQLite) +- `proveedores` — quién ofrece (nombre, tipo_principal, contacto, comision_default, …) +- `servicios` — **fuente única de verdad** (FK proveedor_id). Campos por sección de impacto: + - 📋 Identidad: nombre, tipo, categoria, codigo, descripcion + - ⚙️ Operación: horarios, capacidad_min/max, restricciones + `atributos` (JSON específico por tipo) + - 💰 Comercial: precio_neto, precio_publico, moneda, unidad, tarifas_adicionales + - 📄 Condiciones: terminos + - 🌐 Publicación: mostrar_en_web +- `usuarios` — auth + +### Atributos flexibles por tipo (`servicios.atributos`, JSON) +En vez de columnas rígidas, los extras específicos van en JSON. Sugeridos por tipo (en `index.html` → `ATRIBUTOS`): +- tour: duracion, punto_encuentro, incluye, no_incluye, idioma +- ayb: tipo_menu, min_personas, montaje, servicio_incluido, opciones_dieta +- transportacion: tipo_vehiculo, pasajeros, ruta_zona, chofer, tiempo_espera + +## Disponibilidad — modos (`servicios.modo_disponibilidad` + `horarios` JSON) +Eje independiente del `tipo`. **`modo_disponibilidad`**: `salidas` | `rango` | `siempre` (24/7) | `por_evento`. +Default sugerido por tipo (`MODO_DEFAULT`: tour→salidas, transportacion→siempre, ayb→por_evento), editable. El editor (`renderDisp`) cambia los controles según el modo: +- **salidas**: editor semanal de días + varias salidas por día → `horarios`=`{"Lun":["08:00","14:00"],...}`. (`+ salida` agrega varias; `parseHor`/`fmtHorGroups`/`compactDias` para mostrar.) +- **rango**: días + ventana → `horarios`=`{"dias":[...],"desde":"06:00","hasta":"22:00"}`. +- **siempre** / **por_evento**: `horarios`={} (sin horario; la reserva es por fecha). +`dispShort(s)` arma el texto de disponibilidad según el modo (usado en vista cliente). +Compat: servicios viejos sin `modo` = `salidas` con su `horarios` día-keyed. + +## Alimentos / Menú (opcional, flexible) +`servicios.incluye_alimentos` (0/1) + `menu_detalle` (texto). Fotos del menú: se suben con `tipo=menu` (prefijo `menu_`) al mismo `uploads/servicio_{id}/`; se separan de las fotos del servicio por prefijo (`loadPhotos` excluye `menu_`, `loadMenuFotos` solo `menu_`). En `file-counts` el prefijo `menu` está excluido del `first_image` (no roba el thumbnail). En vista cliente se muestra el bloque "🍽️ Menú / Alimentos" (detalle + galería) solo si aplica. Pensado para tours que incluyen comida como plus; opcional porque depende del operador. + +## Campos "reserva-lista" (`servicios`) +`ubicacion` (lugar/punto de encuentro/dirección), `mapa_url` (link Google Maps), `checkin` (anti no-show, ej. "Llegar 15 min antes"), `anticipacion` (lead time, ej. "48 h"). Se ven en la **vista cliente** (bloques Disponibilidad / Dónde / Check-in). Cada modo + estos campos definen qué pedirá el futuro formulario de reserva (lead casi listo). + +## Vistas (toggle en tab Servicios) +- **✎ Builder** — grid de edición (muestra precio público + margen). +- **👁 Vista cliente** — catálogo limpio agrupado por tipo, como lo vería un cliente. `openCliente()` abre + detalle estilo público. **Oculta**: precio_neto, margen, comisión, notas internas, proveedor, código. + +## Diseño / UI +Formal y minimalista: tipografía **Inter**, paleta blanco/negro/gris (`--ink`,`--muted`,`--line`), +acentos sobrios **mar** (`--sea` #1f4b54, primario/activos) y **arena** (`--sand`, detalles puntuales como A&B). +Botón primario negro. Sin serif decorativo. + +## Archivos / fotos +- En `uploads/servicio_{id}/`, prefijo `foto_` (fotos) o `doc_` (documentos). +- `/api/file-counts` elige `first_image` PREFIRIENDO `foto_` y excluyendo docs (mismo patrón que el Hub). + +## Endpoints +- `GET/POST/PUT/DELETE /api/{proveedores|servicios}` +- `GET /api/servicios` (join proveedor_nombre) · `GET /api/proveedores` (con servicios_count) +- `GET /api/dashboard` · `GET /api/file-counts` · `GET /api/files/{entidad}` +- `POST /api/upload/{entidad}?tipo=foto&label=...` · `DELETE /api/files/{entidad}/{name}` + +## Convenciones de edición +- Editar LOCAL aquí (`/Users/claudeandrefg/Documents/catalogo-borrador/`) y desplegar con scp. +- No instalar dependencias — stdlib + vanilla JS. Si una feature lo requiere, discutir antes. +- Para agregar una tabla nueva: `CREATE TABLE` en `init_db()` + entrada en `TABLES` (CRUD automático). + +## Pendientes / fases siguientes +1. (Hecho) Builder: proveedores + servicios con fotos y todos los campos. +2. Builder de **propuestas/catálogos** (selección de servicios → documento → PDF). Reusar patrón `catalogos` del Hub. +3. **Página web** pública de catálogo online optimizado (sync desde la DB). diff --git a/INSTALACION.md b/INSTALACION.md new file mode 100644 index 0000000..084fa2c --- /dev/null +++ b/INSTALACION.md @@ -0,0 +1,105 @@ +# Instalación y Despliegue — Catálogo de Servicios + +Sin Docker, sin pip, sin CI/CD. Solo Python 3 y `systemd`. + +--- + +## Requisitos +- **Python 3.8+** (stdlib únicamente) +- Linux con `systemd` para correr como servicio (en Windows/Mac corre con `python3 server.py`) + +--- + +## 1. Desarrollo local + +```bash +git clone https://git.consultoria-as.com/consultoria-as/catalogo-servicios.git +cd catalogo-servicios +python3 server.py +# → http://localhost:4403 +``` + +Primera vez: pantalla de **setup** para crear la cuenta admin. Genera `catalogo.db` y `secret.key`. + +--- + +## 2. Despliegue en servidor (Tailscale `iclaude`) + +Ubicación en producción: `/mnt/iclaude/catalogo-borrador/`. Puerto **4403** +(4401 = Hub, 4402 = airbnb-pricing, 4404 = foto-studio). + +### a) Copiar archivos (sin secretos ni datos) + +```bash +scp server.py index.html claude@iclaude:/mnt/iclaude/catalogo-borrador/ +``` + +> **No copiar** `secret.key` ni `catalogo.db` — el servidor ya tiene los suyos. Los datos viven solo en el servidor. + +### b) Servicio systemd + +`/etc/systemd/system/catalogo-borrador.service`: + +```ini +[Unit] +Description=Catalogo (borrador) - builder de servicios +After=network.target + +[Service] +Type=simple +User=claude +WorkingDirectory=/mnt/iclaude/catalogo-borrador +ExecStart=/usr/bin/python3 /mnt/iclaude/catalogo-borrador/server.py +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now catalogo-borrador +``` + +### c) Redespliegue + +```bash +scp index.html server.py claude@iclaude:/mnt/iclaude/catalogo-borrador/ +ssh claude@iclaude "sudo systemctl restart catalogo-borrador" +``` + +Migraciones de esquema corren solas al reiniciar (idempotentes). + +### Logs + +```bash +sudo journalctl -u catalogo-borrador -n 50 --no-pager +``` + +--- + +## 3. Acceso + +- **Privado** (tailnet): `http://iclaude:4403/` · `http://192.168.50.46:4403/` +- **Público** (clientes, sin Tailscale): vía **Tailscale Funnel** en puerto dedicado (8443). + ```bash + tailscale funnel --bg 8443 + tailscale funnel status + ``` + > Nota: usar puerto dedicado (no un path bajo `/catalogo`) porque la app usa rutas absolutas `/api/...`. + +--- + +## 4. Solución de problemas + +| Síntoma | Causa | Solución | +|---|---|---| +| "Sesión inválida" constante | reloj del servidor desfasado | corregir hora (cron `sync_clock.sh`) | +| Logins rotos bajo Funnel con path | rutas absolutas `/api/` | exponer en puerto propio (8443), no en `/catalogo` | +| Thumbnail equivocado | prefijos de archivo | `first_image` prefiere `foto_`, excluye `menu`/docs | + +--- + +*Stack mínimo: todo corre con un solo `python3 server.py`.* diff --git a/MODELO_DATOS.md b/MODELO_DATOS.md new file mode 100644 index 0000000..d1a0a9f --- /dev/null +++ b/MODELO_DATOS.md @@ -0,0 +1,75 @@ +# Modelo de Datos — Catálogo de Servicios + +Base **SQLite** (`catalogo.db`), modo WAL + foreign keys ON. 3 tablas. +El esquema se crea/migra solo al arrancar (`init_db()` en `server.py`); migraciones idempotentes (`ALTER TABLE` solo si la columna falta). + +``` +proveedores (1) ──< (N) servicios [FK proveedor_id, ON DELETE SET NULL] +usuarios (login, independiente) +``` + +--- + +## `proveedores` — Quién ofrece el servicio +Touroperadores / proveedores. + +| Campo | Tipo | Notas | +|---|---|---| +| `id` | INTEGER PK | | +| `nombre` | TEXT | requerido | +| `tipo_principal` | TEXT | `tour` (default) / `ayb` / `transportacion` | +| `contacto`, `telefono`, `email`, `sitio_web` | TEXT | datos de contacto | +| `comision_default` | REAL | comisión base del proveedor | +| `notas` | TEXT | internas | +| `activo` | INTEGER | 1/0 | +| `created_at`, `updated_at` | TEXT | timestamps | + +--- + +## `servicios` — Fuente única de verdad +La tabla central. Campos agrupados por **sección de impacto**. + +| Sección | Campos | +|---|---| +| 📋 **Identidad** | `codigo`, `tipo` (tour/ayb/transportacion), `categoria`, `nombre` (req.), `descripcion` | +| ⚙️ **Operación** | `modo_disponibilidad`, `horarios` (JSON), `capacidad_min`, `capacidad_max`, `restricciones`, `atributos` (JSON por tipo) | +| 💰 **Comercial** | `precio_neto`, `precio_publico`, `moneda` (MXN), `unidad` (por_persona/por_grupo/por_vehiculo/por_evento), `tarifas_adicionales` | +| 📄 **Condiciones** | `terminos` | +| 📍 **Reserva-lista** | `ubicacion`, `mapa_url`, `checkin`, `anticipacion` | +| 🍽️ **Alimentos** | `incluye_alimentos` (0/1), `menu_detalle` | +| 🌐 **Publicación** | `mostrar_en_web` (0/1) | +| | `activo`, `notas`, `created_at`, `updated_at`, `proveedor_id` (FK) | + +### `atributos` — JSON flexible por tipo +En vez de columnas rígidas, los extras específicos van en JSON. Sugeridos (definidos en `index.html → ATRIBUTOS`): +- **tour**: duracion, punto_encuentro, incluye, no_incluye, idioma +- **ayb**: tipo_menu, min_personas, montaje, servicio_incluido, opciones_dieta +- **transportacion**: tipo_vehiculo, pasajeros, ruta_zona, chofer, tiempo_espera + +### `modo_disponibilidad` + `horarios` (JSON) +Eje **independiente** del `tipo`. Default por tipo (`MODO_DEFAULT`): tour→`salidas`, transportacion→`siempre`, ayb→`por_evento`. Editable. + +| Modo | `horarios` | +|---|---| +| `salidas` | `{"Lun":["08:00","14:00"], ...}` (días + varias salidas/día) | +| `rango` | `{"dias":[...],"desde":"06:00","hasta":"22:00"}` | +| `siempre` (24/7) | `{}` | +| `por_evento` | `{}` (reserva por fecha) | + +> Compatibilidad: servicios viejos sin `modo` se tratan como `salidas` con su `horarios` día-keyed. + +--- + +## `usuarios` — Login +`id`, `username` (único), `pass_hash` (PBKDF2-SHA256 + salt), `nombre`, `activo`, `created_at`. +Sesión = cookie firmada con HMAC (clave en `secret.key`, NO versionada). + +--- + +## Archivos (no en DB) +En `uploads/servicio_{id}/` con prefijo: +- `foto_` — fotos del servicio +- `menu_` — fotos del menú/alimentos (separadas; `loadPhotos` excluye `menu_`, `loadMenuFotos` solo `menu_`) +- `doc_` — documentos + +`/api/file-counts` elige `first_image` **prefiriendo** `foto_` y **excluyendo** docs y `menu` (para no robar el thumbnail). diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e8ede5 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Catálogo de Servicios (builder) + +Builder de catálogo **multi-proveedor de servicios** (tours, A&B/banquetes, transportación) para Los Cabos, México. + +Hereda la filosofía del **Art4Hotel Hub**, pero aplicada a **servicios** (no productos físicos): capturar en una sola base de datos la oferta de varios touroperadores/proveedores, con fotos y todos los atributos, para luego generar **propuestas** y una **página web de catálogo online**. + +> Proyecto independiente. No toca el Hub. + +--- + +## ✨ Qué hace + +- **Proveedores** — alta de touroperadores / proveedores (contacto, comisión default) +- **Servicios** — la *fuente única de verdad*: identidad, operación, precios neto/público, condiciones, publicación +- **Atributos flexibles por tipo** — extras específicos en JSON (no rompen el esquema) +- **Disponibilidad** — 4 modos (salidas / rango / siempre / por evento) con editor adaptativo +- **Fotos y menú** — galería por servicio + galería de menú separada por prefijo +- **Dos vistas** — Builder (con margen/precio neto) y Vista cliente (catálogo limpio, oculta lo interno) +- **Login** — usuario/contraseña (PBKDF2) + sesión firmada (HMAC) + +--- + +## 🧱 Stack + +| Capa | Tecnología | +|---|---| +| Backend | **Python 3 stdlib** (`http.server` + `sqlite3`) — cero dependencias | +| Base de datos | **SQLite** (WAL, foreign keys) | +| Frontend | **Vanilla JS SPA** en un solo `index.html` | +| Archivos | Filesystem (`uploads/servicio_{id}/`) | +| Deploy | `scp` + `systemctl` | + +Mismo patrón que el Hub: CRUD genérico vía dict `TABLES` en `server.py` (un handler para todas las tablas). + +--- + +## 🚀 Arranque rápido + +```bash +python3 server.py +# → http://localhost:4403 +``` + +Primera vez: pantalla para **crear la cuenta admin** (genera `catalogo.db` y `secret.key`). + +Ver **[INSTALACION.md](./INSTALACION.md)** para el despliegue en servidor. + +--- + +## 📁 Estructura + +``` +catalogo-borrador/ +├── server.py # Backend: HTTP + SQLite + API + auth +├── index.html # Frontend: SPA (builder + vista cliente) +├── README.md # Este archivo +├── MODELO_DATOS.md # Esquema de la base de datos +├── API.md # Referencia de endpoints +├── INSTALACION.md # Despliegue desde cero +└── CLAUDE.md # Contexto técnico detallado (para asistentes IA) +``` + +> **No incluidos** (ver `.gitignore`): `catalogo.db`, `secret.key`, `uploads/`. + +--- + +## 🔑 Principio de diseño: el servicio = fuente única de verdad + +Cada atributo de un servicio impacta hasta **3 funciones**. Antes de agregar/cambiar uno, define su impacto en: +1. **Operación** — disponibilidad, horarios, capacidad, precio neto +2. **Propuesta / cotizador** — lo que ve el cliente, precio público, términos +3. **Página web** — catálogo online + +**Neto vs público:** `precio_neto` = lo que cobra el proveedor · `precio_publico` = lo que paga el cliente. El margen vive en medio y **nunca** se muestra al cliente (la Vista cliente lo oculta). + +--- + +## 🗺 Fases + +1. ✅ **Builder** — proveedores + servicios con fotos y todos los campos *(actual)* +2. ⏳ **Propuestas/catálogos** — selección de servicios → documento → PDF (reusar patrón del Hub) +3. ⏳ **Web pública** — catálogo online optimizado (sync desde la DB) + +--- + +*Parte del ecosistema Art4Hotel · Los Cabos, BCS, México.* diff --git a/index.html b/index.html new file mode 100644 index 0000000..6036b23 --- /dev/null +++ b/index.html @@ -0,0 +1,768 @@ + + + + + +Catálogo + + + + + +
+
Catálogobuilder
+ + +
+ +
+ +
+
+

Servicios

+
+ + +
+ + + + +
+
+
+ + +
+
+

Proveedores

+ + +
+
+
+ + +
+

Resumen

+
+
+
+ +
+
Claude2
+
= Claude × Claude · un humano y una IA, mismo nombre, construyendo juntos.
+
+ +
+
+ + + + diff --git a/server.py b/server.py new file mode 100644 index 0000000..08b1c93 --- /dev/null +++ b/server.py @@ -0,0 +1,597 @@ +""" +Catálogo (borrador) — Servidor local +Builder de catálogo multi-proveedor de servicios (tours / A&B / transportación). +Base: filosofía del Art4Hotel Hub (servicio = fuente única de verdad). +Zero dependencias externas (stdlib only). + +Uso: + python3 server.py + http://localhost:4402 +""" + +import http.server, json, sqlite3, os, urllib.parse, re, mimetypes +import hashlib, hmac, base64, secrets, time, http.cookies +from datetime import datetime +from pathlib import Path + +PORT = 4403 +BASE = Path(__file__).parent +DB_PATH = BASE / "catalogo.db" +STATIC_DIR = BASE +UPLOADS_DIR = BASE / "uploads" +UPLOADS_DIR.mkdir(exist_ok=True) +MAX_UPLOAD = 25 * 1024 * 1024 # 25MB + +# ── Autenticación ── +SESSION_HOURS = 24 * 14 +SECRET_FILE = BASE / "secret.key" +def _load_secret(): + if SECRET_FILE.exists(): + return SECRET_FILE.read_bytes() + s = secrets.token_bytes(32) + SECRET_FILE.write_bytes(s) + try: os.chmod(SECRET_FILE, 0o600) + except Exception: pass + return s +SECRET = _load_secret() + +def hash_password(password, salt=None): + if salt is None: salt = secrets.token_hex(16) + h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 200000).hex() + return salt + '$' + h + +def verify_password(password, stored): + try: + salt, _ = stored.split('$', 1) + return hmac.compare_digest(hash_password(password, salt), stored) + except Exception: + return False + +def make_session(username): + exp = int(time.time()) + SESSION_HOURS * 3600 + payload = f"{username}|{exp}" + sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest() + return base64.urlsafe_b64encode(payload.encode()).decode() + '.' + sig + +def check_session(token): + try: + b64, sig = token.split('.', 1) + payload = base64.urlsafe_b64decode(b64.encode()).decode() + expect = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expect): return None + username, exp = payload.split('|') + if int(exp) < time.time(): return None + return username + except Exception: + return None + + +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + conn = get_db(); c = conn.cursor() + + # ── Usuarios (auth) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS usuarios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + pass_hash TEXT NOT NULL, + nombre TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Proveedores (touroperadores / quien ofrece el servicio) ── + c.execute(""" + CREATE TABLE IF NOT EXISTS proveedores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nombre TEXT NOT NULL, + tipo_principal TEXT DEFAULT 'tour', + contacto TEXT DEFAULT '', + telefono TEXT DEFAULT '', + email TEXT DEFAULT '', + sitio_web TEXT DEFAULT '', + comision_default REAL DEFAULT 0, + notas TEXT DEFAULT '', + activo INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + + # ── Servicios (FUENTE ÚNICA DE VERDAD) ── + # tipo: tour | ayb | transportacion + # unidad: por_persona | por_grupo | por_vehiculo | por_evento + # atributos: JSON con extras específicos por tipo (flexible, no rompe esquema) + c.execute(""" + CREATE TABLE IF NOT EXISTS servicios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proveedor_id INTEGER, + codigo TEXT DEFAULT '', + tipo TEXT DEFAULT 'tour', + categoria TEXT DEFAULT '', + nombre TEXT NOT NULL, + descripcion TEXT DEFAULT '', + terminos TEXT DEFAULT '', + horarios TEXT DEFAULT '', + capacidad_min INTEGER DEFAULT 0, + capacidad_max INTEGER DEFAULT 0, + unidad TEXT DEFAULT 'por_persona', + precio_neto REAL DEFAULT 0, + precio_publico REAL DEFAULT 0, + moneda TEXT DEFAULT 'MXN', + tarifas_adicionales TEXT DEFAULT '', + restricciones TEXT DEFAULT '', + atributos TEXT DEFAULT '', + mostrar_en_web INTEGER DEFAULT 0, + activo INTEGER DEFAULT 1, + notas TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')), + FOREIGN KEY (proveedor_id) REFERENCES proveedores(id) ON DELETE SET NULL + ) + """) + + # ── migración: modo de disponibilidad + ubicación + check-in (reserva-lista) ── + scols={r[1] for r in c.execute("PRAGMA table_info(servicios)")} + for col in ['modo_disponibilidad','ubicacion','mapa_url','checkin','anticipacion','menu_detalle']: + if col not in scols: c.execute(f"ALTER TABLE servicios ADD COLUMN {col} TEXT DEFAULT ''") + if 'incluye_alimentos' not in scols: c.execute("ALTER TABLE servicios ADD COLUMN incluye_alimentos INTEGER DEFAULT 0") + + conn.commit(); conn.close() + + +# Config para el CRUD genérico (mismo patrón que el Hub) +TABLES = { + 'proveedores': { + 'fields': ['nombre','tipo_principal','contacto','telefono','email','sitio_web', + 'comision_default','notas','activo'], + 'int_fields': ['activo'], + 'float_fields': ['comision_default'], + }, + 'servicios': { + 'fields': ['proveedor_id','codigo','tipo','categoria','nombre','descripcion','terminos', + 'horarios','modo_disponibilidad','ubicacion','mapa_url','checkin','anticipacion', + 'incluye_alimentos','menu_detalle', + 'capacidad_min','capacidad_max','unidad','precio_neto','precio_publico', + 'moneda','tarifas_adicionales','restricciones','atributos','mostrar_en_web', + 'activo','notas'], + 'int_fields': ['proveedor_id','capacidad_min','capacidad_max','mostrar_en_web','activo','incluye_alimentos'], + 'float_fields': ['precio_neto','precio_publico'], + 'nullable_fields': ['proveedor_id'], + }, +} + + +LOGIN_PAGE = """ + + +Catálogo — Acceso + + +
+ +
builder
+

Iniciar sesión

+
Acceso al builder de catálogo
+
+ + + + +
+
+
+""" + + +class CatalogoHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(STATIC_DIR), **kw) + + # ══════ ROUTING ══════ + def do_GET(self): + p = urllib.parse.urlparse(self.path) + if p.path == "/api/needs-setup": return self.handle_needs_setup() + if not self.current_user(): + if p.path.startswith("/api/") or p.path.startswith("/uploads/"): + return self.json_ok({"error": "no autenticado"}, 401) + return self.serve_login_page() + if p.path in ("", "/"): self.path = "/index.html"; return super().do_GET() + if p.path.startswith("/api/"): return self.api_get(p.path) + if p.path.startswith("/uploads/"): return self.serve_upload(p.path) + return super().do_GET() + + def do_POST(self): + if self.path == "/api/login": return self.handle_login() + if self.path == "/api/setup": return self.handle_setup() + if self.path == "/api/logout": return self.handle_logout() + if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401) + if self.path.startswith("/api/upload/"): return self.handle_upload() + if self.path.startswith("/api/"): return self.api_write("POST", self.path) + self.send_error(404) + + def do_PUT(self): + if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401) + if self.path.startswith("/api/"): return self.api_write("PUT", self.path) + self.send_error(404) + + def do_DELETE(self): + if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401) + if self.path.startswith("/api/files/"): return self.delete_file() + if self.path.startswith("/api/"): return self.api_delete(self.path) + self.send_error(404) + + def do_OPTIONS(self): + self.send_response(200) + for h, v in [("Access-Control-Allow-Origin","*"), + ("Access-Control-Allow-Methods","GET,POST,PUT,DELETE,OPTIONS"), + ("Access-Control-Allow-Headers","Content-Type")]: + self.send_header(h, v) + self.end_headers() + + # ══════ HELPERS ══════ + def json_ok(self, data, status=200, cookie=None): + self.send_response(status) + self.send_header("Content-Type","application/json") + self.send_header("Access-Control-Allow-Origin","*") + if cookie: self.send_header("Set-Cookie", cookie) + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode()) + + def body(self): + n = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(n)) if n else {} + + # ══════ AUTENTICACIÓN ══════ + def current_user(self): + cookie = self.headers.get('Cookie', '') + if not cookie: return None + try: + c = http.cookies.SimpleCookie(cookie) + if 'cat_session' in c: + return check_session(c['cat_session'].value) + except Exception: + return None + return None + + def _session_cookie(self, token): + return f"cat_session={token}; HttpOnly; Path=/; Max-Age={SESSION_HOURS*3600}; SameSite=Lax" + + def handle_needs_setup(self): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0] + conn.close() + self.json_ok({"needs_setup": n == 0}) + + def handle_setup(self): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0] + if n > 0: + conn.close(); return self.json_ok({"error": "Ya hay usuarios configurados"}, 403) + data = self.body() + u = (data.get('username') or '').strip().lower() + p = data.get('password') or '' + nombre = (data.get('nombre') or '').strip() + if not u or len(p) < 6: + conn.close(); return self.json_ok({"error": "Usuario requerido y contraseña de 6+ caracteres"}, 400) + conn.execute("INSERT INTO usuarios (username,pass_hash,nombre) VALUES (?,?,?)", + (u, hash_password(p), nombre or u)) + conn.commit(); conn.close() + self.json_ok({"ok": True}, cookie=self._session_cookie(make_session(u))) + + def handle_login(self): + data = self.body() + u = (data.get('username') or '').strip().lower() + p = data.get('password') or '' + conn = get_db() + row = conn.execute("SELECT * FROM usuarios WHERE username=? AND activo=1", (u,)).fetchone() + conn.close() + if not row or not verify_password(p, row['pass_hash']): + return self.json_ok({"error": "Usuario o contraseña incorrectos"}, 401) + self.json_ok({"ok": True, "nombre": row['nombre'] or u}, + cookie=self._session_cookie(make_session(u))) + + def handle_logout(self): + self.json_ok({"ok": True}, cookie="cat_session=; Path=/; Max-Age=0") + + def serve_login_page(self): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(LOGIN_PAGE.encode()) + + # ══════ UPLOADS ══════ + def handle_upload(self): + """POST /api/upload/{entidad}?tipo=foto|doc&label=...""" + parts = self.path.split("/") + entidad = urllib.parse.unquote("/".join(parts[3:])).split("?")[0] + params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + tipo = params.get("tipo", ["foto"])[0] + label = params.get("label", [""])[0].strip() + + content_type = self.headers.get("Content-Type", "") + content_length = int(self.headers.get("Content-Length", 0)) + if content_length > MAX_UPLOAD: + return self.json_ok({"error": "Archivo muy grande (max 25MB)"}, 413) + if "multipart/form-data" not in content_type: + return self.json_ok({"error": "Usar multipart/form-data"}, 400) + + boundary = content_type.split("boundary=")[1].strip() + raw = self.rfile.read(content_length) + files_saved = [] + idx = 0 + for part in raw.split(f"--{boundary}".encode()): + if b"filename=" not in part: continue + header_end = part.find(b"\r\n\r\n") + if header_end == -1: continue + header = part[:header_end].decode("utf-8", errors="replace") + file_data = part[header_end+4:] + if file_data.endswith(b"\r\n"): file_data = file_data[:-2] + if file_data.endswith(b"--\r\n"): file_data = file_data[:-4] + if file_data.endswith(b"--"): file_data = file_data[:-2] + fn_match = re.search(r'filename="([^"]+)"', header) + if not fn_match or not file_data: continue + orig_name = fn_match.group(1) + ext = ('.' + orig_name.rsplit('.', 1)[1]) if '.' in orig_name else '' + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + if label: + desc = label + (f"_{idx+1}" if idx > 0 else "") + safe_desc = re.sub(r'[^\w\-. ]', '', desc).strip().replace(' ', '_') + final_name = f"{tipo}_{ts}_{safe_desc}{ext}" + else: + safe_name = re.sub(r'[^\w\-._]', '_', orig_name) + final_name = f"{tipo}_{ts}_{safe_name}" + idx += 1 + ent_dir = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', entidad) + ent_dir.mkdir(exist_ok=True) + (ent_dir / final_name).write_bytes(file_data) + files_saved.append({ + "name": final_name, "original": orig_name, "tipo": tipo, + "size": len(file_data), "url": f"/uploads/{ent_dir.name}/{final_name}" + }) + if files_saved: + self.json_ok({"files": files_saved}, 201) + else: + self.json_ok({"error": "No se encontraron archivos"}, 400) + + def serve_upload(self, path): + clean = path.replace("..", "").lstrip("/") + filepath = STATIC_DIR / clean + if not filepath.exists() or not filepath.is_file(): + return self.send_error(404) + try: + filepath.resolve().relative_to(UPLOADS_DIR.resolve()) + except ValueError: + return self.send_error(403) + mime, _ = mimetypes.guess_type(str(filepath)) + if not mime: mime = "application/octet-stream" + data = filepath.read_bytes() + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", len(data)) + self.send_header("Content-Disposition", f'inline; filename="{filepath.name}"') + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + + def delete_file(self): + parts = self.path.split("/") + if len(parts) < 5: return self.send_error(400) + entidad = parts[3] + filename = urllib.parse.unquote(parts[4]) + filepath = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', entidad) / filename + if filepath.exists(): + filepath.unlink(); self.json_ok({"ok": True}) + else: + self.send_error(404) + + # ══════ API GET ══════ + def api_get(self, path): + conn = get_db() + try: + if path.startswith("/api/files/"): + entidad = urllib.parse.unquote(path[11:]) + ent_dir = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', entidad) + files = [] + if ent_dir.exists(): + for f in sorted(ent_dir.iterdir()): + if f.is_file(): + ext = f.suffix.lower() + files.append({ + "name": f.name, "size": f.stat().st_size, + "url": f"/uploads/{ent_dir.name}/{f.name}", + "is_image": ext in ('.jpg','.jpeg','.png','.gif','.webp','.bmp'), + "ext": ext, + }) + return self.json_ok(files) + + if path == "/api/file-counts": + IMG_EXT = ('.jpg','.jpeg','.png','.gif','.webp','.bmp') + DOC_PREFIXES = ('doc','terminos','contrato','factura','menu') + PHOTO_PREFIXES = ('foto_','mockup') + result = {} + if UPLOADS_DIR.exists(): + for d in UPLOADS_DIR.iterdir(): + if not d.is_dir(): continue + files = [f for f in d.iterdir() if f.is_file()] + if not files: continue + imgs = sorted([f for f in files if f.suffix.lower() in IMG_EXT], key=lambda f: f.name) + photo = next((f for f in imgs if f.name.lower().startswith(PHOTO_PREFIXES)), None) + if not photo: + photo = next((f for f in imgs if not f.name.lower().startswith(DOC_PREFIXES)), None) + result[d.name] = { + "count": len(files), + "first_image": f"/uploads/{d.name}/{photo.name}" if photo else None + } + return self.json_ok(result) + + if path == "/api/dashboard": + return self.json_ok(self.dashboard(conn)) + + if path == "/api/proveedores": + rows = conn.execute(""" + SELECT p.*, (SELECT COUNT(*) FROM servicios s WHERE s.proveedor_id=p.id) AS servicios_count + FROM proveedores p ORDER BY p.nombre + """).fetchall() + return self.json_ok([dict(r) for r in rows]) + + if path == "/api/servicios": + rows = conn.execute(""" + SELECT s.*, p.nombre AS proveedor_nombre + FROM servicios s LEFT JOIN proveedores p ON p.id=s.proveedor_id + ORDER BY s.id DESC + """).fetchall() + return self.json_ok([dict(r) for r in rows]) + + # Fallback genérico para cualquier tabla registrada + table = path.split("/")[2] if len(path.split("/")) >= 3 else None + if table in TABLES: + rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall() + return self.json_ok([dict(r) for r in rows]) + + self.send_error(404) + finally: + conn.close() + + # ══════ API WRITE (CRUD genérico) ══════ + def api_write(self, method, path): + parts = path.split("/") + table = parts[2] if len(parts) >= 3 else None + item_id = int(parts[3]) if len(parts) >= 4 and parts[3].isdigit() else None + data = self.body() + if table not in TABLES: + return self.send_error(404) + conn = get_db() + try: + cfg = TABLES[table] + if method == "POST": + cols, vals = [], [] + for f in cfg['fields']: + if f in data: + v = data[f] + if f in cfg.get('int_fields',[]): v = int(v or 0) + if f in cfg.get('float_fields',[]): v = float(v or 0) + if f in cfg.get('nullable_fields',[]) and (v == 0 or v == '' or v is None): v = None + cols.append(f); vals.append(v) + placeholders = ','.join(['?']*len(cols)) + cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})", vals) + conn.commit() + return self.json_ok({"id": cur.lastrowid}, 201) + elif method == "PUT" and item_id: + sets, vals = [], [] + for f in cfg['fields']: + if f in data: + v = data[f] + if f in cfg.get('int_fields',[]): v = int(v or 0) + if f in cfg.get('float_fields',[]): v = float(v or 0) + if f in cfg.get('nullable_fields',[]) and (v == 0 or v == '' or v is None): v = None + sets.append(f"{f}=?"); vals.append(v) + if sets: + cols_in_table = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + if 'updated_at' in cols_in_table: + sets.append("updated_at=datetime('now','localtime')") + conn.execute(f"UPDATE {table} SET {','.join(sets)} WHERE id=?", vals+[item_id]) + conn.commit() + return self.json_ok({"ok": True}) + self.send_error(400) + finally: + conn.close() + + def api_delete(self, path): + parts = path.split("/") + table = parts[2] if len(parts) >= 3 else None + item_id = int(parts[3]) if len(parts) >= 4 and parts[3].isdigit() else None + if table not in TABLES or not item_id: + return self.send_error(404) + conn = get_db() + try: + conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,)) + conn.commit() + self.json_ok({"ok": True}) + finally: + conn.close() + + # ══════ DASHBOARD ══════ + def dashboard(self, conn): + por_tipo = {r['tipo']: r['n'] for r in conn.execute( + "SELECT tipo, COUNT(*) n FROM servicios WHERE activo=1 GROUP BY tipo")} + por_prov = {} + for r in conn.execute(""" + SELECT COALESCE(p.nombre,'(sin proveedor)') prov, COUNT(*) n + FROM servicios s LEFT JOIN proveedores p ON p.id=s.proveedor_id + WHERE s.activo=1 GROUP BY prov ORDER BY n DESC"""): + por_prov[r['prov']] = r['n'] + total_serv = conn.execute("SELECT COUNT(*) FROM servicios WHERE activo=1").fetchone()[0] + total_prov = conn.execute("SELECT COUNT(*) FROM proveedores WHERE activo=1").fetchone()[0] + en_web = conn.execute("SELECT COUNT(*) FROM servicios WHERE activo=1 AND mostrar_en_web=1").fetchone()[0] + # margen promedio (donde hay precios) + mrow = conn.execute(""" + SELECT AVG((precio_publico-precio_neto)*100.0/precio_publico) + FROM servicios WHERE activo=1 AND precio_publico>0 AND precio_neto>0""").fetchone()[0] + return { + 'por_tipo': por_tipo, 'por_proveedor': por_prov, + 'total_servicios': total_serv, 'total_proveedores': total_prov, + 'en_web': en_web, 'margen_promedio': round(mrow or 0, 1), + } + + def log_message(self, fmt, *args): + if "/api/" in str(args): super().log_message(fmt, *args) + + +if __name__ == "__main__": + init_db() + print(f"\n Catálogo (borrador) — http://localhost:{PORT}") + print(f" DB: {DB_PATH.name}") + print(f" Ctrl+C para detener\n") + srv = http.server.HTTPServer(("", PORT), CatalogoHandler) + try: srv.serve_forever() + except KeyboardInterrupt: print("\nDetenido."); srv.server_close()