fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config

This commit is contained in:
2026-10-11 05:50:18 +00:00
parent 28118ce732
commit f92018f88e
50 changed files with 4314 additions and 210 deletions

51
infra/provision/README.md Normal file
View File

@@ -0,0 +1,51 @@
# infra/provision — provisioning de instancias white-label
Levanta una clínica/spa nueva de SKEEN en la VPS con un comando, sin tocar las
instancias existentes (`skeen_odoo` y `clinica2_odoo`). Un solo código
(`/root/skeen-odoo/addons`), N bases de datos.
## Archivos
| Archivo | Para qué |
|---|---|
| `provision_clinica.py` | Script principal. Crea DB+rol, secretos, conf, unit, inicializa módulos, post-instala, renombra el menú raíz, arma el vhost nginx y despliega el frontend. |
| `postinstall_clinica.py` | Post-instalación vía ORM de Odoo: marca, empresa, admin del frontend y siembra de glosario/sucursal/settings **solo si existen**. Opcionalmente datos demo. |
| `demo_limpiar.py` | Borra los datos demo de una instancia (para no entregar basura a un cliente). |
| `templates/odoo.conf.tmpl` | Plantilla de `odoo.conf` con secretos/puerto/DB propios. |
| `templates/service.tmpl` | Plantilla del unit systemd `odoo-<slug>.service`. |
| `templates/nginx-vhost.tmpl` | Plantilla del vhost nginx (dominio + puerto LAN + bloqueo WACRM). |
## Uso rápido
```bash
cd /root/skeen-proyecto/infra/provision
# 1) Simular (no ejecuta nada) — SIEMPRE hacerlo primero
./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \
--pack spa --demo --dry-run
# 2) Ejecutar de verdad (coordinado, en horario de bajo uso)
./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \
--pack spa --demo
```
Guía completa: `../../docs/nueva-instancia.md`.
## Decisiones de diseño relevantes
- **Rol PostgreSQL propio por instancia.** El rol `skeen` es compartido y su
password (`skeen_dev_2026`) no se puede cambiar sin romper las dos instancias
existentes. Para cumplir "secretos propios" el script crea un rol nuevo
(`<slug>`) con password aleatoria y una DB cuyo owner es ese rol.
`--db-role skeen` reproduce el procedimiento manual anterior (rol compartido).
- **Dry-run por defecto recomendado.** `--dry-run` valida colisiones reales
(puerto, DB, unit, vhost, dirs) pero no escribe ni ejecuta nada.
- **Post-instalación tolerante.** Los modelos `skeen.sucursal`,
`skeen.service.category`, `skeen.clinic.settings` y el glosario están en
desarrollo por otro cambio; el post-install los detecta en runtime y deja
`TODO` en la salida para lo que aún no exista. Nunca aborta por eso.
- **Frontend compartido.** El bundle se compila una vez desde
`/root/skeen-frontend` y se copia con `rsync` a cada `/var/www/<slug>-frontend`;
la marca sale del API `/brand`, no del build.

89
infra/provision/demo_limpiar.py Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Borra los datos de demostración sembrados por `--demo`.
/root/odoo-venv/bin/python demo_limpiar.py --conf /root/<slug>-odoo.conf --db <db> [--dry-run]
Usa la lista de ids guardada en ir.config_parameter `skeen.demo.ids` y, como
respaldo, los marcadores ('DEMO-' en servicios, referred_by='DEMO' en
pacientes, '[DEMO]' en citas/empleados). Pensado para no dejar basura en una
instancia de cliente antes de entregarla.
"""
import argparse
import json
import sys
import traceback
def load_env(conf_path, db_name):
sys.path.insert(0, '/root/skeen-odoo')
import odoo
from odoo.tools import config as odoo_config
odoo_config.parse_config(['-c', conf_path, '-d', db_name,
'--no-http', '--log-level', 'error'])
odoo.service.server.load_server_wide_modules()
return odoo, odoo.registry(db_name)
# Orden de borrado: primero lo que depende de lo demás.
ORDER = ['skeen.cita', 'skeen.paquete.compra', 'skeen.paquete',
'skeen.monedero', 'skeen.servicio', 'res.partner', 'hr.employee']
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--conf', required=True)
ap.add_argument('--db', required=True)
ap.add_argument('--dry-run', action='store_true')
args = ap.parse_args()
odoo, registry = load_env(args.conf, args.db)
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
raw = ICP.get_param('skeen.demo.ids') or '{}'
try:
ids = json.loads(raw)
except Exception:
ids = {}
# respaldo por marcadores cuando no hay ids guardados
markers = {}
if not any(ids.values()):
markers = {
'skeen.servicio': [('code', 'like', 'DEMO-%')],
'res.partner': [('referred_by', '=', 'DEMO')],
'hr.employee': [('notes', 'like', '[DEMO]')],
'skeen.cita': [('notes', 'like', '[DEMO]')],
}
for model in ORDER:
if model not in env:
continue
Model = env[model].sudo()
recs = Model.browse([])
for rid in ids.get(model.split('.')[-1], []):
recs |= Model.browse(rid).exists()
if model in markers:
recs |= Model.search(markers[model])
if not recs:
continue
print('%-22s %d registros' % (model, len(recs)))
if not args.dry_run:
try:
recs.unlink()
except Exception as e:
print(' no se pudo borrar del todo: %s' % e)
if not args.dry_run:
ICP.set_param('skeen.demo.ids', '{}')
cr.commit()
print('LIMPIEZA %s' % ('SIMULADA (dry-run)' if args.dry_run else 'OK'))
if __name__ == '__main__':
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,445 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Post-instalación de una instancia white-label de SKEEN.
Se ejecuta con el Python del venv de Odoo después de inicializar los módulos:
/root/odoo-venv/bin/python postinstall_clinica.py /ruta/config.json
El config.json trae: conf, db, nombre, dominio, pack, admin_login,
admin_password, con_wacrm, demo, report.
Hace, en orden:
1. Brand neutro (ir.config_parameter 'skeen.clinic.brand').
2. Oculta WACRM (skeen.menu_roles) salvo que con_wacrm sea True.
3. Renombra res.company principal.
4. Usuario admin del frontend con password propia + cambio obligatorio.
5. Siembra, SOLO si existen en runtime, los parámetros/modelos del nuevo
sistema de configuración: glosario (pack), skeen.clinic.settings,
skeen.sucursal, skeen.service.category. Lo que no exista queda como TODO
en el reporte; nunca aborta por eso.
6. Datos demo opcionales (--demo), marcados con '[DEMO]'/'DEMO-' para poder
limpiarlos luego con demo_limpiar.py.
No toca ninguna otra base de datos: todo corre sobre `db` del config.
"""
import datetime
import json
import sys
import traceback
# ---------------------------------------------------------------- packs giro
# (nombre, precio, duración_min, categoría) por giro. La categoría usa la
# selección de skeen.servicio: consulta | tratamiento | procedimiento | paquete.
PACKS = {
'generico': [
('Consulta general', 600, 30, 'consulta'),
('Consulta de seguimiento', 450, 30, 'consulta'),
('Tratamiento básico', 800, 45, 'tratamiento'),
('Tratamiento intensivo', 1200, 60, 'tratamiento'),
('Procedimiento menor', 950, 30, 'procedimiento'),
('Procedimiento mayor', 1800, 90, 'procedimiento'),
('Valoración inicial', 350, 20, 'consulta'),
('Terapia de mantenimiento', 700, 45, 'tratamiento'),
('Sesión express', 400, 20, 'tratamiento'),
('Paquete 5 sesiones', 3000, 45, 'paquete'),
],
'spa': [
('Masaje relajante 60 min', 900, 60, 'tratamiento'),
('Masaje descontracturante', 1100, 60, 'tratamiento'),
('Facial hidratante', 850, 50, 'tratamiento'),
('Facial antiedad', 1400, 60, 'tratamiento'),
('Exfoliación corporal', 750, 45, 'tratamiento'),
('Envoltura de algas', 1200, 60, 'tratamiento'),
('Drenaje linfático', 1000, 60, 'procedimiento'),
('Depilación con cera (pierna)', 500, 40, 'procedimiento'),
('Manicure spa', 450, 40, 'procedimiento'),
('Paquete 5 masajes', 4000, 60, 'paquete'),
],
'clinica': [
('Consulta médica general', 700, 30, 'consulta'),
('Consulta de especialidad', 950, 40, 'consulta'),
('Consulta de seguimiento', 500, 20, 'consulta'),
('Aplicación de medicamento', 350, 15, 'procedimiento'),
('Curación de herida', 450, 25, 'procedimiento'),
('Toma de muestra', 300, 15, 'procedimiento'),
('Terapia física', 800, 50, 'tratamiento'),
('Rehabilitación', 900, 60, 'tratamiento'),
('Valoración preoperatoria', 1100, 45, 'consulta'),
('Paquete 10 terapias', 6500, 50, 'paquete'),
],
'derma': [
('Consulta dermatológica', 900, 30, 'consulta'),
('Consulta de seguimiento', 600, 20, 'consulta'),
('Limpieza facial profunda', 950, 50, 'tratamiento'),
('Peeling químico', 1600, 45, 'tratamiento'),
('Microdermoabrasión', 1300, 45, 'tratamiento'),
('Depilación láser (zona pequeña)', 1200, 30, 'procedimiento'),
('Depilación láser (zona grande)', 2200, 45, 'procedimiento'),
('Mesoterapia facial', 1800, 40, 'procedimiento'),
('Crioterapia', 700, 20, 'procedimiento'),
('Paquete 5 limpiezas', 4000, 50, 'paquete'),
],
}
DEMO_PATIENTS = [
('Ana López Márquez', '6681230001', 'F'), ('Carlos Ruiz Peña', '6681230002', 'M'),
('María Fernanda Soto', '6681230003', 'F'), ('Jorge Alberto Nava', '6681230004', 'M'),
('Lucía Ramírez Ortega', '6681230005', 'F'), ('Pedro Sánchez Luna', '6681230006', 'M'),
('Sofía Herrera Cano', '6681230007', 'F'), ('Diego Morales Vega', '6681230008', 'M'),
('Valeria Castro Ibarra', '6681230009', 'F'), ('Andrés Gil Mendoza', '6681230010', 'M'),
('Renata Flores Cuevas', '6681230011', 'F'), ('Miguel Ángel Ponce', '6681230012', 'M'),
('Camila Ríos Delgado', '6681230013', 'F'), ('Fernando Ochoa Palma', '6681230014', 'M'),
('Isabela Vargas Rocha', '6681230015', 'F'),
]
DEMO_EMPLOYEES = [
('Dra. Elena Márquez', 'doctor', 10.0),
('Dr. Hugo Salinas', 'doctor', 8.0),
('Cosmet. Paula Rincón', 'cosmetologa', 12.0),
]
def load_env(conf_path, db_name):
sys.path.insert(0, '/root/skeen-odoo')
import odoo
from odoo.tools import config as odoo_config
odoo_config.parse_config(['-c', conf_path, '-d', db_name,
'--no-http', '--log-level', 'error'])
odoo.service.server.load_server_wide_modules()
return odoo, odoo.registry(db_name)
def has_model(env, model):
try:
return model in env
except Exception:
return False
def write_first(record, candidates, value):
"""Escribe `value` en el primer campo de `candidates` que exista."""
for field in candidates:
if field in record._fields:
try:
record.write({field: value})
return field
except Exception:
continue
return None
def create_first(model, candidates, value, extra=None):
vals = {}
for field in candidates:
if field in model._fields:
vals[field] = value
break
vals.update(extra or {})
if not vals:
return None
return model.create(vals)
def seed_brand(ICP, cfg, report):
brand = {
'nombre': cfg['nombre'],
'logo_claro': '/brand/logo-blanco.svg',
'logo_oscuro': '/brand/logo-negro.svg',
'pie': cfg.get('pie') or 'Sistema de gestión',
}
ICP.set_param('skeen.clinic.brand', json.dumps(brand, ensure_ascii=False))
report['seeded'].append('skeen.clinic.brand')
def seed_menu_roles(ICP, cfg, report):
if cfg.get('con_wacrm'):
report['skipped'].append('skeen.menu_roles (se pidió --con-wacrm)')
return
roles = json.loads(ICP.get_param('skeen.menu_roles') or '{}')
roles['/wacrm/messages'] = []
roles['/wacrm/leads'] = []
ICP.set_param('skeen.menu_roles', json.dumps(roles, ensure_ascii=False))
report['seeded'].append('skeen.menu_roles (WACRM oculto)')
def seed_company(env, cfg, report):
company = env['res.company'].sudo().search([], limit=1, order='id')
if company:
company.write({'name': cfg['nombre']})
report['seeded'].append('res.company.name = %s' % cfg['nombre'])
def seed_admin(env, cfg, report):
User = env['skeen.frontend.user'].sudo()
admin = User.search([('login', '=', cfg['admin_login'])], limit=1)
if not admin:
vals = {'login': cfg['admin_login'], 'name': '%s Admin' % cfg['nombre'],
'role': 'admin', 'password_hash': 'pendiente'}
admin = User.create(vals)
report['seeded'].append('skeen.frontend.user %s (creado)' % cfg['admin_login'])
else:
report['seeded'].append('skeen.frontend.user %s (password propia)' % cfg['admin_login'])
admin.set_password(cfg['admin_password'])
if 'must_change_password' in admin._fields:
admin.write({'must_change_password': True})
# --------------------------------------------------------- config system v2
def seed_glossary(env, ICP, cfg, report):
"""Glosario con el pack elegido. Tolera los tres estados posibles:
- llave ir.config_parameter 'skeen.glosario' ya existe -> se actualiza;
- modelo 'skeen.glosario' existe -> se crea/actualiza un registro;
- ninguno de los dos existe -> TODO (no aborta)."""
pack = cfg.get('pack') or 'generico'
raw = ICP.get_param('skeen.glosario')
if raw is not None:
try:
data = json.loads(raw)
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
data['pack'] = pack
ICP.set_param('skeen.glosario', json.dumps(data, ensure_ascii=False))
report['seeded'].append("skeen.glosario.pack = %s" % pack)
return
if has_model(env, 'skeen.glosario'):
Model = env['skeen.glosario'].sudo()
rec = Model.search([], limit=1)
if not rec:
rec = create_first(Model, ('pack', 'name', 'nombre'), pack)
elif 'pack' in Model._fields:
rec.write({'pack': pack})
if rec:
report['seeded'].append("skeen.glosario -> pack %s" % pack)
return
report['todo'].append(
"glosario: no existe la llave ir.config_parameter 'skeen.glosario' ni "
"el modelo skeen.glosario; sembrar el pack '%s' cuando el otro cambio "
"esté desplegado" % pack)
def seed_clinic_settings(env, cfg, report):
if not has_model(env, 'skeen.clinic.settings'):
report['todo'].append(
"skeen.clinic.settings: modelo ausente; cuando exista, sembrar "
"nombre=%r, url_publica=https://%s, horario 08:00-19:00, moneda MXN"
% (cfg['nombre'], cfg['dominio']))
return
Model = env['skeen.clinic.settings'].sudo()
rec = Model.search([], limit=1)
if not rec:
rec = Model.create({})
url = 'https://%s' % cfg['dominio']
write_first(rec, ('name', 'nombre', 'nombre_comercial'), cfg['nombre'])
write_first(rec, ('url_publica', 'url', 'website'), url)
write_first(rec, ('moneda', 'currency', 'currency_name'), 'MXN')
# 08:00 - 19:00 en los campos típicos de horario
write_first(rec, ('hora_apertura', 'horario_apertura', 'open_time',
'hour_from'), 8.0)
write_first(rec, ('hora_cierre', 'horario_cierre', 'close_time',
'hour_to'), 19.0)
write_first(rec, ('apertura', 'hora_inicio', 'inicio'), '08:00')
write_first(rec, ('cierre', 'hora_fin', 'fin'), '19:00')
report['seeded'].append('skeen.clinic.settings (nombre, url, horario, MXN)')
def seed_sucursal(env, cfg, report):
if not has_model(env, 'skeen.sucursal'):
report['todo'].append(
"skeen.sucursal: modelo ausente; cuando exista, crear 1 sucursal "
"'default' con nombre=%r" % cfg['nombre'])
return
Model = env['skeen.sucursal'].sudo()
if Model.search([], limit=1):
report['skipped'].append('skeen.sucursal (ya hay sucursales)')
return
rec = create_first(Model, ('name', 'nombre'), cfg['nombre'])
if rec:
report['seeded'].append('skeen.sucursal (1 sucursal default)')
def seed_service_category(env, cfg, report):
if not has_model(env, 'skeen.service.category'):
report['todo'].append(
"skeen.service.category: modelo ausente; cuando exista, sembrar la "
"semilla de categorías del pack '%s'" % (cfg.get('pack') or 'generico'))
return
Model = env['skeen.service.category'].sudo()
cats = []
for _n, _p, _d, cat in PACKS.get(cfg.get('pack') or 'generico', PACKS['generico']):
if cat not in cats:
cats.append(cat)
created = 0
for cat in cats:
label = cat.capitalize()
if Model.search([('name', '=', label)], limit=1):
continue
if create_first(Model, ('name', 'nombre'), label):
created += 1
report['seeded'].append('skeen.service.category (%d nuevas)' % created)
# ------------------------------------------------------------------- demo
def _mark(env, model, field, value, ids):
try:
recs = env[model].sudo().browse(ids)
if field in env[model]._fields:
recs.write({field: value})
except Exception:
pass
def seed_demo(env, cfg, report):
pack = PACKS.get(cfg.get('pack') or 'generico', PACKS['generico'])
today = datetime.date.today()
week_start = today - datetime.timedelta(days=today.weekday())
ids = {'servicio': [], 'paciente': [], 'empleado': [], 'cita': [],
'paquete': [], 'monedero': []}
Servicio = env['skeen.servicio'].sudo()
Serv = []
for i, (name, price, dur, cat) in enumerate(pack, start=1):
rec = Servicio.create({
'name': name, 'code': 'DEMO-%02d' % i, 'price': price,
'duration_min': dur, 'category': cat, 'is_favorite': i <= 3,
})
Serv.append(rec)
ids['servicio'].append(rec.id)
Partner = env['res.partner'].sudo()
Pacientes = []
for i, (name, phone, gender) in enumerate(DEMO_PATIENTS):
vals = {'name': name, 'is_patient': True, 'phone': phone,
'mobile': phone, 'referred_by': 'DEMO',
'email': 'demo%02d@demo.local' % (i + 1)}
if 'gender' in Partner._fields:
vals['gender'] = 'female' if gender == 'F' else 'male'
try:
rec = Partner.create(vals)
except Exception:
rec = Partner.create({'name': name, 'is_patient': True,
'referred_by': 'DEMO'})
Pacientes.append(rec)
ids['paciente'].append(rec.id)
Employee = env['hr.employee'].sudo()
Empleados = []
for name, stype, comm in DEMO_EMPLOYEES:
vals = {'name': name, 'commission_pct': comm}
if 'staff_type' in Employee._fields:
vals['staff_type'] = stype
if 'notes' in Employee._fields:
vals['notes'] = '[DEMO]'
Empleados.append(Employee.create(vals))
ids['empleado'] = [e.id for e in Empleados]
Cita = env['skeen.cita'].sudo()
hours = [9.0, 10.0, 11.0, 12.0, 13.0, 16.0, 17.0, 18.0]
for i in range(20):
day = week_start + datetime.timedelta(days=i % 6)
serv = Serv[i % len(Serv)]
vals = {
'partner_id': Pacientes[i % len(Pacientes)].id,
'servicio_id': serv.id,
'date': day.isoformat(),
'time': hours[i % len(hours)],
'doctor_id': Empleados[i % len(Empleados)].id,
'state': 'confirmed' if i % 3 else 'pending',
'medium': 'onsite',
}
if 'notes' in Cita._fields:
vals['notes'] = '[DEMO]'
try:
c = Cita.create(vals)
ids['cita'].append(c.id)
except Exception:
report['warnings'].append('demo: no se pudo crear cita %d' % i)
# Paquetes + alguna compra
Paquete = env['skeen.paquete'].sudo()
for i, serv in enumerate(Serv[:3]):
try:
p = Paquete.create({
'name': 'Paquete 5 %s' % serv.name, 'code': 'DEMO-P%02d' % (i + 1),
'service_id': serv.id, 'sessions': 5,
'price': serv.price * 4, 'validity_days': 180,
})
ids['paquete'].append(p.id)
if 'skeen.paquete.compra' in env:
env['skeen.paquete.compra'].sudo().create({
'partner_id': Pacientes[i].id, 'paquete_id': p.id,
'date': today.isoformat(), 'sessions_total': 5,
'price': p.price, 'user_login': 'demo',
})
except Exception as e:
report['warnings'].append('demo: paquete %d: %s' % (i, e))
# Monedero con saldo para algunos pacientes
Monedero = env['skeen.monedero'].sudo()
for i in range(5):
try:
m = Monedero.create({'partner_id': Pacientes[i].id,
'points': (i + 1) * 50})
ids['monedero'].append(m.id)
if 'skeen.monedero.transaction' in env:
env['skeen.monedero.transaction'].sudo().create({
'monedero_id': m.id, 'type': 'accrual',
'points': (i + 1) * 50,
'description': 'Saldo demo', 'operator': 'demo',
})
except Exception as e:
report['warnings'].append('demo: monedero %d: %s' % (i, e))
report['demo'] = ids
report['seeded'].append('datos demo (%s)' % ', '.join(
'%s:%d' % (k, len(v)) for k, v in ids.items()))
def main():
cfg = json.load(open(sys.argv[1], encoding='utf-8'))
report = {'seeded': [], 'skipped': [], 'warnings': [], 'todo': [], 'demo': {}}
odoo, registry = load_env(cfg['conf'], cfg['db'])
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
ICP = env['ir.config_parameter'].sudo()
steps = [
lambda: seed_brand(ICP, cfg, report),
lambda: seed_menu_roles(ICP, cfg, report),
lambda: seed_company(env, cfg, report),
lambda: seed_admin(env, cfg, report),
lambda: seed_glossary(env, ICP, cfg, report),
lambda: seed_clinic_settings(env, cfg, report),
lambda: seed_sucursal(env, cfg, report),
lambda: seed_service_category(env, cfg, report),
]
for step in steps:
try:
step()
except Exception as e:
report['warnings'].append('%s: %s' % (step, e))
if cfg.get('demo'):
try:
seed_demo(env, cfg, report)
if any(report['demo'].values()):
ICP.set_param('skeen.demo.ids',
json.dumps(report['demo'], ensure_ascii=False))
except Exception as e:
report['warnings'].append('demo: %s' % e)
cr.commit()
print('POST-INSTALL OK')
print(json.dumps(report, ensure_ascii=False, indent=2))
if cfg.get('report'):
with open(cfg['report'], 'w', encoding='utf-8') as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
if __name__ == '__main__':
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,582 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Provisiona una instancia white-label nueva de SKEEN en la VPS.
Levanta una clínica/spa completa en un comando sin tocar las instancias que ya
existen: base de datos y rol PostgreSQL propios, secretos propios, conf y unit
systemd, inicialización de los módulos skeen, post-instalación (marca, empresa,
admin, glosario/sucursal/settings si existen), renombrado del menú raíz, vhost
nginx y frontend.
Uso típico:
./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \\
--dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \\
--pack spa --demo --dry-run
Con --dry-run NO se ejecuta nada: solo se validan colisiones (lecturas) y se
imprimen los pasos, el contenido de los archivos y los comandos que correrían.
"""
import argparse
import datetime
import json
import os
import re
import secrets
import shlex
import string
import subprocess
import sys
import tempfile
from string import Template
# ------------------------------------------------------------------ rutas fijas
BASE = os.path.dirname(os.path.abspath(__file__))
TEMPLATES = os.path.join(BASE, 'templates')
ODOO_ROOT = '/root/skeen-odoo'
ODOO_BIN = os.path.join(ODOO_ROOT, 'odoo-bin')
ODOO_PYTHON = '/root/odoo-venv/bin/python'
ADDONS_PATH = '/root/skeen-odoo/addons,/root/skeen-odoo/odoo/addons'
FRONTEND_SRC = '/root/skeen-frontend'
BRAND_SRC_CANDIDATES = [
'/var/www/clinica2-frontend/brand',
'/root/skeen-proyecto/frontend/public/brand',
]
BACKUPS_DIR = '/root/backups'
NGINX_AVAILABLE = '/etc/nginx/sites-available'
NGINX_ENABLED = '/etc/nginx/sites-enabled'
SYSTEMD_DIR = '/etc/systemd/system'
REFERENCE_DB = 'skeen_odoo' # de aquí se toma la lista de módulos skeen
DEFAULT_PACK = 'generico'
SLUG_RE = re.compile(r'^[a-z0-9-]+$')
DB_RE = re.compile(r'^[a-z0-9_]+$')
# ------------------------------------------------------------------ utilidades
def gen_secret(n, alphabet=None):
alphabet = alphabet or (string.ascii_letters + string.digits)
return ''.join(secrets.choice(alphabet) for _ in range(n))
def read_template(name):
with open(os.path.join(TEMPLATES, name), encoding='utf-8') as fh:
return fh.read()
def render(name, ctx):
return Template(read_template(name)).substitute(**ctx)
def sh(cmd, check=False, input_text=None):
"""Ejecuta SIEMPRE (solo para lecturas y para el modo real)."""
p = subprocess.run(cmd, capture_output=True, text=True,
input=input_text, cwd=ODOO_ROOT)
if check and p.returncode != 0:
raise RuntimeError('comando falló (%d): %s\n%s' %
(p.returncode, ' '.join(map(str, cmd)), p.stderr))
return p
def psql(db, sql, tuples_only=True):
flag = '-Atc' if tuples_only else '-c'
return sh(['su', 'postgres', '-c',
'psql -d %s %s %s' % (shlex.quote(db), flag, shlex.quote(sql))])
class Ctx:
"""Ejecutor con modo dry-run y pista de deshacer."""
def __init__(self, dry, force):
self.dry = dry
self.force = force
self.n = 0
self.actions = [] # (descripcion, [comando de limpieza])
self.files = []
def step(self, title):
self.n += 1
print('\n%s\n[%d] %s\n%s' % ('=' * 74, self.n, title, '=' * 74))
def info(self, msg=''):
print(' ' + msg)
def warn(self, msg):
print(' !! ' + msg)
def run(self, cmd, check=True, undo=None, quiet=False, cwd=None):
pretty = ' '.join(shlex.quote(str(c)) for c in cmd)
if self.dry:
prefix = ('(cd %s && ' % cwd) if cwd else ''
suf = ')' if cwd else ''
print(' [dry-run] $ %s%s%s' % (prefix, pretty, suf))
return subprocess.CompletedProcess(cmd, 0, '', '')
if not quiet:
print(' $ %s%s' % (('(cd %s) ' % cwd) if cwd else '', pretty))
p = subprocess.run(cmd, cwd=cwd or ODOO_ROOT)
if check and p.returncode != 0:
raise RuntimeError('comando falló (%d): %s' % (p.returncode, pretty))
if undo:
self.actions.append((undo[0], undo[1]))
return p
def write(self, path, content, mode=None, undo=True):
if self.dry:
print(' [dry-run] escribir %s%s' %
(path, (' (chmod %o)' % mode) if mode else ''))
print(' ' + '-' * 66)
for line in content.splitlines():
print(' | ' + line)
print(' ' + '-' * 66)
else:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as fh:
fh.write(content)
if mode:
os.chmod(path, mode)
print(' escrito %s' % path)
self.files.append(path)
if undo:
self.actions.append(('archivo %s' % path, ['rm', '-f', path]))
# --------------------------------------------------------------------- args
def parse_args(argv=None):
ap = argparse.ArgumentParser(
description='Provisiona una instancia white-label de SKEEN.',
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--slug', required=True,
help='identificador corto (^[a-z0-9-]+$), p.ej. spa-bella')
ap.add_argument('--nombre', required=True, help='nombre comercial')
ap.add_argument('--dominio', required=True,
help='dominio público, p.ej. spa.consultoria-as.com')
ap.add_argument('--puerto', required=True, type=int,
help='puerto HTTP de Odoo; longpolling usa puerto+3')
ap.add_argument('--db', help='nombre de la base (default <slug>_odoo)')
ap.add_argument('--db-role',
help='rol PostgreSQL (default <slug>: rol propio con '
'password nueva; usar "skeen" para el rol compartido)')
ap.add_argument('--pack', default=DEFAULT_PACK,
choices=['generico', 'spa', 'clinica', 'derma'],
help='giro para glosario y datos demo')
ap.add_argument('--demo', action='store_true', help='sembrar datos demo')
ap.add_argument('--demo-limpia', action='store_true',
help='borrar datos demo al final (usa demo_limpiar.py)')
ap.add_argument('--dominio-lan', type=int,
help='puerto LAN extra en el vhost nginx (p.ej. 8881)')
ap.add_argument('--admin-login', default='admin')
ap.add_argument('--pie', help='pie de página de la marca (default genérico)')
ap.add_argument('--salida', help='archivo de credenciales (default en '
'/root/backups/<slug>-credenciales-<fecha>.txt)')
ap.add_argument('--con-wacrm', action='store_true',
help='NO bloquear WACRM (por defecto se bloquea)')
ap.add_argument('--sin-wacrm', action='store_true',
help='bloquear WACRM (comportamiento por defecto)')
ap.add_argument('--skip-build', action='store_true',
help='no recompilar el frontend; reusar /root/skeen-frontend/dist')
ap.add_argument('--force', action='store_true',
help='continuar aunque existan colisiones')
ap.add_argument('--dry-run', action='store_true',
help='imprimir todo sin ejecutar nada')
return ap.parse_args(argv)
# ----------------------------------------------------------- lecturas de entorno
def used_ports():
p = sh(['ss', '-ltnH'])
return set(int(m) for m in re.findall(r':(\d+)\b', p.stdout))
def existing_dbs():
p = psql('postgres', 'SELECT datname FROM pg_database')
return set(l.strip() for l in p.stdout.splitlines() if l.strip())
def existing_roles():
p = psql('postgres', 'SELECT rolname FROM pg_roles')
return set(l.strip() for l in p.stdout.splitlines() if l.strip())
def reference_modules():
"""Lista de módulos skeen instalados en la instancia de referencia,
ordenada topológicamente (dependencias primero)."""
names = psql(REFERENCE_DB,
"SELECT name FROM ir_module_module "
"WHERE state='installed' AND name LIKE 'skeen%'").stdout
mods = [l.strip() for l in names.splitlines() if l.strip()]
if not mods:
return mods
deps_raw = psql(REFERENCE_DB,
"SELECT m.name || ' ' || d.name FROM ir_module_module m "
"JOIN ir_module_module_dependency d ON d.module_id=m.id "
"WHERE m.name LIKE 'skeen%'").stdout
deps = {m: set() for m in mods}
for line in deps_raw.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0] in deps and parts[1] in deps:
deps[parts[0]].add(parts[1])
ordered, seen = [], set()
def visit(m):
if m in seen:
return
seen.add(m)
for d in sorted(deps.get(m, ())):
visit(d)
ordered.append(m)
for m in sorted(mods):
visit(m)
return ordered
def psql_file(ctx, db, sql, kind):
"""Ejecuta SQL como postgres. En dry-run solo lo imprime.
El SQL va por stdin (`-f -`) en vez de un archivo temporal: `su postgres`
pierde privilegios y no podría leer un archivo root-only 0600."""
if ctx.dry:
print(' [dry-run] SQL sobre %s (%s):' % (db, kind))
for line in sql.strip().splitlines():
print(' | ' + line)
return
p = subprocess.run(['su', 'postgres', '-c',
'psql -d %s -v ON_ERROR_STOP=1 -f -' % shlex.quote(db)],
input=sql, capture_output=True, text=True)
if p.returncode != 0:
raise RuntimeError('SQL falló:\n%s' % (p.stdout + p.stderr))
print(' SQL (%s) ok' % kind)
def sql_literal(value):
return "'" + str(value).replace("'", "''") + "'"
ctx = None
# --------------------------------------------------------------------- main
def main(argv=None):
global ctx
args = parse_args(argv)
ctx = Ctx(args.dry_run, args.force)
# ---------------------------------------------------------- derivados
slug = args.slug
db = args.db or (slug.replace('-', '_') + '_odoo')
role = args.db_role or slug.replace('-', '_')
http_port = args.puerto
longpolling_port = http_port + 3
conf_path = '/root/%s-odoo.conf' % slug
unit_name = 'odoo-%s.service' % slug
unit_path = os.path.join(SYSTEMD_DIR, unit_name)
data_dir = '/var/lib/odoo-%s' % slug
log_path = '/tmp/odoo-%s.log' % slug
screenshots_dir = '/tmp/odoo_screenshots_%s' % slug
frontend_dir = '/var/www/%s-frontend' % slug
vhost_avail = os.path.join(NGINX_AVAILABLE, slug)
vhost_enabled = os.path.join(NGINX_ENABLED, slug)
fecha = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
salida = args.salida or os.path.join(
BACKUPS_DIR, '%s-credenciales-%s.txt' % (slug, fecha))
pie = args.pie or 'Sistema de gestión'
print('=' * 74)
print('PROVISIONAMIENTO SKEEN — instancia "%s" (%s)' % (args.nombre, slug))
print('Modo: %s' % ('DRY-RUN (no se ejecuta nada)' if args.dry_run else 'REAL'))
print('=' * 74)
# ------------------------------------------------------- 1. validaciones
ctx.step('Validaciones previas')
errors = []
if not SLUG_RE.match(slug):
errors.append('slug inválido %r: debe cumplir ^[a-z0-9-]+$' % slug)
if not DB_RE.match(db):
errors.append('nombre de DB inválido %r: debe cumplir ^[a-z0-9_]+$' % db)
if not DB_RE.match(role):
errors.append('rol inválido %r' % role)
ports = used_ports()
dbs = existing_dbs()
roles = existing_roles()
collisions = []
if http_port in ports:
collisions.append('puerto %d ya está en uso' % http_port)
if longpolling_port in ports:
collisions.append('puerto longpolling %d ya está en uso' % longpolling_port)
if db in dbs:
collisions.append('la base de datos %r ya existe' % db)
if os.path.exists(unit_path):
collisions.append('el unit %s ya existe' % unit_path)
if os.path.exists(vhost_enabled) or os.path.exists(vhost_avail):
collisions.append('el vhost nginx %s ya existe' % slug)
if os.path.exists(frontend_dir):
collisions.append('el directorio frontend %s ya existe' % frontend_dir)
if os.path.exists(conf_path):
collisions.append('el archivo de conf %s ya existe' % conf_path)
if os.path.exists(data_dir):
collisions.append('el data_dir %s ya existe' % data_dir)
for e in errors:
ctx.warn(e)
for c in collisions:
ctx.warn('COLISIÓN: ' + c)
if role in roles and role != 'skeen':
ctx.warn('el rol PostgreSQL %r ya existe; no se cambiará su password' % role)
if errors or (collisions and not args.force):
print('\nABORTADO. Corrige los errores/colisiones o usa --force.')
return 2
if collisions and args.force:
ctx.warn('--force activo: se continúa pese a las colisiones anteriores.')
ctx.info('slug=%s db=%s rol=%s puerto=%d/%d dominio=%s'
% (slug, db, role, http_port, longpolling_port, args.dominio))
ctx.info('pack=%s demo=%s wacrm=%s'
% (args.pack, args.demo, 'NO' if args.con_wacrm else 'bloqueado'))
# ----------------------------------------------------------- 2. secretos
ctx.step('Secretos propios de la instancia')
admin_passwd = gen_secret(24)
db_password = gen_secret(28)
admin_password = gen_secret(16)
ctx.info('admin_passwd (master Odoo): %s' % admin_passwd)
ctx.info('db_password (rol %s): %s' % (role, db_password))
ctx.info('admin frontend %s: %s' % (args.admin_login, admin_password))
ctx.info('(se guardarán en %s, chmod 600)' % salida)
# ------------------------------------------------------------- 3. DB
ctx.step('Base de datos y rol PostgreSQL')
if role == 'skeen':
ctx.warn('Usando el rol COMPARTIDO "skeen". El db_password generado no '
'coincidirá con la password real del rol; ajusta '
'db_password a mano en %s o usa un rol propio (default).'
% conf_path)
elif role in roles:
ctx.warn('El rol %s ya existe: se reutiliza y NO se le cambia la '
'password. Verifica que db_password en la conf coincida.' % role)
else:
create_role_sql = ("CREATE ROLE %s WITH LOGIN PASSWORD %s;"
% (role, sql_literal(db_password)))
psql_file(ctx, 'postgres', create_role_sql, 'CREATE ROLE %s' % role)
ctx.actions.append(('rol %s' % role,
['su', 'postgres', '-c', 'dropuser %s' % role]))
createdb = ['su', 'postgres', '-c', 'createdb -O %s %s' % (role, db)]
ctx.run(createdb, undo=('base %s' % db,
['su', 'postgres', '-c', 'dropdb %s' % db]))
ctx.run(['mkdir', '-p', data_dir],
undo=('data_dir %s' % data_dir, ['rm', '-rf', data_dir]))
# --------------------------------------------------------- 4. odoo.conf
ctx.step('Configuración de Odoo (%s)' % conf_path)
conf = render('odoo.conf.tmpl', {
'ADDONS_PATH': ADDONS_PATH,
'ADMIN_PASSWD': admin_passwd,
'DATA_DIR': data_dir,
'DB_NAME': db,
'DB_PASSWORD': db_password,
'DB_USER': role,
'HTTP_PORT': http_port,
'LONGPOLLING_PORT': longpolling_port,
'LOG_PATH': log_path,
'SCREENSHOTS_DIR': screenshots_dir,
})
ctx.write(conf_path, conf, mode=0o600)
# ------------------------------------------------------ 5. unit systemd
ctx.step('Unit systemd (%s)' % unit_name)
service = render('service.tmpl', {
'NOMBRE': args.nombre, 'SLUG': slug,
'ODOO_PYTHON': ODOO_PYTHON, 'ODOO_BIN': ODOO_BIN,
'CONF_PATH': conf_path,
})
ctx.write(unit_path, service)
ctx.run(['systemctl', 'daemon-reload'])
ctx.run(['systemctl', 'enable', unit_name],
undo=('unit %s' % unit_name,
['systemctl', 'disable', '--now', unit_name]))
# -------------------------------------------------- 6. init de módulos
ctx.step('Inicialización de módulos skeen')
modules = reference_modules()
if not modules:
ctx.warn('No se pudieron leer los módulos de %s; usando la lista del '
'procedimiento manual.' % REFERENCE_DB)
modules = ['skeen_pacientes', 'skeen_citas', 'skeen_inventario',
'skeen_monedero', 'skeen_pagos', 'skeen_ventas',
'skeen_whatsapp', 'skeen_chat', 'skeen_visitas']
ctx.info('Módulos (orden topológico, tomados de %s): %s'
% (REFERENCE_DB, ','.join(modules)))
init_cmd = [ODOO_PYTHON, ODOO_BIN, '-c', conf_path, '-d', db,
'--without-demo=all', '--stop-after-init', '--no-http',
'-i', ','.join(modules), '--log-level=warn']
ctx.run(init_cmd)
ctx.info('(la service no se arranca hasta después del post-install)')
# ------------------------------------------------- 7. post-instalación
ctx.step('Post-instalación (marca, empresa, admin, config v2)%s'
% (', demo' if args.demo else ''))
report_path = os.path.join(tempfile.gettempdir(),
'postinstall-%s.json' % slug)
cfg_post = {
'conf': conf_path, 'db': db, 'nombre': args.nombre,
'dominio': args.dominio, 'pack': args.pack,
'admin_login': args.admin_login, 'admin_password': admin_password,
'con_wacrm': args.con_wacrm, 'demo': args.demo, 'pie': pie,
'report': report_path,
}
cfg_path = os.path.join(tempfile.gettempdir(), 'postinstall-%s.cfg.json' % slug)
ctx.write(cfg_path, json.dumps(cfg_post, ensure_ascii=False, indent=2),
mode=0o600)
helper = os.path.join(BASE, 'postinstall_clinica.py')
ctx.run([ODOO_PYTHON, helper, cfg_path])
if not ctx.dry and os.path.exists(report_path):
try:
with open(report_path, encoding='utf-8') as fh:
rep = json.load(fh)
for k in ('seeded', 'skipped', 'warnings', 'todo'):
for item in rep.get(k, []):
ctx.info('%-9s %s' % (k + ':', item))
except Exception as e:
ctx.warn('no se pudo leer el reporte de post-install: %s' % e)
else:
ctx.info('(el post-install reporta qué sembró y deja TODO para lo que '
'el modelo del nuevo config aún no exista)')
# --------------------------------------------- 8. renombrar menú raíz
ctx.step('Renombrar el menú raíz (%s)' % args.nombre)
menu_sql = (
"UPDATE ir_ui_menu SET name = jsonb_build_object('en_US', %s)\n"
"WHERE id = (SELECT res_id FROM ir_model_data\n"
" WHERE module='skeen_pacientes' AND name='menu_skeen_root'\n"
" AND model='ir.ui.menu');"
% sql_literal(args.nombre))
psql_file(ctx, db, menu_sql, 'menu_skeen_root -> %s' % args.nombre)
# ---------------------------------------------------- 9. vhost nginx
ctx.step('Vhost nginx (%s)' % slug)
lan_listen = ' listen %d;\n' % args.dominio_lan if args.dominio_lan else ''
lan_comment = ('# Acceso LAN: http://<ip-de-la-vps>:%d' % args.dominio_lan
if args.dominio_lan else '')
wacrm_block = '' if args.con_wacrm else (
"\n # Instancia sin WhatsApp: se corta la ruta WACRM aunque el\n"
" # backend tenga fallback. Quitar este bloque al activar WhatsApp.\n"
" location /api/odoo/skeen/frontend/v1/wacrm/ {\n"
" return 403;\n"
" }")
vhost = render('nginx-vhost.tmpl', {
'NOMBRE': args.nombre, 'SLUG': slug, 'DOMINIO': args.dominio,
'HTTP_PORT': http_port, 'FRONTEND_DIR': frontend_dir,
'LAN_LISTEN': lan_listen, 'LAN_COMMENT': lan_comment,
'LAN_PORT_OR_EMPTY': args.dominio_lan or '',
'WACRM_BLOCK': wacrm_block,
})
ctx.write(vhost_avail, vhost)
if ctx.dry:
print(' [dry-run] $ ln -s %s %s' % (vhost_avail, vhost_enabled))
else:
if os.path.lexists(vhost_enabled):
os.unlink(vhost_enabled)
os.symlink(vhost_avail, vhost_enabled)
print(' symlink %s -> %s' % (vhost_enabled, vhost_avail))
ctx.actions.append(('vhost %s' % slug,
['rm', '-f', vhost_enabled, vhost_avail]))
ctx.run(['nginx', '-t'])
ctx.run(['systemctl', 'reload', 'nginx'])
# ------------------------------------------------------- 10. frontend
ctx.step('Frontend React (%s)' % frontend_dir)
if args.skip_build:
ctx.info('--skip-build: se reutiliza %s/dist' % FRONTEND_SRC)
else:
ctx.info('Build compartido (un solo bundle sirve a todas las instancias; '
'la marca sale del API /brand). Se compila una vez desde %s.'
% FRONTEND_SRC)
ctx.run(['npm', 'run', 'build'], cwd=FRONTEND_SRC)
ctx.run(['mkdir', '-p', frontend_dir],
undo=('frontend %s' % frontend_dir, ['rm', '-rf', frontend_dir]))
ctx.run(['rsync', '-a', '--delete', FRONTEND_SRC + '/dist/', frontend_dir + '/'])
brand_src = next((p for p in BRAND_SRC_CANDIDATES if os.path.isdir(p)),
BRAND_SRC_CANDIDATES[-1])
ctx.info('Logos genéricos desde %s' % brand_src)
ctx.run(['mkdir', '-p', os.path.join(frontend_dir, 'brand')])
ctx.run(['cp', '-f'] + [os.path.join(brand_src, f)
for f in ('logo-blanco.svg', 'logo-negro.svg',
'favicon.svg')]
+ [os.path.join(frontend_dir, 'brand') + '/'])
ctx.run(['chown', '-R', 'www-data:www-data', frontend_dir])
# ------------------------------------------------- 11. arrancar service
ctx.step('Arranque de la instancia')
ctx.run(['systemctl', 'start', unit_name],
undo=('service %s' % unit_name,
['systemctl', 'stop', unit_name]))
ctx.run(['systemctl', 'is-active', unit_name], check=False)
# ------------------------------------------------- demo-limpia opcional
if args.demo_limpia:
ctx.step('Limpieza de datos demo')
ctx.run([ODOO_PYTHON, os.path.join(BASE, 'demo_limpiar.py'),
'--conf', conf_path, '--db', db])
# ------------------------------------------------------- 12. credenciales
ctx.step('Credenciales y resumen')
creds = (
'Instancia: %s (%s)\n'
'Generado: %s\n'
'Dominio: https://%s\n'
'Odoo HTTP: http://127.0.0.1:%d (longpolling %d)\n'
'Base de datos: %s\n'
'Rol PostgreSQL: %s\n'
'Conf: %s\n'
'Unit: %s\n'
'Frontend: %s\n'
'\n'
'Odoo master password (admin_passwd): %s\n'
'Frontend admin: %s / %s (cambio obligatorio al primer login)\n'
% (args.nombre, slug, datetime.datetime.now().isoformat(timespec='seconds'),
args.dominio, http_port, longpolling_port, db, role, conf_path,
unit_name, frontend_dir, admin_passwd, args.admin_login, admin_password)
)
if ctx.dry:
print(' [dry-run] escribir %s (chmod 600):' % salida)
for line in creds.splitlines():
print(' | ' + line)
else:
os.makedirs(BACKUPS_DIR, exist_ok=True)
with open(salida, 'w', encoding='utf-8') as fh:
fh.write(creds)
os.chmod(salida, 0o600)
print(' credenciales guardadas en %s (chmod 600)' % salida)
print('\n%s' % ('-' * 74))
print('RESUMEN — %s' % ('dry-run: nada se ejecutó' if ctx.dry else 'OK'))
print('-' * 74)
print(' Logs: tail -f %s' % log_path)
print(' Estado: systemctl status %s' % unit_name)
print(' Health: curl http://127.0.0.1:%d/web/health' % http_port)
print(' LAN: http://<ip-vps>:%s' % (args.dominio_lan or '-'))
print(' PÚBLICO: agregar ingress en el dashboard de cloudflared:')
print(' hostname %s -> service http://localhost:80' % args.dominio)
if args.demo and not args.demo_limpia:
print(' Demo: borrar con %s/demo_limpiar.py --conf %s --db %s'
% (BASE, conf_path, db))
print('-' * 74)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
print('\nInterrumpido.')
sys.exit(130)
except Exception as e:
print('\n' + '!' * 74)
print('FALLÓ: %s' % e)
print('!' * 74)
print('Estado parcial (limpia a mano si lo necesitas):')
if ctx is not None:
for desc, undo in ctx.actions:
print(' - %s -> %s' % (desc, ' '.join(map(str, undo))))
else:
print(' - aún no se ejecutó ningún paso')
sys.exit(1)

View File

@@ -0,0 +1,39 @@
# Instancia ${NOMBRE} (${SLUG}) — frontend React + API Odoo en puerto ${HTTP_PORT}.
# Acceso público: ${DOMINIO} (agregar el ingress en el dashboard del túnel
# cloudflared: hostname ${DOMINIO} -> service http://localhost:80).
${LAN_COMMENT}
server {
listen 80;
${LAN_LISTEN}
server_name ${DOMINIO};
# Frontend React (mismo build que SKEEN; la marca sale del API /brand)
location / {
root ${FRONTEND_DIR};
try_files $$uri $$uri/ /index.html;
add_header Cache-Control "no-cache";
}
# API Odoo para el frontend
location /api/odoo/ {
proxy_pass http://127.0.0.1:${HTTP_PORT}/;
proxy_http_version 1.1;
proxy_set_header Host $$host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_read_timeout 120s;
}
# Backend Odoo (administración directa, opcional)
location /web/ {
proxy_pass http://127.0.0.1:${HTTP_PORT}/web/;
proxy_http_version 1.1;
proxy_set_header Host $$host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_read_timeout 120s;
}
${WACRM_BLOCK}
}

View File

@@ -0,0 +1,44 @@
[options]
addons_path = ${ADDONS_PATH}
admin_passwd = ${ADMIN_PASSWD}
csv_internal_sep = ,
data_dir = ${DATA_DIR}
db_host = localhost
db_maxconn = 64
db_name = ${DB_NAME}
db_password = ${DB_PASSWORD}
db_port = 5432
db_sslmode = prefer
db_user = ${DB_USER}
demo = {}
email_from = False
http_enable = True
http_interface =
http_port = ${HTTP_PORT}
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_request = 8192
limit_time_cpu = 60
limit_time_real = 120
limit_time_real_cron = -1
list_db = True
log_db = False
log_db_level = warning
log_handler = :INFO
log_level = info
logfile = ${LOG_PATH}
longpolling_port = ${LONGPOLLING_PORT}
max_cron_threads = 2
pg_path =
pidfile =
proxy_mode = True
reportgz = False
screenshots = ${SCREENSHOTS_DIR}
server_wide_modules = base,web
smtp_port = 25
smtp_server = localhost
test_tags = None
translate_modules = ['all']
unaccent = False
without_demo = all
workers = 0

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Odoo ${NOMBRE} (${SLUG}) — skeen-odoo venv, instancia white-label
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/skeen-odoo
# Instancia aislada: sin SUPABASE_URL / WACRM_API_* / HERMES_BRIDGE_URL.
# Los endpoints WACRM quedan sin credenciales (escenario B) y el vhost nginx
# corta la ruta con 403.
ExecStart=${ODOO_PYTHON} ${ODOO_BIN} -c ${CONF_PATH}
Restart=always
RestartSec=5
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target