From b9d5617051ac5d2eb46148bdeab5771ae534e9f6 Mon Sep 17 00:00:00 2001 From: Alaguraj0361 Date: Fri, 19 Jun 2026 15:03:51 +0530 Subject: [PATCH] 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. --- addons/Dine360_Shivasakthi/__manifest__.py | 2 +- addons/dine360_dashboard/__manifest__.py | 2 +- .../static/src/css/login_style.css | 4 +- ...chennora_title.js => shivasakthi_title.js} | 0 addons/dine360_saas_master/__init__.py | 3 + addons/dine360_saas_master/__manifest__.py | 24 ++ .../controllers/__init__.py | 2 + .../controllers/saas_api.py | 173 ++++++++++++++ .../data/saas_plan_data.xml | 56 +++++ addons/dine360_saas_master/models/__init__.py | 4 + .../dine360_saas_master/models/saas_backup.py | 117 ++++++++++ .../dine360_saas_master/models/saas_plan.py | 13 ++ .../models/saas_restaurant.py | 219 ++++++++++++++++++ .../security/ir.model.access.csv | 4 + .../views/saas_backup_views.xml | 60 +++++ .../dine360_saas_master/views/saas_menus.xml | 19 ++ .../views/saas_plan_views.xml | 61 +++++ .../views/saas_restaurant_views.xml | 116 ++++++++++ ...anner-1.webp => shivasakthi-banner-1.webp} | Bin ...anner-2.webp => shivasakthi-banner-2.webp} | Bin ...anner-3.webp => shivasakthi-banner-3.webp} | Bin .../views/layout.xml | 2 +- devops/SCALING_GUIDE.md | 89 +++++++ devops/backup_s3.sh | 42 ++++ devops/docker-compose.yml | 108 +++++++++ devops/nginx.conf | 91 ++++++++ devops/postgresql.conf | 43 ++++ devops/redis.conf | 22 ++ docker-compose.yml | 16 +- 29 files changed, 1279 insertions(+), 13 deletions(-) rename addons/dine360_dashboard/static/src/js/{chennora_title.js => shivasakthi_title.js} (100%) create mode 100644 addons/dine360_saas_master/__init__.py create mode 100644 addons/dine360_saas_master/__manifest__.py create mode 100644 addons/dine360_saas_master/controllers/__init__.py create mode 100644 addons/dine360_saas_master/controllers/saas_api.py create mode 100644 addons/dine360_saas_master/data/saas_plan_data.xml create mode 100644 addons/dine360_saas_master/models/__init__.py create mode 100644 addons/dine360_saas_master/models/saas_backup.py create mode 100644 addons/dine360_saas_master/models/saas_plan.py create mode 100644 addons/dine360_saas_master/models/saas_restaurant.py create mode 100644 addons/dine360_saas_master/security/ir.model.access.csv create mode 100644 addons/dine360_saas_master/views/saas_backup_views.xml create mode 100644 addons/dine360_saas_master/views/saas_menus.xml create mode 100644 addons/dine360_saas_master/views/saas_plan_views.xml create mode 100644 addons/dine360_saas_master/views/saas_restaurant_views.xml rename addons/dine360_theme_shivasakthi/static/src/img/{chen-banner-1.webp => shivasakthi-banner-1.webp} (100%) rename addons/dine360_theme_shivasakthi/static/src/img/{chen-banner-2.webp => shivasakthi-banner-2.webp} (100%) rename addons/dine360_theme_shivasakthi/static/src/img/{chen-banner-3.webp => shivasakthi-banner-3.webp} (100%) create mode 100644 devops/SCALING_GUIDE.md create mode 100644 devops/backup_s3.sh create mode 100644 devops/docker-compose.yml create mode 100644 devops/nginx.conf create mode 100644 devops/postgresql.conf create mode 100644 devops/redis.conf 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