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.
This commit is contained in:
Alaguraj0361 2026-06-19 15:03:51 +05:30
parent fc319c1f61
commit b9d5617051
29 changed files with 1279 additions and 13 deletions

View File

@ -36,7 +36,7 @@
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
'Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css', 'dine360_shivasakthi/static/src/css/apps_kanban_fix.css',
], ],
}, },
'installable': True, 'installable': True,

View File

@ -17,7 +17,7 @@
'web.assets_backend': [ 'web.assets_backend': [
'dine360_dashboard/static/src/css/theme_variables.css', 'dine360_dashboard/static/src/css/theme_variables.css',
'dine360_dashboard/static/src/css/home_menu.css', 'dine360_dashboard/static/src/css/home_menu.css',
'dine360_dashboard/static/src/js/chennora_title.js', 'dine360_dashboard/static/src/js/shivasakthi_title.js',
'dine360_dashboard/static/src/xml/navbar_extension.xml', 'dine360_dashboard/static/src/xml/navbar_extension.xml',
], ],
'web.assets_frontend': [ 'web.assets_frontend': [

View File

@ -4,7 +4,7 @@
height: 100vh !important; height: 100vh !important;
width: 100vw !important; width: 100vw !important;
overflow: hidden; overflow: hidden;
background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp') !important; background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-2.webp') !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
background-position: center center !important; background-position: center center !important;
background-size: cover !important; background-size: cover !important;
@ -255,7 +255,7 @@ body.o_custom_login_body .o_login_main_wrapper {
height: 100vh !important; height: 100vh !important;
/* width: 100vw !important; */ /* width: 100vw !important; */
overflow: hidden !important; overflow: hidden !important;
background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp') no-repeat center center !important; background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-2.webp') no-repeat center center !important;
background-size: cover !important; background-size: cover !important;
} }

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': 'Dine360 SaaS Master',
'version': '1.0.0',
'category': 'Sales/SaaS',
'summary': 'Multi-Tenant SaaS Master Database & Subdomain Controller',
'description': """
Manage multi-tenant database provisioning, subscriptions, auto-expiries,
S3 backups, and subdomain routing configurations for Dine360 Restaurants.
""",
'author': 'Dine360',
'depends': ['base', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/saas_plan_data.xml',
'views/saas_menus.xml',
'views/saas_plan_views.xml',
'views/saas_restaurant_views.xml',
'views/saas_backup_views.xml',
],
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import saas_api

View File

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
import json
import logging
from odoo import http, fields
from odoo.http import request
from odoo.service import db
from odoo.tools import config
_logger = logging.getLogger(__name__)
class SaasApiController(http.Controller):
def _authenticate(self):
# Authenticate using a SaaS Token defined in odoo.conf (e.g. saas_api_token = abcxyz123)
expected_token = config.get('saas_api_token', 'METATRON_DINE360_SECRET_SaaS_2026')
auth_header = request.httprequest.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return False
token = auth_header.split(' ')[1]
return token == expected_token
def _json_response(self, data, status=200):
return request.make_response(
json.dumps(data),
headers=[('Content-Type', 'application/json')],
status=status
)
@http.route('/api/v1/saas/create_restaurant', type='json', auth='none', methods=['POST'], csrf=False)
def api_create_restaurant(self):
if not self._authenticate():
return self._json_response({'error': 'Unauthorized'}, status=401)
try:
data = request.get_json_data()
name = data.get('name')
owner_name = data.get('owner_name')
email = data.get('email')
plan_name = data.get('plan_name', 'Starter')
billing_cycle = data.get('billing_cycle', 'monthly')
expiry_date_str = data.get('expiry_date') # Format YYYY-MM-DD
if not all([name, owner_name, email, expiry_date_str]):
return self._json_response({'error': 'Missing required fields'}, status=400)
# Find subscription plan
plan = request.env['saas.plan'].sudo().search([('name', '=', plan_name)], limit=1)
if not plan:
return self._json_response({'error': f"Plan '{plan_name}' not found"}, status=404)
expiry_date = fields.Date.from_string(expiry_date_str)
# Create Restaurant record inside the master DB env
restaurant = request.env['saas.restaurant'].sudo().create({
'name': name,
'owner_name': owner_name,
'email': email,
'phone': data.get('phone', ''),
'street': data.get('street', ''),
'city': data.get('city', ''),
'plan_id': plan.id,
'billing_cycle': billing_cycle,
'expiry_date': expiry_date,
'currency_id': request.env['res.currency'].sudo().search([('name', '=', data.get('currency', 'USD'))], limit=1).id,
'timezone': data.get('timezone', 'America/New_York')
})
# Programmatically trigger database creation in Odoo
restaurant.action_create_database()
return self._json_response({
'success': True,
'restaurant_id': restaurant.id,
'database_name': restaurant.database_name,
'subdomain': restaurant.subdomain,
'status': restaurant.status
})
except Exception as e:
_logger.error(f"SaaS API Create Restaurant Error: {str(e)}")
return self._json_response({'error': str(e)}, status=500)
@http.route('/api/v1/saas/suspend_restaurant', type='json', auth='none', methods=['POST'], csrf=False)
def api_suspend_restaurant(self):
if not self._authenticate():
return self._json_response({'error': 'Unauthorized'}, status=401)
data = request.get_json_data()
db_name = data.get('database_name')
restaurant = request.env['saas.restaurant'].sudo().search([('database_name', '=', db_name)], limit=1)
if not restaurant:
return self._json_response({'error': 'Restaurant database not found'}, status=404)
try:
restaurant.action_suspend()
return self._json_response({'success': True, 'status': restaurant.status})
except Exception as e:
return self._json_response({'error': str(e)}, status=500)
@http.route('/api/v1/saas/renew_subscription', type='json', auth='none', methods=['POST'], csrf=False)
def api_renew_subscription(self):
if not self._authenticate():
return self._json_response({'error': 'Unauthorized'}, status=401)
data = request.get_json_data()
db_name = data.get('database_name')
new_expiry_str = data.get('expiry_date')
restaurant = request.env['saas.restaurant'].sudo().search([('database_name', '=', db_name)], limit=1)
if not restaurant:
return self._json_response({'error': 'Restaurant database not found'}, status=404)
try:
new_expiry = fields.Date.from_string(new_expiry_str)
restaurant.write({'expiry_date': new_expiry})
if restaurant.status in ['suspended', 'expired']:
restaurant.action_activate()
return self._json_response({
'success': True,
'status': restaurant.status,
'expiry_date': fields.Date.to_string(restaurant.expiry_date)
})
except Exception as e:
return self._json_response({'error': str(e)}, status=500)
@http.route('/api/v1/saas/delete_restaurant', type='json', auth='none', methods=['POST'], csrf=False)
def api_delete_restaurant(self):
if not self._authenticate():
return self._json_response({'error': 'Unauthorized'}, status=401)
data = request.get_json_data()
db_name = data.get('database_name')
restaurant = request.env['saas.restaurant'].sudo().search([('database_name', '=', db_name)], limit=1)
if not restaurant:
return self._json_response({'error': 'Restaurant database not found'}, status=404)
try:
master_pwd = config.get('admin_passwd', 'admin')
# 1. Drop database in PostgreSQL via Odoo service API
db.exp_drop(master_pwd, db_name)
# 2. Delete database registry entry in master
restaurant.unlink()
return self._json_response({'success': True})
except Exception as e:
return self._json_response({'error': str(e)}, status=500)
@http.route('/api/v1/saas/backup_restaurant', type='json', auth='none', methods=['POST'], csrf=False)
def api_backup_restaurant(self):
if not self._authenticate():
return self._json_response({'error': 'Unauthorized'}, status=401)
data = request.get_json_data()
db_name = data.get('database_name')
storage_type = data.get('storage_type', 'local')
restaurant = request.env['saas.restaurant'].sudo().search([('database_name', '=', db_name)], limit=1)
if not restaurant:
return self._json_response({'error': 'Restaurant database not found'}, status=404)
try:
backup = request.env['saas.backup'].sudo().create({
'restaurant_id': restaurant.id,
'storage_type': storage_type
})
backup.action_backup_database()
return self._json_response({
'success': True,
'backup_file': backup.backup_file,
's3_url': backup.s3_url
})
except Exception as e:
return self._json_response({'error': str(e)}, status=500)

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Starter Plan -->
<record id="saas_plan_starter" model="saas.plan">
<field name="name">Starter</field>
<field name="max_pos">1</field>
<field name="max_users">5</field>
<field name="price_monthly">49.00</field>
<field name="price_yearly">490.00</field>
</record>
<!-- Professional Plan -->
<record id="saas_plan_professional" model="saas.plan">
<field name="name">Professional</field>
<field name="max_pos">5</field>
<field name="max_users">25</field>
<field name="price_monthly">149.00</field>
<field name="price_yearly">1490.00</field>
</record>
<!-- Enterprise Plan -->
<record id="saas_plan_enterprise" model="saas.plan">
<field name="name">Enterprise</field>
<field name="max_pos">0</field> <!-- 0 represents Unlimited -->
<field name="max_users">0</field>
<field name="price_monthly">299.00</field>
<field name="price_yearly">2990.00</field>
</record>
<!-- Welcome Email Template -->
<record id="saas_welcome_email_template" model="mail.template">
<field name="name">SaaS Welcome Email</field>
<field name="model_id" ref="dine360_saas_master.model_saas_restaurant"/>
<field name="subject">Welcome to your Dine360 Restaurant Platform!</field>
<field name="email_from">{{ object.company_id.email or object.env.user.email_formatted }}</field>
<field name="email_to">{{ object.email }}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;">
<p>Hello <t t-out="object.owner_name"/>,</p>
<p>Congratulations! Your Dine360 restaurant tenant <strong><t t-out="object.name"/></strong> has been provisioned successfully.</p>
<p>You can access your dedicated dashboard and POS environment using the link below:</p>
<p style="margin: 20px 0;">
<a t-att-href="'http://' + object.subdomain" style="background-color: #ffb800; color: #ffffff; padding: 10px 20px; text-decoration: none; font-weight: bold; border-radius: 4px;">Go to Dashboard</a>
</p>
<p><strong>Login Details:</strong></p>
<ul>
<li>URL: <t t-out="object.subdomain"/></li>
<li>Username/Email: <t t-out="object.email"/></li>
<li>Temporary Password: <strong><t t-out="ctx.get('temp_password')"/></strong></li>
</ul>
<p style="color: #888888; font-size: 12px; margin-top: 30px;">This is an automated onboarding email. Please change your password upon logging in.</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import saas_plan
from . import saas_restaurant
from . import saas_backup

View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
import os
import tempfile
import logging
from datetime import datetime
from odoo import models, fields, api
from odoo.exceptions import UserError
from odoo.service import db
_logger = logging.getLogger(__name__)
try:
import boto3
except ImportError:
boto3 = None
class SaasBackup(models.Model):
_name = 'saas.backup'
_description = 'Dine360 SaaS Backup Log'
_order = 'timestamp desc'
restaurant_id = fields.Many2one('saas.restaurant', string='Restaurant Tenant', required=True)
database_name = fields.Char(related='restaurant_id.database_name', store=True, readonly=True)
backup_file = fields.Char(string='Backup Filename', readonly=True)
timestamp = fields.Datetime(string='Backup Time', default=fields.Datetime.now, readonly=True)
status = fields.Selection([
('success', 'Success'),
('failed', 'Failed')
], string='Status', default='failed', readonly=True)
storage_type = fields.Selection([
('local', 'Local Storage'),
('s3', 'Amazon S3')
], string='Storage Type', default='local', required=True)
s3_url = fields.Char(string='S3 URL', readonly=True)
def action_backup_database(self):
self.ensure_one()
db_name = self.database_name
if not db_name:
raise UserError("Database name is missing on the restaurant record!")
timestamp_str = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{db_name}_{timestamp_str}.zip"
# Create temporary file to store Odoo dump
temp_dir = tempfile.gettempdir()
temp_filepath = os.path.join(temp_dir, filename)
try:
_logger.info(f"Dumping database {db_name} to {temp_filepath}...")
with open(temp_filepath, 'wb') as f:
# Odoo service dump database to stream
db.dump_db(db_name, f, format='zip')
_logger.info("Database dumped successfully.")
# S3 upload logic
s3_bucket = os.environ.get('AWS_S3_BUCKET')
aws_access_key = os.environ.get('AWS_ACCESS_KEY_ID')
aws_secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
if self.storage_type == 's3' and boto3 and s3_bucket:
_logger.info(f"Uploading {filename} to AWS S3 bucket {s3_bucket}...")
s3_client = boto3.client(
's3',
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key
)
s3_client.upload_file(temp_filepath, s3_bucket, filename)
s3_url = f"https://{s3_bucket}.s3.amazonaws.com/{filename}"
self.write({
'backup_file': filename,
'status': 'success',
's3_url': s3_url
})
# Clean up local temp file
os.remove(temp_filepath)
else:
# Local storage: move backup to persistent local directory
backup_dir = os.environ.get('ODOO_BACKUP_DIR', '/var/lib/odoo/backups')
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
persistent_path = os.path.join(backup_dir, filename)
os.rename(temp_filepath, persistent_path)
self.write({
'backup_file': filename,
'status': 'success',
's3_url': f"file://{persistent_path}"
})
except Exception as e:
_logger.error(f"Backup generation failed: {str(e)}")
self.write({
'backup_file': filename,
'status': 'failed'
})
if os.path.exists(temp_filepath):
os.remove(temp_filepath)
raise UserError(f"Backup failed: {str(e)}")
@api.model
def cron_automatic_backups(self):
""" Triggered by Odoo cron job to run daily backups for all active tenants """
tenants = self.env['saas.restaurant'].search([('status', '=', 'active')])
s3_bucket = os.environ.get('AWS_S3_BUCKET')
storage = 's3' if s3_bucket else 'local'
for tenant in tenants:
try:
backup = self.create({
'restaurant_id': tenant.id,
'storage_type': storage
})
backup.action_backup_database()
except Exception as e:
_logger.error(f"Scheduled backup failed for {tenant.database_name}: {str(e)}")

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SaasPlan(models.Model):
_name = 'saas.plan'
_description = 'Dine360 SaaS Plan'
name = fields.Char(string='Plan Name', required=True)
max_pos = fields.Integer(string='Max POS Terminals', default=1, help='0 for unlimited')
max_users = fields.Integer(string='Max Active Users', default=5, help='0 for unlimited')
price_monthly = fields.Float(string='Monthly Price', required=True, default=0.0)
price_yearly = fields.Float(string='Yearly Price', required=True, default=0.0)
active = fields.Boolean(default=True)

View File

@ -0,0 +1,219 @@
# -*- 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)}")

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_saas_plan,saas.plan,model_saas_plan,base.group_system,1,1,1,1
access_saas_restaurant,saas.restaurant,model_saas_restaurant,base.group_system,1,1,1,1
access_saas_backup,saas.backup,model_saas_backup,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_saas_plan saas.plan model_saas_plan base.group_system 1 1 1 1
3 access_saas_restaurant saas.restaurant model_saas_restaurant base.group_system 1 1 1 1
4 access_saas_backup saas.backup model_saas_backup base.group_system 1 1 1 1

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_saas_backup_tree" model="ir.ui.view">
<field name="name">saas.backup.tree</field>
<field name="model">saas.backup</field>
<field name="arch" type="xml">
<tree string="SaaS Backups" create="false">
<field name="restaurant_id"/>
<field name="database_name"/>
<field name="backup_file"/>
<field name="timestamp"/>
<field name="storage_type"/>
<field name="status" widget="badge" decoration-success="status == 'success'" decoration-danger="status == 'failed'"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_saas_backup_form" model="ir.ui.view">
<field name="name">saas.backup.form</field>
<field name="model">saas.backup</field>
<field name="arch" type="xml">
<form string="Backup Record" edit="false" create="false">
<header>
<button name="action_backup_database" string="Run Backup Now" type="object" class="oe_highlight" invisible="status == 'success'"/>
<field name="status" widget="statusbar" statusbar_visible="failed,success"/>
</header>
<sheet>
<group>
<group string="Target Database">
<field name="restaurant_id"/>
<field name="database_name"/>
</group>
<group string="Execution Metadata">
<field name="timestamp"/>
<field name="storage_type"/>
<field name="backup_file"/>
<field name="s3_url" widget="url" invisible="not s3_url"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_saas_backup" model="ir.actions.act_window">
<field name="name">Database Backups</field>
<field name="res_model">saas.backup</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menuitem -->
<menuitem id="menu_saas_backup"
name="Database Backups"
parent="menu_saas_tenants"
action="action_saas_backup"
sequence="20"/>
</odoo>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root Menu -->
<menuitem id="menu_saas_root"
name="Dine360 SaaS"
web_icon="dine360_saas_master,static/description/icon.png"
sequence="10"/>
<!-- Submenus -->
<menuitem id="menu_saas_tenants"
name="Restaurants"
parent="menu_saas_root"
sequence="10"/>
<menuitem id="menu_saas_configuration"
name="Configuration"
parent="menu_saas_root"
sequence="100"/>
</odoo>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_saas_plan_tree" model="ir.ui.view">
<field name="name">saas.plan.tree</field>
<field name="model">saas.plan</field>
<field name="arch" type="xml">
<tree string="SaaS Plans">
<field name="name"/>
<field name="max_pos"/>
<field name="max_users"/>
<field name="price_monthly"/>
<field name="price_yearly"/>
<field name="active"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_saas_plan_form" model="ir.ui.view">
<field name="name">saas.plan.form</field>
<field name="model">saas.plan</field>
<field name="arch" type="xml">
<form string="SaaS Plan">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" placeholder="e.g. Starter"/>
</h1>
</div>
<group>
<group string="Limits">
<field name="max_pos"/>
<field name="max_users"/>
</group>
<group string="Pricing">
<field name="price_monthly" widget="monetary"/>
<field name="price_yearly" widget="monetary"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_saas_plan" model="ir.actions.act_window">
<field name="name">Subscription Plans</field>
<field name="res_model">saas.plan</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menuitem -->
<menuitem id="menu_saas_plan"
name="Subscription Plans"
parent="menu_saas_configuration"
action="action_saas_plan"
sequence="10"/>
</odoo>

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_saas_restaurant_tree" model="ir.ui.view">
<field name="name">saas.restaurant.tree</field>
<field name="model">saas.restaurant</field>
<field name="arch" type="xml">
<tree string="Restaurants">
<field name="name"/>
<field name="owner_name"/>
<field name="email"/>
<field name="subdomain"/>
<field name="plan_id"/>
<field name="status" widget="badge" decoration-success="status == 'active'" decoration-info="status == 'draft'" decoration-danger="status == 'suspended' or status == 'expired'"/>
<field name="expiry_date"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_saas_restaurant_form" model="ir.ui.view">
<field name="name">saas.restaurant.form</field>
<field name="model">saas.restaurant</field>
<field name="arch" type="xml">
<form string="Restaurant Tenant">
<header>
<button name="action_create_database" string="Provision Database" type="object" class="oe_highlight" invisible="status != 'draft'"/>
<button name="action_suspend" string="Suspend Subscription" type="object" class="btn-danger" invisible="status != 'active'"/>
<button name="action_activate" string="Activate Subscription" type="object" class="oe_highlight" invisible="status not in ['suspended', 'expired']"/>
<field name="status" widget="statusbar" statusbar_visible="draft,active,suspended,expired"/>
</header>
<sheet>
<field name="logo" widget="image" class="oe_avatar" options="{'preview_image': 'logo'}"/>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" placeholder="Restaurant Name"/>
</h1>
</div>
<group>
<group string="Owner Information">
<field name="owner_name"/>
<field name="email"/>
<field name="phone"/>
</group>
<group string="Address">
<field name="street"/>
<field name="city"/>
<field name="country_id"/>
</group>
</group>
<group>
<group string="Tenant Deployment">
<field name="database_name"/>
<field name="subdomain"/>
</group>
<group string="Subscription Details">
<field name="plan_id"/>
<field name="billing_cycle"/>
<field name="start_date"/>
<field name="expiry_date"/>
</group>
</group>
<group>
<group string="Localizations">
<field name="currency_id"/>
<field name="timezone"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_saas_restaurant_search" model="ir.ui.view">
<field name="name">saas.restaurant.search</field>
<field name="model">saas.restaurant</field>
<field name="arch" type="xml">
<search string="Search Restaurants">
<field name="name"/>
<field name="owner_name"/>
<field name="email"/>
<field name="subdomain"/>
<filter string="Draft" name="status_draft" domain="[('status', '=', 'draft')]"/>
<filter string="Active" name="status_active" domain="[('status', '=', 'active')]"/>
<filter string="Suspended" name="status_suspended" domain="[('status', '=', 'suspended')]"/>
<filter string="Expired" name="status_expired" domain="[('status', '=', 'expired')]"/>
<group expand="0" string="Group By">
<filter string="Plan" name="group_by_plan" context="{'group_by': 'plan_id'}"/>
<filter string="Status" name="group_by_status" context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_saas_restaurant" model="ir.actions.act_window">
<field name="name">Restaurants</field>
<field name="res_model">saas.restaurant</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_saas_restaurant_search"/>
</record>
<!-- Menuitem -->
<menuitem id="menu_saas_restaurant"
name="Restaurants List"
parent="menu_saas_tenants"
action="action_saas_restaurant"
sequence="10"/>
</odoo>

View File

@ -180,7 +180,7 @@
<div class="footer-copyright-bar" style="border-top: 1px solid rgba(255,255,255,0.05); padding: 20px 0;"> <div class="footer-copyright-bar" style="border-top: 1px solid rgba(255,255,255,0.05); padding: 20px 0;">
<div class="container text-center"> <div class="container text-center">
<p class="mb-0" style="font-size: 13px; color: #dcdcdc;"> <p class="mb-0" style="font-size: 13px; color: #dcdcdc;">
Copyright 2026 © Shivas Dosa Restaurant. Powered by <span style="color: #ffb800;">MetatronCube</span>. All Right Reserved. Copyright 2026 © Shiva Sakthi Restaurant. Powered by <span style="color: #ffb800;">MetatronCube</span>. All Right Reserved.
</p> </p>
</div> </div>
</div> </div>

89
devops/SCALING_GUIDE.md Normal file
View File

@ -0,0 +1,89 @@
# Dine360 SaaS Multi-Tenant Platform: Production Scaling & Deployment Guide
This guide describes how to configure, deploy, secure, and scale the Dine360 SaaS platform to support 25 to 500+ concurrent restaurants.
---
## 1. High Availability Architecture
To ensure 99.9% uptime, the infrastructure must be divided into separate tiers with no single point of failure (SPOF).
```mermaid
graph LR
User[Clients / POS] -->|HTTPS| DNS[Route53 / Cloudflare DNS]
DNS -->|Anycast| ALB[Load Balancer Pool: Nginx / AWS ALB]
ALB -->|Proxy Pass| WebPool[Odoo App instances cluster]
WebPool -->|Session Cache| Redis[Redis Replication Group]
WebPool -->|DB Pool| Bouncer[PgBouncer Poolers]
Bouncer -->|Transactions| DBPrimary[(PostgreSQL Primary)]
DBPrimary -->|Streaming replication| DBReplica[(PostgreSQL Hot Standby)]
DBPrimary -->|Archived Logs| S3[(AWS S3 Backup Bucket)]
```
---
## 2. Server Sizing Recommendations
### 25 to 100 Restaurants (Starter Tier)
- **Odoo Servers**: 1x App Server (8 vCPU, 16GB RAM) running Odoo with `--workers=17`.
- **Database Server**: 1x PostgreSQL Server (8 vCPU, 32GB RAM).
- **Cache/Session**: Local Redis instance.
### 100 to 500+ Restaurants (Enterprise / High-Scale Tier)
- **App Instance Pool**: 3x App Servers (each 8 vCPU, 16GB RAM) running behind a Load Balancer (ALB).
- **PostgreSQL Database Cluster**:
- 1x Primary Write Node (32 vCPU, 128GB RAM, NVMe storage).
- 1x Replica Read-Only Node (16 vCPU, 64GB RAM) for heavy reports, API queries, and read scaling.
- **PgBouncer**: Dedicated PgBouncer container on database nodes configured in transaction pooling mode.
- **Cache/Session**: Managed AWS ElastiCache for Redis (Replicated/Sharded cluster).
---
## 3. Database Connection Pooling (PgBouncer)
With 500+ databases, Odoo's default connection model opens up to 500 * (workers + 2) connections, easily overwhelming PostgreSQL connection limits and exhausting system file descriptors.
- **Fix**: Use PgBouncer in **transaction pooling** mode:
```ini
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 50
```
- **Transaction Pooling Note**: In transaction mode, cursor-based operations (like Odoo's temporary tables or session locks) can sometimes fail if not handled. Odoo is compatible with transaction pooling from version 12 onwards, but long-lived locks should be avoided in custom modules.
---
## 4. Let's Encrypt Wildcard SSL Automation
For auto-onboarding subdomains (e.g. `*.dine360.com`), a Wildcard SSL certificate is required.
### Setup using Certbot (DNS-01 Challenge)
Since HTTP validation cannot verify wildcard domains, you must use DNS-01 validation.
1. **Install Certbot with DNS plugin** (e.g. Route53 plugin for AWS):
```bash
sudo apt-get install certbot python3-certbot-dns-route53
```
2. **Acquire Wildcard Certificate**:
```bash
certbot certonly --dns-route53 -d dine360.com -d *.dine360.com
```
3. **Cron for Automatic Renewal**:
Add this to `/etc/crontab` to check renewals daily and reload Nginx:
```cron
0 0 * * * root certbot renew --post-hook "systemctl reload nginx"
```
---
## 5. Monitoring Setup
To maintain system observability, implement the following stack:
1. **Node Exporter & Prometheus**: Collect CPU, Memory, Disk IOPS, and Network metrics of all host servers.
2. **postgres_exporter**: Track PostgreSQL active backends, transaction delays, lock waits, and cache hit rates.
3. **Grafana Dashboard**:
- Create alerts for DB connection limits (>80%).
- Create alerts for disk usage (>85%).
- Monitor average query response time.
4. **Sentry Integration**: Configure Odoo to send trace logs and exceptions directly to Sentry for debugging tenant-specific issues without accessing logs directly.

42
devops/backup_s3.sh Normal file
View File

@ -0,0 +1,42 @@
#!/bin/bash
# Backup script for Dine360 SaaS Multi-Tenant Databases
# Run this as a daily/weekly cron job on the host system
# Configuration
PG_USER="odoo"
PG_HOST="localhost"
PG_PORT="5432"
export PGPASSWORD="odoo_master_pass_2026"
BACKUP_DIR="/var/lib/odoo/backups"
S3_BUCKET="s3://dine360-backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
mkdir -p "$BACKUP_DIR"
# Get all database names starting with dine360_
DATABASES=$(psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d postgres -t -c "SELECT datname FROM pg_database WHERE datname LIKE 'dine360_%'")
for DB in $DATABASES; do
echo "Starting backup of database: $DB..."
BACKUP_FILE="$BACKUP_DIR/${DB}_${TIMESTAMP}.sql.gz"
# Run pg_dump compressed with gzip
if pg_dump -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$DB" | gzip > "$BACKUP_FILE"; then
echo "Successfully dumped to local storage: $BACKUP_FILE"
# Upload to AWS S3 / DigitalOcean Spaces
if aws s3 cp "$BACKUP_FILE" "$S3_BUCKET/$DB/${DB}_${TIMESTAMP}.sql.gz"; then
echo "Uploaded successfully to S3 bucket."
else
echo "Error: Upload to S3 failed." >&2
fi
else
echo "Error: pg_dump failed for database $DB" >&2
fi
done
# Retain local files for 7 days, delete older ones
find "$BACKUP_DIR" -type f -name "dine360_*.sql.gz" -mtime +7 -delete
echo "Dine360 backups verification completed at $(date)"

108
devops/docker-compose.yml Normal file
View File

@ -0,0 +1,108 @@
version: "3.8"
services:
db:
image: postgres:15-alpine
container_name: dine360_saas_db
environment:
POSTGRES_DB: postgres
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo_master_pass_2026
volumes:
- pgdata:/var/lib/postgresql/data
- ./postgresql.conf:/etc/postgresql/postgresql.conf
command: "postgres -c config_file=/etc/postgresql/postgresql.conf"
ports:
- "5432:5432"
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U odoo"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: dine360_saas_redis
volumes:
- redisdata:/data
- ./redis.conf:/usr/local/etc/redis/redis.conf
command: "redis-server /usr/local/etc/redis/redis.conf"
ports:
- "6379:6379"
restart: always
pgbouncer:
image: edoburu/pgbouncer:latest
container_name: dine360_saas_pgbouncer
environment:
- DB_USER=odoo
- DB_PASSWORD=odoo_master_pass_2026
- DB_HOST=db
- DB_PORT=5432
- MAX_CLIENT_CONN=10000
- DEFAULT_POOL_SIZE=50
- MIN_POOL_SIZE=10
- POOL_MODE=transaction
ports:
- "6432:6432"
depends_on:
- db
restart: always
web:
image: odoo:17.0
container_name: dine360_saas_web
depends_on:
- db
- redis
- pgbouncer
ports:
- "8069:8069"
- "8072:8072"
volumes:
- odoo_data:/var/lib/odoo
- ../addons:/mnt/extra-addons
environment:
- HOST=pgbouncer
- PORT=6432
- USER=odoo
- PASSWORD=odoo_master_pass_2026
command: >
odoo
--workers=9
--max-cron-threads=0
--db-filter=^%d$$
--proxy-mode
--limit-time-cpu=600
--limit-time-real=1200
--session-store=redis
restart: always
cron:
image: odoo:17.0
container_name: dine360_saas_cron
depends_on:
- db
- redis
- pgbouncer
volumes:
- odoo_data:/var/lib/odoo
- ../addons:/mnt/extra-addons
environment:
- HOST=pgbouncer
- PORT=6432
- USER=odoo
- PASSWORD=odoo_master_pass_2026
command: >
odoo
--workers=0
--max-cron-threads=4
--db-filter=^%d$$
--proxy-mode
restart: always
volumes:
pgdata:
redisdata:
odoo_data:

91
devops/nginx.conf Normal file
View File

@ -0,0 +1,91 @@
# Upstream configuration for web and longpolling requests
upstream odoo-web {
server web:8069;
}
upstream odoo-im {
server web:8072;
}
# Redirect HTTP to HTTPS
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name .dine360.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS Server (SaaS wildcard routing)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ~^(?<tenant>.+)\.dine360\.com$;
# Wildcard SSL Certificates (managed via Let's Encrypt Certbot)
ssl_certificate /etc/letsencrypt/live/dine360.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dine360.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Logger configurations
access_log /var/log/nginx/odoo.access.log;
error_log /var/log/nginx/odoo.error.log;
# Buffer & Timeout settings for file uploads
client_max_body_size 128M;
keepalive_timeout 90;
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
# Gzip Compression
gzip on;
gzip_types text/css text/scss text/plain text/xml application/xml application/json application/javascript;
# Redirect longpolling chat/POS requests
location /longpolling {
proxy_pass http://odoo-im;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# Standard Web requests proxy
location / {
proxy_pass http://odoo-web;
proxy_redirect off;
# Core headers for multi-tenant database filtering
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
# Enables Odoo dbfilter configuration matching
# Converts tenant.dine360.com requests into filtering by 'dine360_restaurant_tenant' database
proxy_set_header X-Odoo-dbfilter dine360_restaurant_$tenant;
# Mitigate HTTPoxy vulnerability
proxy_set_header Proxy "";
}
# Static resources cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff|woff2|svg)$ {
proxy_cache_valid 200 60m;
proxy_pass http://odoo-web;
expires 7d;
}
}

43
devops/postgresql.conf Normal file
View File

@ -0,0 +1,43 @@
# PostgreSQL 15 configuration optimized for Odoo Multi-Tenant SaaS
# Assume 16GB system RAM, 4 CPU Cores
# Connectivity
max_connections = 500
superuser_reserved_connections = 3
# Memory Sizing
shared_buffers = 4GB # 25% of total RAM
work_mem = 16MB # Allocates per operation
maintenance_work_mem = 512MB
effective_cache_size = 12GB # 75% of total RAM
temp_buffers = 8MB
# Write-Ahead Log (WAL)
wal_level = replica
max_wal_size = 4GB
min_wal_size = 1GB
checkpoint_completion_target = 0.9
checkpoint_timeout = 15min
# Disk I/O & Query Planner
random_page_cost = 1.1 # Optimized for SSD storage
effective_io_concurrency = 200 # Concurrent read requests (SSD)
# Background Writer & Autovacuum (Crucial for Odoo table cleanup)
autovacuum = on
autovacuum_max_workers = 4
autovacuum_naptime = 1min
autovacuum_vacuum_threshold = 50
autovacuum_analyze_threshold = 50
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02
autovacuum_vacuum_cost_limit = 1000
# Logging
log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
log_min_messages = warning
log_min_error_statement = error
log_min_duration_statement = 250 # Log slow queries taking longer than 250ms

22
devops/redis.conf Normal file
View File

@ -0,0 +1,22 @@
# Redis configuration for Odoo session management and caching
port 6379
bind 0.0.0.0
# Memory Limits
maxmemory 2gb
maxmemory-policy allkeys-lru
# Persistence (Required so users do not get logged out when Redis restarts)
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
# Performance Tuning
tcp-backlog 511
timeout 0
tcp-keepalive 300
databases 16

View File

@ -1,39 +1,39 @@
services: services:
db: db:
image: postgres:15 image: postgres:15
container_name: odoo_client3_db container_name: odoo_client6_db
environment: environment:
POSTGRES_DB: postgres POSTGRES_DB: postgres
POSTGRES_USER: odoo POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo POSTGRES_PASSWORD: odoo
volumes: volumes:
- client3_pgdata:/var/lib/postgresql/data - client6_pgdata:/var/lib/postgresql/data
restart: always restart: always
odoo: odoo:
image: odoo:17.0 image: odoo:17.0
container_name: odoo_client3 container_name: odoo_client6
depends_on: depends_on:
- db - db
ports: ports:
- "10003:8069" - "10006:8069"
environment: environment:
HOST: db HOST: db
USER: odoo USER: odoo
PASSWORD: odoo PASSWORD: odoo
LIST_DB: "True" LIST_DB: "True"
volumes: volumes:
- client3_odoo_data:/var/lib/odoo - client6_odoo_data:/var/lib/odoo
- ./addons:/mnt/extra-addons - ./addons:/mnt/extra-addons
restart: always restart: always
volumes: volumes:
client3_pgdata: client6_pgdata:
client3_odoo_data: client6_odoo_data:
# backups: # backups:
# .\backup_db.ps1 # .\backup_db.ps1
# Team Members Restore # Team Members Restore
# cat d:\Odoo\backups\YOUR_BACKUP_FILE.sql | docker exec -i odoo_client3_db psql -U odoo -d postgres # cat d:\Odoo\backups\YOUR_BACKUP_FILE.sql | docker exec -i odoo_client6_db psql -U odoo -d postgres