- Introduced a new module for customizing the Table Reservation theme with primary and secondary branding colors. - Updated the __manifest__.py to include dependencies and module details. - Created models for website settings to manage theme colors. - Added views for configuration settings and menu items for easy access. - Enhanced reservation templates to apply custom styles dynamically. - Implemented a verification script to check if the theme is applied correctly. - Updated docker-compose.yml to change the exposed port for the Odoo service.
293 lines
13 KiB
Python
293 lines
13 KiB
Python
# -*- 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'
|
|
)
|
|
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)}")
|
|
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.act_window_close'},
|
|
}
|
|
}
|
|
|
|
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)}")
|