# -*- coding: utf-8 -*- import logging import re import secrets import string import subprocess import odoo from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class SaasDatabase(models.Model): """Flow 2: Database & Instance Provisioning Flow System Generates Unique Database Name -> Create PostgreSQL Database (schema, user setup, permissions) -> Install Selected App Modules (website, crm, accounting, ...) -> Configure System Instance (workers, timeout, memory limits) -> Generate Admin Credentials & Send Email -> Instance Ready! (30-60 seconds) Also documents the routing pipeline: 1. User Request -> company1.domain-name.com 2. DNS Resolution -> Load Balancer IP 3. Load Balancer -> Route to available Worker 4. Worker -> Read database name from subdomain 5. Database Router -> Connect to correct tenant database 6. Process Request -> Return response """ _name = 'saas.database' _description = 'SaaS Tenant Database / Instance' _inherit = ['mail.thread'] _order = 'create_date desc' trial_request_id = fields.Many2one('saas.trial.request', required=True, ondelete='cascade') company_name = fields.Char(required=True) app_ids = fields.Many2many('saas.app', string='Apps to Install') # ---- Generated Database Name ---- db_name = fields.Char('Database Name', copy=False, readonly=True, tracking=True) subdomain = fields.Char('Subdomain (FQDN)', copy=False, readonly=True, tracking=True) # ---- Instance configuration ---- worker_count = fields.Integer(default=2) timeout_seconds = fields.Integer(default=120) memory_limit_mb = fields.Integer(default=512) worker_node = fields.Char('Assigned Worker Node', readonly=True) # ---- Admin credentials ---- admin_login = fields.Char(readonly=True, copy=False) admin_password = fields.Char(readonly=True, copy=False) # store hashed/secret-managed in real prod state = fields.Selection([ ('draft', 'Draft'), ('name_generated', 'DB Name Generated'), ('db_created', 'PostgreSQL Database Created'), ('modules_installed', 'Apps Installed'), ('configured', 'Instance Configured'), ('credentials_sent', 'Admin Credentials Sent'), ('ready', 'Instance Ready'), ('error', 'Provisioning Error'), ], default='draft', tracking=True) error_message = fields.Text() provisioning_seconds = fields.Float('Provisioning Duration (s)', readonly=True) # ------------------------------------------------------------------ # MAIN ENTRY POINT - runs the whole Flow 2 pipeline # ------------------------------------------------------------------ def action_provision(self): self.ensure_one() start = fields.Datetime.now() try: self._generate_unique_db_name() self._create_postgres_database() self._install_selected_modules() self._configure_instance() self._generate_admin_credentials() self._send_credentials_email() self.state = 'ready' self.trial_request_id.state = 'ready' except Exception as e: _logger.exception("Provisioning failed for %s", self.company_name) self.write({'state': 'error', 'error_message': str(e)}) self.trial_request_id.state = 'failed' raise UserError(_("Provisioning failed: %s") % e) finally: end = fields.Datetime.now() self.provisioning_seconds = (end - start).total_seconds() return True # ------------------------------------------------------------------ # Step 1: System Generates Unique Database Name # e.g. company-name12345.domain-name.com # ------------------------------------------------------------------ def _generate_unique_db_name(self): self.ensure_one() base_slug = re.sub(r'[^a-z0-9]+', '-', self.company_name.lower()).strip('-') or 'tenant' suffix = ''.join(secrets.choice(string.digits) for _ in range(5)) candidate = f"{base_slug}{suffix}" # Ensure global uniqueness against existing tenant records while self.search_count([('db_name', '=', candidate)]): suffix = ''.join(secrets.choice(string.digits) for _ in range(5)) candidate = f"{base_slug}{suffix}" main_domain = self.env['ir.config_parameter'].sudo().get_param( 'saas_trial_portal.main_domain', default='domain-name.com' ) self.write({ 'db_name': candidate, 'subdomain': f"{candidate}.{main_domain}", 'state': 'name_generated', }) # ------------------------------------------------------------------ # Step 2: Create PostgreSQL Database (schema, user setup, permissions) # ------------------------------------------------------------------ def _create_postgres_database(self): """Uses Odoo's own database service to create + initialize a new tenant database. Equivalent to running: createdb -O odoo-bin -d -i base --stop-after-init but done in-process via odoo.service.db so it integrates with the running Odoo instance (same approach used by odoo.com signup flow and the /web/database/manager screens). """ self.ensure_one() db_name = self.db_name # Security / permissions: dedicated low-privilege role per tenant # could be created here via a raw psycopg2 connection to "postgres" # maintenance DB if your infra requires per-tenant DB roles: # # import psycopg2 # conn = psycopg2.connect(dbname='postgres') # conn.autocommit = True # cur = conn.cursor() # cur.execute(f'CREATE ROLE "{db_name}_role" LOGIN PASSWORD %s', [secrets.token_urlsafe(16)]) # cur.execute(f'CREATE DATABASE "{db_name}" OWNER "{db_name}_role"') # # For a single shared DB-user setup (default Odoo install), we let # Odoo's db service create+initialize the database directly: try: from odoo.service import db as db_service db_service.exp_create_database( db_name, demo=False, lang='en_US', user_password=None, # set after init, see _generate_admin_credentials login=None, country_code=None, phone=None, ) except Exception as e: raise UserError(_("Could not create PostgreSQL database '%s': %s") % (db_name, e)) self.state = 'db_created' # ------------------------------------------------------------------ # Step 3: Install Selected App Modules (website, crm, accounting...) # ------------------------------------------------------------------ def _install_selected_modules(self): self.ensure_one() module_names = self.app_ids.mapped('technical_module_name') if not module_names: self.state = 'modules_installed' return # Open a registry/cursor on the NEW tenant database and install modules registry = odoo.modules.registry.Registry.new(self.db_name, update_module=True) with registry.cursor() as cr: env = api.Environment(cr, odoo.SUPERUSER_ID, {}) modules = env['ir.module.module'].search([ ('name', 'in', module_names), ('state', '=', 'uninstalled'), ]) if modules: modules.button_immediate_install() cr.commit() self.state = 'modules_installed' # ------------------------------------------------------------------ # Step 4: Configure System Instance (workers, timeout, memory limits) # ------------------------------------------------------------------ def _configure_instance(self): """In a multi-worker / multi-tenant deployment this writes the per-tenant resource limits to your orchestration layer (e.g. a Kubernetes ConfigMap, systemd template unit, or a row in a 'workers pool' table read by your reverse proxy / load balancer). Here we persist it on the record and on ir.config_parameter so the Database Router (see routing pipeline in class docstring) can read it. """ self.ensure_one() self.write({ 'worker_node': self._assign_worker_node(), }) self.state = 'configured' def _assign_worker_node(self): """Simplified round-robin assignment across configured worker node names. Replace with real load-balancer / k8s scheduler call. """ nodes_param = self.env['ir.config_parameter'].sudo().get_param( 'saas_trial_portal.worker_nodes', default='worker-1,worker-2,worker-3' ) nodes = [n.strip() for n in nodes_param.split(',') if n.strip()] count = self.search_count([('worker_node', '!=', False)]) return nodes[count % len(nodes)] if nodes else 'worker-1' # ------------------------------------------------------------------ # Step 5: Generate Admin Credentials & Send Email # ------------------------------------------------------------------ def _generate_admin_credentials(self): self.ensure_one() password = secrets.token_urlsafe(12) login = self.trial_request_id.email registry = odoo.modules.registry.Registry.new(self.db_name) with registry.cursor() as cr: env = api.Environment(cr, odoo.SUPERUSER_ID, {}) admin_user = env.ref('base.user_admin', raise_if_not_found=False) if admin_user: admin_user.write({'login': login, 'name': self.trial_request_id.name}) admin_user.sudo().write({'password': password}) cr.commit() self.write({'admin_login': login, 'admin_password': password}) def _send_credentials_email(self): self.ensure_one() template = self.env.ref('saas_trial_portal.mail_template_instance_ready') template.send_mail(self.id, force_send=True) self.state = 'credentials_sent'