diff --git a/addons/Dine360_Shivasakthi/__manifest__.py b/addons/Dine360_Shivasakthi/__manifest__.py
index 224f026..caed817 100644
--- a/addons/Dine360_Shivasakthi/__manifest__.py
+++ b/addons/Dine360_Shivasakthi/__manifest__.py
@@ -36,7 +36,7 @@
],
'assets': {
'web.assets_backend': [
- 'Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css',
+ 'dine360_shivasakthi/static/src/css/apps_kanban_fix.css',
],
},
'installable': True,
diff --git a/addons/dine360_dashboard/__manifest__.py b/addons/dine360_dashboard/__manifest__.py
index 62e7e1b..010530f 100644
--- a/addons/dine360_dashboard/__manifest__.py
+++ b/addons/dine360_dashboard/__manifest__.py
@@ -17,7 +17,7 @@
'web.assets_backend': [
'dine360_dashboard/static/src/css/theme_variables.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',
],
'web.assets_frontend': [
diff --git a/addons/dine360_dashboard/static/src/css/login_style.css b/addons/dine360_dashboard/static/src/css/login_style.css
index 352e243..5a8d084 100644
--- a/addons/dine360_dashboard/static/src/css/login_style.css
+++ b/addons/dine360_dashboard/static/src/css/login_style.css
@@ -4,7 +4,7 @@
height: 100vh !important;
width: 100vw !important;
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-position: center center !important;
background-size: cover !important;
@@ -255,7 +255,7 @@ body.o_custom_login_body .o_login_main_wrapper {
height: 100vh !important;
/* width: 100vw !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;
}
diff --git a/addons/dine360_dashboard/static/src/js/chennora_title.js b/addons/dine360_dashboard/static/src/js/shivasakthi_title.js
similarity index 100%
rename from addons/dine360_dashboard/static/src/js/chennora_title.js
rename to addons/dine360_dashboard/static/src/js/shivasakthi_title.js
diff --git a/addons/dine360_saas_master/__init__.py b/addons/dine360_saas_master/__init__.py
new file mode 100644
index 0000000..3b38916
--- /dev/null
+++ b/addons/dine360_saas_master/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+from . import models
+from . import controllers
diff --git a/addons/dine360_saas_master/__manifest__.py b/addons/dine360_saas_master/__manifest__.py
new file mode 100644
index 0000000..5dbaf7e
--- /dev/null
+++ b/addons/dine360_saas_master/__manifest__.py
@@ -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',
+}
diff --git a/addons/dine360_saas_master/controllers/__init__.py b/addons/dine360_saas_master/controllers/__init__.py
new file mode 100644
index 0000000..ddc815b
--- /dev/null
+++ b/addons/dine360_saas_master/controllers/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import saas_api
diff --git a/addons/dine360_saas_master/controllers/saas_api.py b/addons/dine360_saas_master/controllers/saas_api.py
new file mode 100644
index 0000000..cad7d74
--- /dev/null
+++ b/addons/dine360_saas_master/controllers/saas_api.py
@@ -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)
diff --git a/addons/dine360_saas_master/data/saas_plan_data.xml b/addons/dine360_saas_master/data/saas_plan_data.xml
new file mode 100644
index 0000000..5703315
--- /dev/null
+++ b/addons/dine360_saas_master/data/saas_plan_data.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Starter
+ 1
+ 5
+ 49.00
+ 490.00
+
+
+
+
+ Professional
+ 5
+ 25
+ 149.00
+ 1490.00
+
+
+
+
+ Enterprise
+ 0
+ 0
+ 299.00
+ 2990.00
+
+
+
+ SaaS Welcome Email
+
+ Welcome to your Dine360 Restaurant Platform!
+ {{ object.company_id.email or object.env.user.email_formatted }}
+ {{ object.email }}
+
+
+
Hello ,
+
Congratulations! Your Dine360 restaurant tenant has been provisioned successfully.
+
You can access your dedicated dashboard and POS environment using the link below:
+
+ Go to Dashboard
+
+
Login Details:
+
+ - URL:
+ - Username/Email:
+ - Temporary Password:
+
+
This is an automated onboarding email. Please change your password upon logging in.
+
+
+
+
+
diff --git a/addons/dine360_saas_master/models/__init__.py b/addons/dine360_saas_master/models/__init__.py
new file mode 100644
index 0000000..82fc7ac
--- /dev/null
+++ b/addons/dine360_saas_master/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+from . import saas_plan
+from . import saas_restaurant
+from . import saas_backup
diff --git a/addons/dine360_saas_master/models/saas_backup.py b/addons/dine360_saas_master/models/saas_backup.py
new file mode 100644
index 0000000..9cbb08a
--- /dev/null
+++ b/addons/dine360_saas_master/models/saas_backup.py
@@ -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)}")
diff --git a/addons/dine360_saas_master/models/saas_plan.py b/addons/dine360_saas_master/models/saas_plan.py
new file mode 100644
index 0000000..e41d75c
--- /dev/null
+++ b/addons/dine360_saas_master/models/saas_plan.py
@@ -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)
diff --git a/addons/dine360_saas_master/models/saas_restaurant.py b/addons/dine360_saas_master/models/saas_restaurant.py
new file mode 100644
index 0000000..e05fc9e
--- /dev/null
+++ b/addons/dine360_saas_master/models/saas_restaurant.py
@@ -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)}")
diff --git a/addons/dine360_saas_master/security/ir.model.access.csv b/addons/dine360_saas_master/security/ir.model.access.csv
new file mode 100644
index 0000000..e4926a2
--- /dev/null
+++ b/addons/dine360_saas_master/security/ir.model.access.csv
@@ -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
diff --git a/addons/dine360_saas_master/views/saas_backup_views.xml b/addons/dine360_saas_master/views/saas_backup_views.xml
new file mode 100644
index 0000000..a3b3952
--- /dev/null
+++ b/addons/dine360_saas_master/views/saas_backup_views.xml
@@ -0,0 +1,60 @@
+
+
+
+
+ saas.backup.tree
+ saas.backup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ saas.backup.form
+ saas.backup
+
+
+
+
+
+
+
+ Database Backups
+ saas.backup
+ tree,form
+
+
+
+
+
diff --git a/addons/dine360_saas_master/views/saas_menus.xml b/addons/dine360_saas_master/views/saas_menus.xml
new file mode 100644
index 0000000..e059a7f
--- /dev/null
+++ b/addons/dine360_saas_master/views/saas_menus.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/dine360_saas_master/views/saas_plan_views.xml b/addons/dine360_saas_master/views/saas_plan_views.xml
new file mode 100644
index 0000000..9dc0b54
--- /dev/null
+++ b/addons/dine360_saas_master/views/saas_plan_views.xml
@@ -0,0 +1,61 @@
+
+
+
+
+ saas.plan.tree
+ saas.plan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ saas.plan.form
+ saas.plan
+
+
+
+
+
+
+
+ Subscription Plans
+ saas.plan
+ tree,form
+
+
+
+
+
diff --git a/addons/dine360_saas_master/views/saas_restaurant_views.xml b/addons/dine360_saas_master/views/saas_restaurant_views.xml
new file mode 100644
index 0000000..7176b8e
--- /dev/null
+++ b/addons/dine360_saas_master/views/saas_restaurant_views.xml
@@ -0,0 +1,116 @@
+
+
+
+
+ saas.restaurant.tree
+ saas.restaurant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ saas.restaurant.form
+ saas.restaurant
+
+
+
+
+
+
+
+ saas.restaurant.search
+ saas.restaurant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Restaurants
+ saas.restaurant
+ tree,form
+
+
+
+
+
+
diff --git a/addons/dine360_theme_shivasakthi/static/src/img/chen-banner-1.webp b/addons/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-1.webp
similarity index 100%
rename from addons/dine360_theme_shivasakthi/static/src/img/chen-banner-1.webp
rename to addons/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-1.webp
diff --git a/addons/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp b/addons/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-2.webp
similarity index 100%
rename from addons/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp
rename to addons/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-2.webp
diff --git a/addons/dine360_theme_shivasakthi/static/src/img/chen-banner-3.webp b/addons/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-3.webp
similarity index 100%
rename from addons/dine360_theme_shivasakthi/static/src/img/chen-banner-3.webp
rename to addons/dine360_theme_shivasakthi/static/src/img/shivasakthi-banner-3.webp
diff --git a/addons/dine360_theme_shivasakthi/views/layout.xml b/addons/dine360_theme_shivasakthi/views/layout.xml
index 14e4322..611e838 100644
--- a/addons/dine360_theme_shivasakthi/views/layout.xml
+++ b/addons/dine360_theme_shivasakthi/views/layout.xml
@@ -180,7 +180,7 @@
diff --git a/devops/SCALING_GUIDE.md b/devops/SCALING_GUIDE.md
new file mode 100644
index 0000000..2cffcd1
--- /dev/null
+++ b/devops/SCALING_GUIDE.md
@@ -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.
diff --git a/devops/backup_s3.sh b/devops/backup_s3.sh
new file mode 100644
index 0000000..b221a76
--- /dev/null
+++ b/devops/backup_s3.sh
@@ -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)"
diff --git a/devops/docker-compose.yml b/devops/docker-compose.yml
new file mode 100644
index 0000000..456e847
--- /dev/null
+++ b/devops/docker-compose.yml
@@ -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:
diff --git a/devops/nginx.conf b/devops/nginx.conf
new file mode 100644
index 0000000..2f8c98a
--- /dev/null
+++ b/devops/nginx.conf
@@ -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 ~^(?.+)\.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;
+ }
+}
diff --git a/devops/postgresql.conf b/devops/postgresql.conf
new file mode 100644
index 0000000..e703a39
--- /dev/null
+++ b/devops/postgresql.conf
@@ -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
diff --git a/devops/redis.conf b/devops/redis.conf
new file mode 100644
index 0000000..22fa2c0
--- /dev/null
+++ b/devops/redis.conf
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 4c7b3aa..e11dd15 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,39 +1,39 @@
services:
db:
image: postgres:15
- container_name: odoo_client3_db
+ container_name: odoo_client6_db
environment:
POSTGRES_DB: postgres
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
volumes:
- - client3_pgdata:/var/lib/postgresql/data
+ - client6_pgdata:/var/lib/postgresql/data
restart: always
odoo:
image: odoo:17.0
- container_name: odoo_client3
+ container_name: odoo_client6
depends_on:
- db
ports:
- - "10003:8069"
+ - "10006:8069"
environment:
HOST: db
USER: odoo
PASSWORD: odoo
LIST_DB: "True"
volumes:
- - client3_odoo_data:/var/lib/odoo
+ - client6_odoo_data:/var/lib/odoo
- ./addons:/mnt/extra-addons
restart: always
volumes:
- client3_pgdata:
- client3_odoo_data:
+ client6_pgdata:
+ client6_odoo_data:
# backups:
# .\backup_db.ps1
# 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