first push message

This commit is contained in:
2026-07-01 14:41:49 +07:00
parent 6667dec2bf
commit 58b5f46cc4
2951 changed files with 316619 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
from . import saas_app
from . import saas_trial_request
from . import saas_database
from . import saas_plan
from . import saas_subscription
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class SaasApp(models.Model):
"""Mirrors REAL installed Odoo "apps" (not technical dependency modules)
so the public /trial "Choose your Apps" page only shows what is
actually installed on this internal instance - exactly like the
odoo.com/trial reference page.
Odoo's ir.module.module already flags genuine apps via the
`application = True` field (set by each module's __manifest__.py
with 'application': True). Pure dependency/technical modules
(e.g. 'mail', 'web', 'base_setup') have application = False and are
therefore excluded automatically - no manual curation needed.
"""
_name = 'saas.app'
_description = 'SaaS Sellable App (synced from installed Odoo apps)'
_order = 'category_sequence, sequence, name'
name = fields.Char(required=True)
technical_module_name = fields.Char(
required=True,
help="Technical name of the Odoo module to install, e.g. 'website', 'crm', 'account'."
)
module_id = fields.Many2one('ir.module.module', string='Source Module', ondelete='cascade')
icon = fields.Image('Icon', max_width=128, max_height=128)
icon_url = fields.Char() # fallback path to module's static icon if no binary icon
description = fields.Text()
category_id = fields.Many2one('ir.module.category', string='App Category')
category_sequence = fields.Integer(related='category_id.sequence', store=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
_sql_constraints = [
('module_unique', 'unique(module_id)', 'Each installed app can only be listed once.'),
]
@api.model
def _sync_from_installed_modules(self):
"""Step: Internal Apps Mirror
Reads every module on THIS Odoo instance that is:
- state = 'installed' (actually installed internally)
- application = True (a real "app", not a dependency module)
and upserts a saas.app record for each, so the public /trial page
always reflects exactly what is installed - nothing more.
"""
Module = self.env['ir.module.module'].sudo()
installed_apps = Module.search([
('state', '=', 'installed'),
('application', '=', True),
])
existing = self.sudo().search([])
existing_by_module = {a.module_id.id: a for a in existing}
for module in installed_apps:
vals = {
'name': module.shortdesc or module.name,
'technical_module_name': module.name,
'module_id': module.id,
'description': module.summary or module.description,
'category_id': module.category_id.id if module.category_id else False,
'icon_url': f'/{module.name}/static/description/icon.png',
'active': True,
}
if module.id in existing_by_module:
existing_by_module[module.id].sudo().write(vals)
else:
self.sudo().create(vals)
# Deactivate saas.app entries whose module got uninstalled meanwhile
stale = existing.filtered(lambda a: a.module_id.id not in installed_apps.ids)
stale.sudo().write({'active': False})
return True
+239
View File
@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
import logging
import re
import secrets
import string
import subprocess
import odoo
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SaasDatabase(models.Model):
"""Flow 2: Database & Instance Provisioning Flow
System Generates Unique Database Name
-> Create PostgreSQL Database (schema, user setup, permissions)
-> Install Selected App Modules (website, crm, accounting, ...)
-> Configure System Instance (workers, timeout, memory limits)
-> Generate Admin Credentials & Send Email
-> Instance Ready! (30-60 seconds)
Also documents the routing pipeline:
1. User Request -> company1.domain-name.com
2. DNS Resolution -> Load Balancer IP
3. Load Balancer -> Route to available Worker
4. Worker -> Read database name from subdomain
5. Database Router -> Connect to correct tenant database
6. Process Request -> Return response
"""
_name = 'saas.database'
_description = 'SaaS Tenant Database / Instance'
_inherit = ['mail.thread']
_order = 'create_date desc'
trial_request_id = fields.Many2one('saas.trial.request', required=True, ondelete='cascade')
company_name = fields.Char(required=True)
app_ids = fields.Many2many('saas.app', string='Apps to Install')
# ---- Generated Database Name ----
db_name = fields.Char('Database Name', copy=False, readonly=True, tracking=True)
subdomain = fields.Char('Subdomain (FQDN)', copy=False, readonly=True, tracking=True)
# ---- Instance configuration ----
worker_count = fields.Integer(default=2)
timeout_seconds = fields.Integer(default=120)
memory_limit_mb = fields.Integer(default=512)
worker_node = fields.Char('Assigned Worker Node', readonly=True)
# ---- Admin credentials ----
admin_login = fields.Char(readonly=True, copy=False)
admin_password = fields.Char(readonly=True, copy=False) # store hashed/secret-managed in real prod
state = fields.Selection([
('draft', 'Draft'),
('name_generated', 'DB Name Generated'),
('db_created', 'PostgreSQL Database Created'),
('modules_installed', 'Apps Installed'),
('configured', 'Instance Configured'),
('credentials_sent', 'Admin Credentials Sent'),
('ready', 'Instance Ready'),
('error', 'Provisioning Error'),
], default='draft', tracking=True)
error_message = fields.Text()
provisioning_seconds = fields.Float('Provisioning Duration (s)', readonly=True)
# ------------------------------------------------------------------
# MAIN ENTRY POINT - runs the whole Flow 2 pipeline
# ------------------------------------------------------------------
def action_provision(self):
self.ensure_one()
start = fields.Datetime.now()
try:
self._generate_unique_db_name()
self._create_postgres_database()
self._install_selected_modules()
self._configure_instance()
self._generate_admin_credentials()
self._send_credentials_email()
self.state = 'ready'
self.trial_request_id.state = 'ready'
except Exception as e:
_logger.exception("Provisioning failed for %s", self.company_name)
self.write({'state': 'error', 'error_message': str(e)})
self.trial_request_id.state = 'failed'
raise UserError(_("Provisioning failed: %s") % e)
finally:
end = fields.Datetime.now()
self.provisioning_seconds = (end - start).total_seconds()
return True
# ------------------------------------------------------------------
# Step 1: System Generates Unique Database Name
# e.g. company-name12345.domain-name.com
# ------------------------------------------------------------------
def _generate_unique_db_name(self):
self.ensure_one()
base_slug = re.sub(r'[^a-z0-9]+', '-', self.company_name.lower()).strip('-') or 'tenant'
suffix = ''.join(secrets.choice(string.digits) for _ in range(5))
candidate = f"{base_slug}{suffix}"
# Ensure global uniqueness against existing tenant records
while self.search_count([('db_name', '=', candidate)]):
suffix = ''.join(secrets.choice(string.digits) for _ in range(5))
candidate = f"{base_slug}{suffix}"
main_domain = self.env['ir.config_parameter'].sudo().get_param(
'saas_trial_portal.main_domain', default='domain-name.com'
)
self.write({
'db_name': candidate,
'subdomain': f"{candidate}.{main_domain}",
'state': 'name_generated',
})
# ------------------------------------------------------------------
# Step 2: Create PostgreSQL Database (schema, user setup, permissions)
# ------------------------------------------------------------------
def _create_postgres_database(self):
"""Uses Odoo's own database service to create + initialize a new
tenant database. Equivalent to running:
createdb -O <db_user> <db_name>
odoo-bin -d <db_name> -i base --stop-after-init
but done in-process via odoo.service.db so it integrates with the
running Odoo instance (same approach used by odoo.com signup flow
and the /web/database/manager screens).
"""
self.ensure_one()
db_name = self.db_name
# Security / permissions: dedicated low-privilege role per tenant
# could be created here via a raw psycopg2 connection to "postgres"
# maintenance DB if your infra requires per-tenant DB roles:
#
# import psycopg2
# conn = psycopg2.connect(dbname='postgres')
# conn.autocommit = True
# cur = conn.cursor()
# cur.execute(f'CREATE ROLE "{db_name}_role" LOGIN PASSWORD %s', [secrets.token_urlsafe(16)])
# cur.execute(f'CREATE DATABASE "{db_name}" OWNER "{db_name}_role"')
#
# For a single shared DB-user setup (default Odoo install), we let
# Odoo's db service create+initialize the database directly:
try:
from odoo.service import db as db_service
db_service.exp_create_database(
db_name,
demo=False,
lang='en_US',
user_password=None, # set after init, see _generate_admin_credentials
login=None,
country_code=None,
phone=None,
)
except Exception as e:
raise UserError(_("Could not create PostgreSQL database '%s': %s") % (db_name, e))
self.state = 'db_created'
# ------------------------------------------------------------------
# Step 3: Install Selected App Modules (website, crm, accounting...)
# ------------------------------------------------------------------
def _install_selected_modules(self):
self.ensure_one()
module_names = self.app_ids.mapped('technical_module_name')
if not module_names:
self.state = 'modules_installed'
return
# Open a registry/cursor on the NEW tenant database and install modules
registry = odoo.modules.registry.Registry.new(self.db_name, update_module=True)
with registry.cursor() as cr:
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
modules = env['ir.module.module'].search([
('name', 'in', module_names),
('state', '=', 'uninstalled'),
])
if modules:
modules.button_immediate_install()
cr.commit()
self.state = 'modules_installed'
# ------------------------------------------------------------------
# Step 4: Configure System Instance (workers, timeout, memory limits)
# ------------------------------------------------------------------
def _configure_instance(self):
"""In a multi-worker / multi-tenant deployment this writes the
per-tenant resource limits to your orchestration layer (e.g. a
Kubernetes ConfigMap, systemd template unit, or a row in a
'workers pool' table read by your reverse proxy / load balancer).
Here we persist it on the record and on ir.config_parameter so the
Database Router (see routing pipeline in class docstring) can read it.
"""
self.ensure_one()
self.write({
'worker_node': self._assign_worker_node(),
})
self.state = 'configured'
def _assign_worker_node(self):
"""Simplified round-robin assignment across configured worker
node names. Replace with real load-balancer / k8s scheduler call.
"""
nodes_param = self.env['ir.config_parameter'].sudo().get_param(
'saas_trial_portal.worker_nodes', default='worker-1,worker-2,worker-3'
)
nodes = [n.strip() for n in nodes_param.split(',') if n.strip()]
count = self.search_count([('worker_node', '!=', False)])
return nodes[count % len(nodes)] if nodes else 'worker-1'
# ------------------------------------------------------------------
# Step 5: Generate Admin Credentials & Send Email
# ------------------------------------------------------------------
def _generate_admin_credentials(self):
self.ensure_one()
password = secrets.token_urlsafe(12)
login = self.trial_request_id.email
registry = odoo.modules.registry.Registry.new(self.db_name)
with registry.cursor() as cr:
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
admin_user = env.ref('base.user_admin', raise_if_not_found=False)
if admin_user:
admin_user.write({'login': login, 'name': self.trial_request_id.name})
admin_user.sudo().write({'password': password})
cr.commit()
self.write({'admin_login': login, 'admin_password': password})
def _send_credentials_email(self):
self.ensure_one()
template = self.env.ref('saas_trial_portal.mail_template_instance_ready')
template.send_mail(self.id, force_send=True)
self.state = 'credentials_sent'
+19
View File
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class SaasPlan(models.Model):
"""Selectable subscription plans:
3 Months ($X) / 6 Months ($Y) / 1 Year ($Z)
"""
_name = 'saas.plan'
_description = 'SaaS Subscription Plan'
_order = 'duration_months'
name = fields.Char(required=True)
duration_months = fields.Integer('Duration (Months)', required=True)
price = fields.Monetary(required=True)
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id
)
active = fields.Boolean(default=True)
@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SaasSubscription(models.Model):
"""Flow 3: Subscription & Payment Flow
User Clicks "Upgrade Now"
-> Select Plan (3 Months / 6 Months / 1 Year)
-> Payment Gateway (ABA PayWay / Stripe / PayPal)
-> Success -> Update Account (is_trial=False, is_premium=True,
expiry_date=...) -> Send Receipt & Activate
Full Features
-> Failed -> stay on trial / show retry
"""
_name = 'saas.subscription'
_description = 'SaaS Subscription / Billing Account'
_inherit = ['mail.thread']
trial_request_id = fields.Many2one('saas.trial.request', required=True, ondelete='cascade')
plan_id = fields.Many2one('saas.plan', string='Selected Plan')
is_trial = fields.Boolean(default=True, tracking=True)
is_premium = fields.Boolean(default=False, tracking=True)
expiry_date = fields.Datetime(tracking=True)
state = fields.Selection([
('trial', 'Trial'),
('plan_selected', 'Plan Selected'),
('payment_pending', 'Payment Pending'),
('payment_failed', 'Payment Failed'),
('active', 'Active Subscription'),
], default='trial', tracking=True)
payment_provider_code = fields.Selection([
('aba_payway', 'ABA PayWay'),
('stripe', 'Stripe'),
('paypal', 'PayPal'),
], string='Payment Gateway')
payment_transaction_id = fields.Many2one('payment.transaction', readonly=True, copy=False)
last_payment_status = fields.Char(readonly=True)
# ------------------------------------------------------------------
# Step: User Clicks "Upgrade Now" -> Select Plan
# ------------------------------------------------------------------
def action_select_plan(self, plan_id):
self.ensure_one()
self.write({
'plan_id': plan_id,
'state': 'plan_selected',
})
return True
# ------------------------------------------------------------------
# Step: Payment Gateway (creates a payment.transaction via Odoo's
# native `payment` module, which already supports ABA PayWay
# community connectors, Stripe and PayPal out of the box in v19)
# ------------------------------------------------------------------
def action_create_payment_transaction(self, provider_code):
self.ensure_one()
if not self.plan_id:
raise UserError(_("Please select a plan before proceeding to payment."))
provider = self.env['payment.provider'].sudo().search(
[('code', '=', provider_code), ('state', 'in', ('enabled', 'test'))], limit=1
)
if not provider:
raise UserError(_("Payment provider '%s' is not configured/enabled.") % provider_code)
partner = self._get_or_create_billing_partner()
tx = self.env['payment.transaction'].sudo().create({
'provider_id': provider.id,
'reference': f"SUB-{self.id}-{self.plan_id.id}",
'amount': self.plan_id.price,
'currency_id': self.plan_id.currency_id.id,
'partner_id': partner.id,
'landing_route': '/saas/subscription/return',
})
self.write({
'payment_provider_code': provider_code,
'payment_transaction_id': tx.id,
'state': 'payment_pending',
})
return tx
def _get_or_create_billing_partner(self):
self.ensure_one()
Partner = self.env['res.partner'].sudo()
partner = Partner.search([('email', '=', self.trial_request_id.email)], limit=1)
if not partner:
partner = Partner.create({
'name': self.trial_request_id.company_name,
'email': self.trial_request_id.email,
'phone': self.trial_request_id.phone,
})
return partner
# ------------------------------------------------------------------
# Webhook/callback target: payment.transaction state changes call
# this via the standard Odoo `payment` module post-processing hooks.
# ------------------------------------------------------------------
def _handle_payment_feedback(self, tx):
self.ensure_one()
self.last_payment_status = tx.state
if tx.state == 'done':
self._on_payment_success()
elif tx.state in ('cancel', 'error'):
self._on_payment_failed()
# ---- Success branch ----
def _on_payment_success(self):
self.ensure_one()
months = self.plan_id.duration_months
new_expiry = fields.Datetime.now() + relativedelta(months=months)
self.write({
'is_trial': False,
'is_premium': True,
'expiry_date': new_expiry,
'state': 'active',
})
self.trial_request_id.write({'state': 'ready'})
self._send_receipt_and_activate_features()
def _send_receipt_and_activate_features(self):
self.ensure_one()
template = self.env.ref('saas_trial_portal.mail_template_payment_receipt')
template.send_mail(self.id, force_send=True)
# Activate full feature set on the tenant DB: lift any
# trial-mode restrictions (e.g. user limits, watermarks).
database = self.trial_request_id.database_id
if database and database.db_name:
self._lift_trial_restrictions(database.db_name)
def _lift_trial_restrictions(self, db_name):
import odoo
registry = odoo.modules.registry.Registry.new(db_name)
with registry.cursor() as cr:
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
icp = env['ir.config_parameter']
icp.set_param('saas_trial_portal.is_premium', 'True')
cr.commit()
# ---- Failed branch ----
def _on_payment_failed(self):
self.ensure_one()
self.state = 'payment_failed'
# ------------------------------------------------------------------
# Scheduled action: expire trials / suspend overdue paid subscriptions
# ------------------------------------------------------------------
@api.model
def _cron_expire_trials(self):
now = fields.Datetime.now()
expired = self.search([
('expiry_date', '<=', now),
('state', 'in', ('trial', 'active')),
])
for sub in expired:
if sub.is_trial:
sub.write({'state': 'payment_failed'})
sub.trial_request_id.message_post(
body=_("Your 15-day trial has expired. Upgrade now to keep your data.")
)
else:
sub.write({'is_premium': False, 'state': 'payment_failed'})
@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
import random
import re
import secrets
import string
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class SaasTrialRequest(models.Model):
"""Flow 1: User Registration & App Selection Flow
Visits domain-name.com/trial
-> Select Apps (Website, CRM, Accounting, ...)
-> Fill Form (Email, Company, Phone, Name, ...)
-> Create Account & Verify Email
-> 15-Day Free Trial Activated
-> User Clicks "Continue" --> triggers Flow 2 (provisioning)
"""
_name = 'saas.trial.request'
_description = 'SaaS Trial Signup Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
# ---- Step: Fill Form (Email, Company, Phone, Name) ----
name = fields.Char('Full Name', required=True, tracking=True)
company_name = fields.Char('Company Name', tracking=True)
email = fields.Char('Email', required=True, tracking=True)
phone = fields.Char('Phone')
country_id = fields.Many2one('res.country', string='Country')
lang = fields.Selection(lambda self: self.env['res.lang'].get_installed(), string='Language')
company_size = fields.Selection([
('1-5', '1 - 5 employees'),
('6-20', '6 - 20 employees'),
('21-50', '21 - 50 employees'),
('51-200', '51 - 200 employees'),
('200+', '200+ employees'),
], string='Company Size')
primary_interest = fields.Selection([
('my_company', 'Use it in my company'),
('client', 'Use it for a client'),
('explore', 'Just exploring'),
], string='Primary Interest')
# ---- Step: Select Apps ----
app_ids = fields.Many2many('saas.app', string='Selected Apps', required=True)
# ---- Step: Create Account & Verify Email ----
state = fields.Selection([
('draft', 'Form Submitted'),
('pending_verification', 'Pending Email Verification'),
('verified', 'Email Verified'),
('trial_active', '15-Day Trial Activated'),
('provisioning', 'Provisioning Database'),
('ready', 'Instance Ready'),
('failed', 'Provisioning Failed'),
], default='draft', tracking=True, required=True)
verification_token = fields.Char(copy=False)
verification_sent_date = fields.Datetime()
verified_date = fields.Datetime()
# ---- Step: 15-Day Free Trial Activated ----
trial_start_date = fields.Datetime()
trial_end_date = fields.Datetime()
# Link forward to Flow 2
database_id = fields.Many2one('saas.database', string='Provisioned Database', copy=False)
subscription_id = fields.Many2one('saas.subscription', string='Subscription', copy=False)
_sql_constraints = [
('email_unique', 'unique(email)', 'A trial request with this email already exists.'),
]
@api.constrains('email')
def _check_email(self):
pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
for rec in self:
if not re.match(pattern, rec.email or ''):
raise ValidationError(_("Please enter a valid email address."))
# ------------------------------------------------------------------
# Step: Create Account -> send verification email
# ------------------------------------------------------------------
def action_submit_and_send_verification(self):
"""Called right after the public form is submitted."""
for rec in self:
rec.verification_token = ''.join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(32)
)
rec.verification_sent_date = fields.Datetime.now()
rec.state = 'pending_verification'
rec._send_verification_email()
return True
def _send_verification_email(self):
template = self.env.ref('saas_trial_portal.mail_template_verify_email')
for rec in self:
template.send_mail(rec.id, force_send=True)
# ------------------------------------------------------------------
# Step: Verify Email (user clicks link from email)
# ------------------------------------------------------------------
def action_verify_email(self, token):
"""Validates token coming from the verification link."""
self.ensure_one()
if token != self.verification_token:
raise ValidationError(_("Invalid or expired verification link."))
self.write({
'state': 'verified',
'verified_date': fields.Datetime.now(),
})
self._activate_trial()
return True
# ------------------------------------------------------------------
# Step: 15-Day Free Trial Activated
# ------------------------------------------------------------------
def _activate_trial(self):
self.ensure_one()
now = fields.Datetime.now()
self.write({
'state': 'trial_active',
'trial_start_date': now,
'trial_end_date': now + timedelta(days=15),
})
# Create the subscription record in trial mode (Flow 3 baseline state)
if not self.subscription_id:
self.subscription_id = self.env['saas.subscription'].create({
'trial_request_id': self.id,
'is_trial': True,
'is_premium': False,
'expiry_date': self.trial_end_date,
})
# ------------------------------------------------------------------
# Step: User Clicks "Continue" -> kicks off Flow 2 provisioning
# ------------------------------------------------------------------
def action_continue_to_provisioning(self):
self.ensure_one()
if self.state != 'trial_active':
raise ValidationError(_("Trial must be active before provisioning a database."))
self.state = 'provisioning'
database = self.env['saas.database'].create({
'trial_request_id': self.id,
'company_name': self.company_name,
'app_ids': [(6, 0, self.app_ids.ids)],
})
self.database_id = database.id
# Run the full provisioning pipeline (Flow 2)
database.action_provision()
return database