# -*- 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"] log_file_path = f"/tmp/provision_{db_name}.log" with open(log_file_path, "w") as log_file: result = subprocess.run(cmd, stdout=log_file, stderr=subprocess.STDOUT, text=True) if result.returncode != 0: tail_msg = "" try: with open(log_file_path, "r") as log_file: tail_msg = log_file.read()[-2000:] except Exception: pass raise Exception(f"Odoo CLI installation failed (exit code {result.returncode}): {tail_msg}") _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 (safe update only non-empty fields) company = env['res.company'].search([], limit=1) if company and company_vals: cleaned_company_vals = {k: v for k, v in company_vals.items() if v is not False and v is not None and v != ''} if cleaned_company_vals: company.write(cleaned_company_vals) # Admin user setup (ID 2 is usually admin) admin_user = env['res.users'].browse(2) if admin_user.exists() and admin_vals: cleaned_admin_vals = {k: v for k, v in admin_vals.items() if v is not False and v is not None and v != ''} if cleaned_admin_vals: admin_user.write(cleaned_admin_vals) # Set timezone if timezone and admin_user.exists() and admin_user.partner_id: admin_user.partner_id.tz = timezone or 'America/New_York' # Ensure Restaurant POS Configuration is enabled if 'pos.config' in env: for p_cfg in env['pos.config'].search([]): vals_to_write = {} if hasattr(p_cfg, 'module_pos_restaurant') and not p_cfg.module_pos_restaurant: vals_to_write['module_pos_restaurant'] = True if hasattr(p_cfg, 'online_orders_enabled') and not p_cfg.online_orders_enabled: vals_to_write['online_orders_enabled'] = True if hasattr(p_cfg, 'use_qz_printer') and not p_cfg.use_qz_printer: vals_to_write['use_qz_printer'] = True if vals_to_write: p_cfg.write(vals_to_write) # Ensure default draft Uber config exists if uber module is installed if 'uber.config' in env: if not env['uber.config'].search([], limit=1): env['uber.config'].create({ 'name': f"{company.name if company else 'Restaurant'} Uber Eats / Direct", 'client_id': 'SANDBOX_CLIENT_ID', 'client_secret': 'SANDBOX_CLIENT_SECRET', 'environment': 'sandbox', }) # Ensure Point of Sale backend menu is hidden in tenant restaurant pos_menu = env.ref('point_of_sale.menu_point_root', raise_if_not_found=False) if pos_menu: pos_menu.write({'active': False}) # 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') # Onboarding Features for Restaurant Tenant feature_online_orders = fields.Boolean( string='Online Orders (POS & Web)', default=True, help='Installs Dine360 Online Orders with POS integration and KDS.' ) feature_table_reservation = fields.Boolean( string='Table Reservation & Theme', default=True, help='Installs Dine360 Table Reservation and Theme Customizer.' ) feature_uber_integration = fields.Boolean( string='Uber Integration (Eats & Direct)', default=True, help='Installs Dine360 Uber Integration for Eats and Direct delivery.' ) feature_dashboard = fields.Boolean( string='Modern Unified Dashboard', default=True, help='Installs Dine360 Modern Dashboard with unified navigation and module pages.' ) feature_qz_printer = fields.Boolean( string='QZ Tray Thermal Printer', default=True, help='Installs Dine360 QZ Printer module for Star/Epson direct thermal receipt printing.' ) activate_table_reservation = fields.Boolean( string='Activate Table Reservation Theme', compute='_compute_activate_table_reservation', inverse='_inverse_activate_table_reservation', store=True, help='Legacy flag mapped to Table Reservation.' ) @api.depends('feature_table_reservation') def _compute_activate_table_reservation(self): for rec in self: rec.activate_table_reservation = rec.feature_table_reservation def _inverse_activate_table_reservation(self): for rec in self: rec.feature_table_reservation = rec.activate_table_reservation _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!\n" f"Please click 'Drop Database' to remove the existing leftover database, or choose a different Subdomain." ) # 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, } # Clean values to avoid DB constraint failures (such as null currency_id) company_vals = {k: v for k, v in company_vals.items() if v is not False and v is not None and v != ''} admin_vals = {k: v for k, v in admin_vals.items() if v is not False and v is not None and v != ''} # Only install requested onboard features and their dependencies modules_to_install = [] if self.feature_online_orders: modules_to_install.append('dine360_online_orders') if self.feature_table_reservation: modules_to_install.append('dine360_theme_reservation') if self.feature_uber_integration: modules_to_install.append('dine360_uber') if self.feature_dashboard: modules_to_install.append('dine360_dashboard') if self.feature_qz_printer: modules_to_install.append('dine360_qz_printer') if not modules_to_install: modules_to_install = [ 'dine360_online_orders', 'dine360_theme_reservation', 'dine360_uber', 'dine360_dashboard', 'dine360_qz_printer' ] # 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_drop_database(self): """ Drop the tenant database from PostgreSQL """ self.ensure_one() if self.database_name and self.database_name in db.list_dbs(): try: db.exp_drop(self.database_name) _logger.info(f"Database {self.database_name} dropped by user action.") except Exception as e: raise UserError(f"Failed to drop database from PostgreSQL: {str(e)}") self.status = 'draft' return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Database Removed', 'message': f'PostgreSQL database {self.database_name} has been dropped.', 'sticky': False, 'next': {'type': 'ir.actions.client', 'tag': 'reload'}, } } def unlink(self): """ Drop the PostgreSQL database when a restaurant record is deleted """ for record in self: if record.database_name and record.database_name in db.list_dbs(): try: _logger.info(f"Dropping tenant database {record.database_name} on record deletion...") db.exp_drop(record.database_name) except Exception as e: _logger.warning(f"Failed to drop database {record.database_name} during unlink: {str(e)}") return super(SaasRestaurant, self).unlink() 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)}")