- Introduced `saas.plan` model to define subscription plans with limits and pricing. - Created `saas.restaurant` model to manage restaurant tenants, including database provisioning and subscription management. - Implemented views for managing SaaS plans and restaurant tenants, including tree and form views. - Added security access rights for the new models. - Developed a backup management view for database backups. - Updated menu structure to include new SaaS management options. - Added Docker and deployment configurations for PostgreSQL, Redis, and Odoo services. - Included scaling guide and backup scripts for production environments. - Enhanced theme with new images and layout adjustments.
220 lines
9.5 KiB
Python
220 lines
9.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
import string
|
|
import random
|
|
import logging
|
|
from odoo import models, fields, api, registry, SUPERUSER_ID
|
|
from odoo.exceptions import UserError
|
|
from odoo.service import db
|
|
from odoo.tools import config
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
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'),
|
|
('active', 'Active'),
|
|
('suspended', 'Suspended'),
|
|
('expired', 'Expired')
|
|
], 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')
|
|
|
|
_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 for c in vals.get('name', '').lower() if c.isalnum())
|
|
if not name_slug:
|
|
name_slug = 'restaurant'
|
|
|
|
# Ensure uniqueness
|
|
base_slug = name_slug
|
|
counter = 1
|
|
while self.env['saas.restaurant'].search([('subdomain', '=', f"{name_slug}.dine360.com")]):
|
|
name_slug = f"{base_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
|
|
master_pwd = config.get('admin_passwd', 'admin')
|
|
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(master_pwd, self.database_name, False, 'en_US', admin_pass)
|
|
_logger.info(f"Database {self.database_name} created successfully.")
|
|
except Exception as e:
|
|
raise UserError(f"PostgreSQL database creation failed: {str(e)}")
|
|
|
|
# 2. Boot environment on new database and install modules
|
|
tenant_registry = registry(self.database_name)
|
|
modules_to_install = [
|
|
'base', 'contacts', 'sale', 'stock', 'purchase',
|
|
'account', 'point_of_sale', 'hr', 'restaurant'
|
|
]
|
|
|
|
try:
|
|
_logger.info(f"Installing base modules on {self.database_name}...")
|
|
with tenant_registry.cursor() as cr:
|
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
|
module_objs = env['ir.module.module'].search([('name', 'in', modules_to_install)])
|
|
module_objs.write({'state': 'to_install'})
|
|
cr.commit()
|
|
# Run immediate install for Odoo base
|
|
module_objs.button_immediate_install()
|
|
cr.commit()
|
|
|
|
_logger.info("Base modules installed successfully. Configuring company...")
|
|
|
|
# 3. Configure Company & Admin User inside the new database
|
|
with tenant_registry.cursor() as cr:
|
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
|
|
|
# Company setup
|
|
company = env['res.company'].search([], limit=1)
|
|
company.write({
|
|
'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 user setup (ID 2 is usually admin)
|
|
admin_user = env['res.users'].browse(2)
|
|
admin_user.write({
|
|
'name': self.owner_name,
|
|
'login': self.email,
|
|
'email': self.email,
|
|
})
|
|
admin_user._set_password(admin_pass)
|
|
|
|
# Set timezone
|
|
admin_user.partner_id.tz = self.timezone
|
|
cr.commit()
|
|
|
|
except Exception as e:
|
|
# If provisioning fails midway, drop the database to allow clean retry
|
|
_logger.error(f"Failed configuring tenant registry: {str(e)}")
|
|
try:
|
|
db.exp_drop(master_pwd, self.database_name)
|
|
except Exception:
|
|
pass
|
|
raise UserError(f"Database provisioning / module installation failed: {str(e)}")
|
|
|
|
self.status = 'active'
|
|
|
|
# Send Welcome Email
|
|
template = self.env.ref('dine360_saas_master.saas_welcome_email_template', raise_if_not_found=False)
|
|
if template:
|
|
# Context for temporary password
|
|
self.with_context(temp_password=admin_pass).message_post_with_source(
|
|
source_ref=template,
|
|
subtype_xmlid='mail.mt_comment'
|
|
)
|
|
|
|
return True
|
|
|
|
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)}")
|