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 <noreply@anthropic.com>
598 lines
27 KiB
Python
598 lines
27 KiB
Python
"""
|
|
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 = """<!DOCTYPE html>
|
|
<html lang="es"><head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Catálogo — Acceso</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',system-ui,sans-serif;background:#f7f7f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;color:#16181d;-webkit-font-smoothing:antialiased}
|
|
.card{background:#fff;border:1px solid #e8e9ec;border-radius:14px;box-shadow:0 1px 2px rgba(20,24,28,.04),0 12px 32px rgba(20,24,28,.08);width:100%;max-width:380px;padding:38px 34px;text-align:center}
|
|
.logo{font-size:20px;font-weight:700;letter-spacing:-.01em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:8px}
|
|
.logo .dot{width:7px;height:7px;border-radius:2px;background:#1f4b54;display:inline-block}
|
|
.tag{font-size:10px;color:#8b9097;letter-spacing:.14em;text-transform:uppercase;font-weight:500;margin-bottom:26px}
|
|
h1{font-size:20px;color:#16181d;font-weight:700;letter-spacing:-.01em;margin-bottom:6px}
|
|
.sub{font-size:13px;color:#8b9097;margin-bottom:22px}
|
|
label{display:block;text-align:left;font-size:11px;color:#3d424b;letter-spacing:.01em;font-weight:600;margin:12px 0 5px}
|
|
input{width:100%;padding:11px 13px;border:1px solid #d9dbe0;border-radius:8px;font-family:inherit;font-size:15px;background:#fff;outline:none}
|
|
input:focus{border-color:#1f4b54;box-shadow:0 0 0 3px #eef3f3}
|
|
button{width:100%;margin-top:22px;padding:12px;background:#16181d;color:#fff;border:none;border-radius:8px;font-family:inherit;font-size:14px;font-weight:600;letter-spacing:.01em;cursor:pointer;transition:.12s}
|
|
button:hover{background:#000}
|
|
.err{color:#b3322b;font-size:12px;margin-top:14px;min-height:16px}
|
|
</style></head><body>
|
|
<div class="card">
|
|
<div class="logo"><span class="dot"></span>Catálogo</div>
|
|
<div class="tag">builder</div>
|
|
<h1 id="title">Iniciar sesión</h1>
|
|
<div class="sub" id="subtitle">Acceso al builder de catálogo</div>
|
|
<form id="f">
|
|
<div id="nombre-fg" style="display:none"><label>Tu nombre</label><input id="nombre" autocomplete="name"></div>
|
|
<label>Usuario</label><input id="username" autocomplete="username" autocapitalize="none">
|
|
<label>Contraseña</label><input id="password" type="password" autocomplete="current-password">
|
|
<button type="submit" id="btn">Entrar</button>
|
|
<div class="err" id="err"></div>
|
|
</form>
|
|
</div>
|
|
<script>
|
|
let setup=false;
|
|
fetch('/api/needs-setup').then(r=>r.json()).then(d=>{
|
|
if(d.needs_setup){
|
|
setup=true;
|
|
document.getElementById('title').textContent='Crear cuenta de administrador';
|
|
document.getElementById('subtitle').textContent='Primera vez — define tu usuario y contraseña';
|
|
document.getElementById('nombre-fg').style.display='block';
|
|
document.getElementById('btn').textContent='Crear cuenta';
|
|
document.getElementById('password').setAttribute('autocomplete','new-password');
|
|
}
|
|
});
|
|
document.getElementById('f').onsubmit=async e=>{
|
|
e.preventDefault();
|
|
const err=document.getElementById('err'); err.textContent='';
|
|
const body={username:document.getElementById('username').value.trim().toLowerCase(),
|
|
password:document.getElementById('password').value,
|
|
nombre:document.getElementById('nombre').value.trim()};
|
|
try{
|
|
const r=await fetch(setup?'/api/setup':'/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
const d=await r.json();
|
|
if(d.ok){location.href='/';}
|
|
else{err.textContent=d.error||'Error';}
|
|
}catch(_){err.textContent='Error de conexión';}
|
|
};
|
|
</script></body></html>"""
|
|
|
|
|
|
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()
|