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:
parent
fc319c1f61
commit
b9d5617051
@ -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,
|
||||
|
||||
@ -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': [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
3
addons/dine360_saas_master/__init__.py
Normal file
3
addons/dine360_saas_master/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
24
addons/dine360_saas_master/__manifest__.py
Normal file
24
addons/dine360_saas_master/__manifest__.py
Normal 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',
|
||||
}
|
||||
2
addons/dine360_saas_master/controllers/__init__.py
Normal file
2
addons/dine360_saas_master/controllers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import saas_api
|
||||
173
addons/dine360_saas_master/controllers/saas_api.py
Normal file
173
addons/dine360_saas_master/controllers/saas_api.py
Normal 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)
|
||||
56
addons/dine360_saas_master/data/saas_plan_data.xml
Normal file
56
addons/dine360_saas_master/data/saas_plan_data.xml
Normal 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>
|
||||
4
addons/dine360_saas_master/models/__init__.py
Normal file
4
addons/dine360_saas_master/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import saas_plan
|
||||
from . import saas_restaurant
|
||||
from . import saas_backup
|
||||
117
addons/dine360_saas_master/models/saas_backup.py
Normal file
117
addons/dine360_saas_master/models/saas_backup.py
Normal 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)}")
|
||||
13
addons/dine360_saas_master/models/saas_plan.py
Normal file
13
addons/dine360_saas_master/models/saas_plan.py
Normal 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)
|
||||
219
addons/dine360_saas_master/models/saas_restaurant.py
Normal file
219
addons/dine360_saas_master/models/saas_restaurant.py
Normal 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)}")
|
||||
4
addons/dine360_saas_master/security/ir.model.access.csv
Normal file
4
addons/dine360_saas_master/security/ir.model.access.csv
Normal 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
|
||||
|
60
addons/dine360_saas_master/views/saas_backup_views.xml
Normal file
60
addons/dine360_saas_master/views/saas_backup_views.xml
Normal 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>
|
||||
19
addons/dine360_saas_master/views/saas_menus.xml
Normal file
19
addons/dine360_saas_master/views/saas_menus.xml
Normal 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>
|
||||
61
addons/dine360_saas_master/views/saas_plan_views.xml
Normal file
61
addons/dine360_saas_master/views/saas_plan_views.xml
Normal 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>
|
||||
116
addons/dine360_saas_master/views/saas_restaurant_views.xml
Normal file
116
addons/dine360_saas_master/views/saas_restaurant_views.xml
Normal 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>
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@ -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="container text-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
89
devops/SCALING_GUIDE.md
Normal file
89
devops/SCALING_GUIDE.md
Normal 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
42
devops/backup_s3.sh
Normal 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
108
devops/docker-compose.yml
Normal 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
91
devops/nginx.conf
Normal 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
43
devops/postgresql.conf
Normal 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
22
devops/redis.conf
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user