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:
51
infra/provision/README.md
Normal file
51
infra/provision/README.md
Normal 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
89
infra/provision/demo_limpiar.py
Executable 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)
|
||||
445
infra/provision/postinstall_clinica.py
Executable file
445
infra/provision/postinstall_clinica.py
Executable 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)
|
||||
582
infra/provision/provision_clinica.py
Executable file
582
infra/provision/provision_clinica.py
Executable 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)
|
||||
39
infra/provision/templates/nginx-vhost.tmpl
Normal file
39
infra/provision/templates/nginx-vhost.tmpl
Normal 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}
|
||||
}
|
||||
44
infra/provision/templates/odoo.conf.tmpl
Normal file
44
infra/provision/templates/odoo.conf.tmpl
Normal 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
|
||||
19
infra/provision/templates/service.tmpl
Normal file
19
infra/provision/templates/service.tmpl
Normal 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
|
||||
Reference in New Issue
Block a user