Alaguraj0361 b9d5617051 Add SaaS multi-tenant models and views for restaurant management
- 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.
2026-06-19 15:03:51 +05:30

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)}")