# -*- coding: utf-8 -*- import string import random import logging import threading from odoo import models, fields, api, registry, SUPERUSER_ID from odoo.exceptions import UserError from odoo.service import db from odoo.tools import config from odoo.modules.registry import Registry _logger = logging.getLogger(__name__) def _async_provisioning_worker(parent_db, saas_res_id, db_name, modules, company_vals, admin_vals, timezone, admin_pass): _logger.info(f"Starting async provisioning background thread for database: {db_name}") try: # 1. Run module installation via Odoo CLI subprocess to avoid registry/cache pollution import subprocess # Remove 'base' from modules list if present since exp_create_database already installs it cli_modules = [m for m in modules if m != 'base'] cmd = ["odoo", "-i", ",".join(cli_modules), "-d", db_name, "--db_host=db", "-r", "odoo", "-w", "odoo", "--stop-after-init"] _logger.info(f"Executing background CLI installation: {' '.join(cmd)}") result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if result.returncode != 0: raise Exception(f"Odoo CLI installation failed (exit code {result.returncode}): {result.stderr or result.stdout}") _logger.info(f"Background thread: Base modules installed on {db_name}. Configuring company & admin...") # 2. Configure Company and Admin User inside the new database tenant_registry = registry(db_name) with tenant_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) # Company setup company = env['res.company'].search([], limit=1) company.write(company_vals) # Admin user setup (ID 2 is usually admin) admin_user = env['res.users'].browse(2) admin_user.write(admin_vals) # Set timezone admin_user.partner_id.tz = timezone or 'America/New_York' # Force Odoo registry signaling update env.registry.registry_invalidated = True env.registry.signal_changes() cr.commit() # Force a registry reload locally in memory to sync the active process immediately _logger.info(f"Background thread: Forcing local registry reload in memory for {db_name}...") Registry.new(db_name) _logger.info(f"Background thread: Configuration completed for {db_name}. Activating SaaS Master record...") # 3. Update SaaS Master record status and send Welcome Email parent_registry = registry(parent_db) with parent_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) saas_record = env['saas.restaurant'].browse(saas_res_id) saas_record.write({'status': 'active'}) # Send Welcome Email template = env.ref('dine360_saas_master.saas_welcome_email_template', raise_if_not_found=False) if template: saas_record.with_context(temp_password=admin_pass).message_post_with_source( source_ref=template, subtype_xmlid='mail.mt_comment' ) # Send real-time bus notification to the backend UI to refresh the form view try: env['bus.bus']._sendone('saas_provisioning', 'saas_provisioning', { 'saas_record_id': saas_record.id, 'status': 'active' }) except Exception as bus_err: _logger.error(f"Failed to send success bus notification: {str(bus_err)}") cr.commit() _logger.info(f"Background thread: Async provisioning finished successfully for {db_name}!") except Exception as e: _logger.error(f"Background thread: Provisioning failed for database {db_name}: {str(e)}") # Drop the corrupted database shell try: db.exp_drop(db_name) except Exception as drop_err: _logger.error(f"Background thread: Failed to drop database {db_name} on cleanup: {str(drop_err)}") # Update SaaS record to failed try: parent_registry = registry(parent_db) with parent_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) saas_record = env['saas.restaurant'].browse(saas_res_id) saas_record.write({'status': 'failed'}) saas_record.message_post(body=f"Failed background database provisioning: {str(e)}") # Send real-time bus notification to the backend UI to refresh the form view try: env['bus.bus']._sendone('saas_provisioning', 'saas_provisioning', { 'saas_record_id': saas_record.id, 'status': 'failed' }) except Exception as bus_err: _logger.error(f"Failed to send failure bus notification: {str(bus_err)}") cr.commit() except Exception as update_err: _logger.error(f"Background thread: Failed to mark status as failed on SaaS record: {str(update_err)}") class SaasRestaurant(models.Model): _name = 'saas.restaurant' _inherit = ['mail.thread', 'mail.activity.mixin'] _description = 'Dine360 SaaS Restaurant Tenant' name = fields.Char(string='Restaurant Name', required=True, tracking=True) owner_name = fields.Char(string='Owner Name', required=True, tracking=True) email = fields.Char(string='Owner Email', required=True, tracking=True) phone = fields.Char(string='Phone') street = fields.Char(string='Street') city = fields.Char(string='City') country_id = fields.Many2one('res.country', string='Country') plan_id = fields.Many2one('saas.plan', string='Subscription Plan', required=True, tracking=True) billing_cycle = fields.Selection([ ('monthly', 'Monthly Billing'), ('yearly', 'Annual Billing') ], string='Billing Cycle', default='monthly', required=True) database_name = fields.Char(string='PostgreSQL DB Name', readonly=True, copy=False) subdomain = fields.Char(string='Subdomain', readonly=True, copy=False) status = fields.Selection([ ('draft', 'Draft'), ('provisioning', 'Provisioning'), ('active', 'Active'), ('suspended', 'Suspended'), ('expired', 'Expired'), ('failed', 'Failed') ], string='Status', default='draft', required=True, tracking=True) start_date = fields.Date(string='Start Date', default=fields.Date.context_today) expiry_date = fields.Date(string='Expiry Date', required=True) currency_id = fields.Many2one('res.currency', string='Base Currency') timezone = fields.Char(string='Timezone', default='America/New_York') logo = fields.Binary(string='Restaurant Logo') activate_table_reservation = fields.Boolean( string='Activate Table Reservation Theme', default=False, help='If checked, automatically installs and configures the themed Table Reservation module on this restaurant database.' ) _sql_constraints = [ ('unique_subdomain', 'unique(subdomain)', 'This subdomain is already taken!'), ('unique_database_name', 'unique(database_name)', 'This database name is already registered!') ] @api.model def create(self, vals): # Generate subdomain and database name from restaurant name name_slug = "".join([c.lower() for c in vals.get('name', '') if c.isalnum()]) if not name_slug: name_slug = "restaurant" counter = 1 original_slug = name_slug while self.search([('subdomain', '=', f"{name_slug}.dine360.com")]): name_slug = f"{original_slug}{counter}" counter += 1 vals['subdomain'] = f"{name_slug}.dine360.com" vals['database_name'] = f"dine360_restaurant_{name_slug}" return super(SaasRestaurant, self).create(vals) def action_create_database(self): self.ensure_one() if self.database_name in db.list_dbs(): raise UserError(f"Database {self.database_name} already exists in PostgreSQL!") # 1. Provision PostgreSQL database admin_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=12)) try: _logger.info(f"Creating database {self.database_name}...") db.exp_create_database( self.database_name, False, 'en_US', user_password=admin_pass, login=self.email, country_code=self.country_id.code if self.country_id else None, phone=self.phone ) _logger.info(f"Database {self.database_name} created successfully.") except Exception as e: raise UserError(f"PostgreSQL database creation failed: {str(e)}") # 2. Extract configuration values company_vals = { 'name': self.name, 'phone': self.phone, 'email': self.email, 'street': self.street, 'city': self.city, 'country_id': self.country_id.id if self.country_id else False, 'currency_id': self.currency_id.id if self.currency_id else False, 'logo': self.logo, } admin_vals = { 'name': self.owner_name, 'login': self.email, 'email': self.email, 'password': admin_pass, } modules_to_install = [ 'base', 'contacts', 'sale', 'stock', 'purchase', 'account', 'point_of_sale', 'hr', 'pos_restaurant' ] if self.activate_table_reservation: modules_to_install.extend(['website', 'dine360_table', 'dine360_reservation', 'dine360_theme_reservation']) # Set status to provisioning self.status = 'provisioning' # 3. Spawn background worker thread parent_db = self.env.cr.dbname t = threading.Thread( target=_async_provisioning_worker, args=(parent_db, self.id, self.database_name, modules_to_install, company_vals, admin_vals, self.timezone, admin_pass), daemon=True ) t.start() return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Database Provisioning Started', 'message': 'Your database creation has started in the background. Please wait 1-2 minutes for completion before logging in.', 'sticky': False, 'next': {'type': 'ir.actions.client', 'tag': 'reload'}, } } def action_suspend(self): self.ensure_one() # Suspend by disabling login in target database tenant_registry = registry(self.database_name) try: with tenant_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) # Disable all users except superuser users = env['res.users'].search([('id', '!=', SUPERUSER_ID)]) users.write({'active': False}) cr.commit() self.status = 'suspended' except Exception as e: raise UserError(f"Failed to suspend database users: {str(e)}") def action_activate(self): self.ensure_one() # Activate by re-enabling login in target database tenant_registry = registry(self.database_name) try: with tenant_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) # Enable all users users = env['res.users'].search([('active', '=', False), ('id', '!=', SUPERUSER_ID)]) users.write({'active': True}) cr.commit() self.status = 'active' except Exception as e: raise UserError(f"Failed to activate database users: {str(e)}") @api.model def cron_check_subscriptions(self): """ Runs nightly to find expired accounts and enforce subscription plan limits """ today = fields.Date.context_today(self) expired_tenants = self.search([('expiry_date', '<', today), ('status', '=', 'active')]) for tenant in expired_tenants: tenant.status = 'expired' tenant.action_suspend() # Enforcement check for limits active_tenants = self.search([('status', '=', 'active')]) for tenant in active_tenants: plan = tenant.plan_id tenant_registry = registry(tenant.database_name) try: with tenant_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) # 1. Count active users user_count = env['res.users'].search_count([('active', '=', True), ('id', '!=', SUPERUSER_ID)]) # 2. Count active POS terminals pos_count = env['pos.config'].search_count([('active', '=', True)]) # Log warnings/limits if plan.max_users > 0 and user_count > plan.max_users: _logger.warning(f"Tenant {tenant.name} exceeds user limit: {user_count}/{plan.max_users}") # Auto suspend or flag if plan.max_pos > 0 and pos_count > plan.max_pos: _logger.warning(f"Tenant {tenant.name} exceeds POS limit: {pos_count}/{plan.max_pos}") except Exception as e: _logger.error(f"Error checking limits for {tenant.database_name}: {str(e)}")