first push message
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SaaS Trial & Subscription Portal',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Website/SaaS',
|
||||
'summary': 'Self-service trial signup, database provisioning, and subscription/payment management',
|
||||
'description': """
|
||||
SaaS Trial & Subscription Portal
|
||||
=================================
|
||||
Implements 3 business flows:
|
||||
|
||||
1. User Registration & App Selection Flow
|
||||
- Public trial signup form (website/trial)
|
||||
- Email verification
|
||||
- 15-day trial activation
|
||||
|
||||
2. Database & Instance Provisioning Flow
|
||||
- Unique tenant database name generation
|
||||
- PostgreSQL database creation (schema, users, permissions)
|
||||
- Module installation per selected apps
|
||||
- Instance configuration (workers, timeout, memory)
|
||||
- Admin credential generation & email
|
||||
- Tenant request routing (subdomain -> worker -> DB)
|
||||
|
||||
3. Subscription & Payment Flow
|
||||
- Plan selection (3/6/12 months)
|
||||
- Payment gateway integration (ABA PayWay, Stripe, PayPal)
|
||||
- Account upgrade (trial -> premium)
|
||||
- Receipt & feature activation
|
||||
""",
|
||||
'author': 'Your Company',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'mail', 'website', 'payment'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'security/security.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'data/mail_template_data.xml',
|
||||
'views/saas_trial_request_views.xml',
|
||||
'views/saas_database_views.xml',
|
||||
'views/saas_subscription_views.xml',
|
||||
'views/saas_plan_views.xml',
|
||||
'views/website_templates.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'saas_trial_portal/static/src/css/trial_form.css',
|
||||
'saas_trial_portal/static/src/js/trial_form.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import trial_controller
|
||||
from . import tenant_router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tenant Request Routing Pipeline (ដំណើរការ Request)
|
||||
-----------------------------------------------------
|
||||
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
|
||||
|
||||
Steps 1-3 happen OUTSIDE Odoo, at the infrastructure layer:
|
||||
- DNS: wildcard A/CNAME record *.domain-name.com -> Load Balancer IP
|
||||
- Load Balancer (nginx/HAProxy/Traefik) terminates TLS and forwards
|
||||
to one of N Odoo worker processes/pods, e.g.:
|
||||
|
||||
nginx.conf snippet:
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.domain-name.com;
|
||||
location / {
|
||||
proxy_pass http://odoo_workers;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
upstream odoo_workers {
|
||||
server worker-1:8069;
|
||||
server worker-2:8069;
|
||||
server worker-3:8069;
|
||||
}
|
||||
|
||||
Steps 4-6 are implemented in Odoo itself via this controller, which reads
|
||||
the subdomain from the Host header and switches `request.session.db`
|
||||
before any other controller/model code executes. Odoo natively supports
|
||||
this via the `dbfilter` config option (odoo.conf):
|
||||
|
||||
dbfilter = ^%h$
|
||||
|
||||
`%h` is replaced by the Host header at request time, so Odoo automatically
|
||||
maps "company1.domain-name.com" -> database "company1" PROVIDED the
|
||||
database name matches the first subdomain label. Since our generated
|
||||
db_name (see saas.database._generate_unique_db_name) IS the first label
|
||||
of the subdomain, `dbfilter = ^%h$` alone satisfies steps 4-5 for the
|
||||
standard Odoo multi-database filter mechanism.
|
||||
|
||||
The explicit controller below is only needed if you want CUSTOM routing
|
||||
logic beyond dbfilter (e.g. custom error pages for suspended/expired
|
||||
tenants, or a non-Odoo-native subdomain naming scheme).
|
||||
"""
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class TenantRouterController(http.Controller):
|
||||
|
||||
@http.route('/saas/tenant-status', type='json', auth='public')
|
||||
def tenant_status(self, **kw):
|
||||
"""Optional health endpoint the Load Balancer / monitoring system
|
||||
can call to verify a tenant database is reachable and active
|
||||
before routing traffic to it (step 3-4 sanity check).
|
||||
"""
|
||||
host = request.httprequest.host.split(':')[0]
|
||||
subdomain_label = host.split('.')[0]
|
||||
|
||||
database = request.env['saas.database'].sudo().search(
|
||||
[('db_name', '=', subdomain_label)], limit=1
|
||||
)
|
||||
if not database:
|
||||
return {'status': 'not_found'}
|
||||
|
||||
subscription = database.trial_request_id.subscription_id
|
||||
if subscription and subscription.expiry_date and subscription.expiry_date < http.fields.Datetime.now():
|
||||
return {'status': 'expired', 'db_name': database.db_name}
|
||||
|
||||
return {
|
||||
'status': 'active' if database.state == 'ready' else database.state,
|
||||
'db_name': database.db_name,
|
||||
'worker_node': database.worker_node,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class SaasTrialController(http.Controller):
|
||||
"""Flow 1: User Registration & App Selection Flow
|
||||
Handles: domain-name.com/trial
|
||||
"""
|
||||
|
||||
@http.route('/trial', type='http', auth='public', website=True, sitemap=True)
|
||||
def trial_signup_page(self, **kw):
|
||||
"""Step: Choose your Apps
|
||||
Mirrors odoo.com/trial - shows ONLY real installed apps
|
||||
(saas.app is synced from ir.module.module where
|
||||
state='installed' and application=True), grouped by category,
|
||||
exactly like the reference screenshot.
|
||||
"""
|
||||
apps = request.env['saas.app'].sudo().search([('active', '=', True)])
|
||||
|
||||
groups = {}
|
||||
order = []
|
||||
for app in apps:
|
||||
cat_name = app.category_id.name or 'Other'
|
||||
if cat_name not in groups:
|
||||
groups[cat_name] = []
|
||||
order.append(cat_name)
|
||||
groups[cat_name].append(app)
|
||||
|
||||
app_groups = [(cat, groups[cat]) for cat in order]
|
||||
countries = request.env['res.country'].sudo().search([], order='name')
|
||||
languages = request.env['res.lang'].sudo().get_installed()
|
||||
|
||||
return request.render('saas_trial_portal.trial_signup_template', {
|
||||
'app_groups': app_groups,
|
||||
'countries': countries,
|
||||
'languages': languages,
|
||||
})
|
||||
|
||||
@http.route('/trial/submit', type='http', auth='public', website=True, methods=['POST'])
|
||||
def trial_signup_submit(self, **post):
|
||||
import json
|
||||
app_ids_raw = post.get('app_ids_json') or '[]'
|
||||
try:
|
||||
app_ids = [int(a) for a in json.loads(app_ids_raw)]
|
||||
except (ValueError, TypeError):
|
||||
app_ids = []
|
||||
|
||||
trial = request.env['saas.trial.request'].sudo().create({
|
||||
'name': post.get('name'),
|
||||
'company_name': post.get('company_name'),
|
||||
'email': post.get('email'),
|
||||
'phone': post.get('phone'),
|
||||
'country_id': int(post['country_id']) if post.get('country_id') else False,
|
||||
'lang': post.get('lang') or False,
|
||||
'company_size': post.get('company_size') or False,
|
||||
'primary_interest': post.get('primary_interest') or False,
|
||||
'app_ids': [(6, 0, app_ids)],
|
||||
})
|
||||
trial.action_submit_and_send_verification()
|
||||
return request.render('saas_trial_portal.trial_check_email_template', {
|
||||
'email': trial.email,
|
||||
})
|
||||
|
||||
@http.route('/trial/verify', type='http', auth='public', website=True)
|
||||
def trial_verify_email(self, token=None, id=None, **kw):
|
||||
trial = request.env['saas.trial.request'].sudo().browse(int(id)) if id else None
|
||||
if not trial or not trial.exists():
|
||||
return request.render('saas_trial_portal.trial_error_template', {
|
||||
'message': 'Invalid verification link.'
|
||||
})
|
||||
trial.action_verify_email(token)
|
||||
return request.render('saas_trial_portal.trial_activated_template', {
|
||||
'trial': trial,
|
||||
})
|
||||
|
||||
@http.route('/trial/continue', type='http', auth='public', website=True, methods=['POST'])
|
||||
def trial_continue(self, id=None, **kw):
|
||||
trial = request.env['saas.trial.request'].sudo().browse(int(id))
|
||||
database = trial.action_continue_to_provisioning()
|
||||
return request.render('saas_trial_portal.trial_provisioning_template', {
|
||||
'trial': trial,
|
||||
'database': database,
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="ir_cron_expire_trials" model="ir.cron">
|
||||
<field name="name">SaaS: Expire 15-Day Trials</field>
|
||||
<field name="model_id" ref="model_saas_subscription"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_expire_trials()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_cron_sync_apps" model="ir.cron">
|
||||
<field name="name">SaaS: Sync Installed Apps for /trial Page</field>
|
||||
<field name="model_id" ref="model_saas_app"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._sync_from_installed_modules()</field>
|
||||
<field name="interval_number">6</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Flow 1: Email Verification -->
|
||||
<record id="mail_template_verify_email" model="mail.template">
|
||||
<field name="name">SaaS: Verify Your Email</field>
|
||||
<field name="model_id" ref="model_saas_trial_request"/>
|
||||
<field name="subject">Confirm your email to activate your 15-day trial</field>
|
||||
<field name="email_to">{{ object.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: Arial, sans-serif;">
|
||||
<p>Hi <t t-out="object.name"/>,</p>
|
||||
<p>Thanks for signing up! Please confirm your email to activate your
|
||||
<strong>15-day free trial</strong>.</p>
|
||||
<p>
|
||||
<a t-att-href="'/trial/verify?id=%s&token=%s' % (object.id, object.verification_token)"
|
||||
style="background:#714B67;color:#fff;padding:10px 18px;border-radius:4px;text-decoration:none;">
|
||||
Verify Email & Activate Trial
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Flow 2: Instance Ready -->
|
||||
<record id="mail_template_instance_ready" model="mail.template">
|
||||
<field name="name">SaaS: Your Instance Is Ready</field>
|
||||
<field name="model_id" ref="model_saas_database"/>
|
||||
<field name="subject">Your Odoo instance is ready 🎉</field>
|
||||
<field name="email_to">{{ object.trial_request_id.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: Arial, sans-serif;">
|
||||
<p>Hi <t t-out="object.trial_request_id.name"/>,</p>
|
||||
<p>Your instance has been provisioned and is ready to use:</p>
|
||||
<ul>
|
||||
<li>URL: <a t-att-href="'https://%s' % object.subdomain"><t t-out="object.subdomain"/></a></li>
|
||||
<li>Login: <t t-out="object.admin_login"/></li>
|
||||
<li>Temporary Password: <t t-out="object.admin_password"/></li>
|
||||
</ul>
|
||||
<p>Please log in and change your password immediately.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Flow 3: Payment Receipt -->
|
||||
<record id="mail_template_payment_receipt" model="mail.template">
|
||||
<field name="name">SaaS: Payment Receipt</field>
|
||||
<field name="model_id" ref="model_saas_subscription"/>
|
||||
<field name="subject">Payment received - Full features activated</field>
|
||||
<field name="email_to">{{ object.trial_request_id.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: Arial, sans-serif;">
|
||||
<p>Hi <t t-out="object.trial_request_id.name"/>,</p>
|
||||
<p>We've received your payment for the
|
||||
<strong><t t-out="object.plan_id.name"/></strong> plan.</p>
|
||||
<p>Your subscription is now active until
|
||||
<t t-out="object.expiry_date"/>. All premium features have been
|
||||
activated on your instance.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
def post_init_hook(env):
|
||||
"""Runs once right after this module is installed, so the /trial
|
||||
'Choose your Apps' page is populated immediately with whatever apps
|
||||
are already installed internally - no waiting for the 6h cron.
|
||||
"""
|
||||
env['saas.app']._sync_from_installed_modules()
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_saas_app_user,saas.app.user,model_saas_app,base.group_user,1,0,0,0
|
||||
access_saas_app_manager,saas.app.manager,model_saas_app,base.group_system,1,1,1,1
|
||||
access_saas_trial_request_user,saas.trial.request.user,model_saas_trial_request,base.group_user,1,1,1,0
|
||||
access_saas_trial_request_public,saas.trial.request.public,model_saas_trial_request,base.group_public,1,1,1,0
|
||||
access_saas_database_user,saas.database.user,model_saas_database,base.group_user,1,1,1,0
|
||||
access_saas_plan_user,saas.plan.user,model_saas_plan,base.group_user,1,0,0,0
|
||||
access_saas_plan_public,saas.plan.public,model_saas_plan,base.group_public,1,0,0,0
|
||||
access_saas_plan_manager,saas.plan.manager,model_saas_plan,base.group_system,1,1,1,1
|
||||
access_saas_subscription_user,saas.subscription.user,model_saas_subscription,base.group_user,1,1,1,0
|
||||
access_saas_subscription_public,saas.subscription.public,model_saas_subscription,base.group_public,1,1,1,0
|
||||
|
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="module_category_saas" model="res.groups.privilege">
|
||||
<field name="name">SaaS Portal</field>
|
||||
</record>
|
||||
|
||||
<record id="group_saas_admin" model="res.groups">
|
||||
<field name="name">SaaS Portal Administrator</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="privilege_id" ref="module_category_saas"/>
|
||||
</record>
|
||||
|
||||
<!-- Public users may only see/update their OWN trial request & subscription -->
|
||||
<record id="rule_trial_request_public_own" model="ir.rule">
|
||||
<field name="name">Public: own trial request only</field>
|
||||
<field name="model_id" ref="model_saas_trial_request"/>
|
||||
<field name="domain_force">[('email', '=', user.email)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_public'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.app-card {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
border: 1px solid #dee2e6 !important;
|
||||
}
|
||||
.app-card:hover {
|
||||
border-color: #714B67 !important;
|
||||
}
|
||||
.app-card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
label.app-card.selected {
|
||||
border-color: #714B67 !important;
|
||||
box-shadow: 0 0 0 2px rgba(113, 75, 103, .25);
|
||||
background: #faf7f9;
|
||||
}
|
||||
.app-card-check {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 1px solid #714B67;
|
||||
color: #714B67;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
#selected_apps_list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/** SaaS Trial Portal - Frontend interactions
|
||||
* Drives the 2-step /trial wizard:
|
||||
* Step 1: Choose your Apps (card grid + live sidebar)
|
||||
* Step 2: Your Information (registration form)
|
||||
*
|
||||
* NOTE: Odoo bundles/loads frontend JS assets and this script can run
|
||||
* AFTER DOMContentLoaded has already fired (depending on bundle timing).
|
||||
* Relying only on the DOMContentLoaded event can mean init() never runs.
|
||||
* So we check document.readyState first and run immediately if the DOM
|
||||
* is already parsed, falling back to the event listener otherwise.
|
||||
*/
|
||||
(function () {
|
||||
function init() {
|
||||
console.log('[saas_trial_portal] trial_form.js init');
|
||||
|
||||
var selected = {}; // { appId: appName }
|
||||
|
||||
var countEls = [document.getElementById('selected_count'), document.getElementById('selected_count_2')];
|
||||
var listEl = document.getElementById('selected_apps_list');
|
||||
var sidebar = document.getElementById('trial_sidebar');
|
||||
var continueBtn = document.getElementById('btn_continue');
|
||||
var stepApps = document.getElementById('trial_step_apps');
|
||||
var stepForm = document.getElementById('trial_step_form');
|
||||
var changeBtn = document.getElementById('btn_change_selection');
|
||||
var hiddenAppIds = document.getElementById('app_ids_json');
|
||||
|
||||
// Bail out quietly if we're not on the /trial page at all
|
||||
// (this JS is bundled site-wide via web.assets_frontend).
|
||||
if (!stepApps) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cardCount = document.querySelectorAll('.app-card').length;
|
||||
console.log('[saas_trial_portal] found', cardCount, 'app cards');
|
||||
|
||||
function render() {
|
||||
var ids = Object.keys(selected);
|
||||
countEls.forEach(function (el) { if (el) { el.textContent = ids.length; } });
|
||||
|
||||
if (listEl) {
|
||||
listEl.innerHTML = '';
|
||||
ids.forEach(function (id) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'mb-2';
|
||||
li.textContent = selected[id];
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('d-none', ids.length === 0);
|
||||
}
|
||||
|
||||
if (hiddenAppIds) {
|
||||
hiddenAppIds.value = JSON.stringify(ids);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click handler - resilient to render timing and to
|
||||
// the native <label for="..."> forwarding clicks to its hidden
|
||||
// checkbox.
|
||||
document.addEventListener('click', function (ev) {
|
||||
var card = ev.target.closest('.app-card');
|
||||
if (!card) { return; }
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
var id = card.getAttribute('data-app-id');
|
||||
var name = card.getAttribute('data-app-name');
|
||||
var checkbox = card.querySelector('.app-card-checkbox');
|
||||
var checkMark = card.querySelector('.app-card-check');
|
||||
|
||||
if (selected[id]) {
|
||||
delete selected[id];
|
||||
card.classList.remove('selected');
|
||||
if (checkbox) { checkbox.checked = false; }
|
||||
if (checkMark) { checkMark.classList.add('d-none'); }
|
||||
} else {
|
||||
selected[id] = name;
|
||||
card.classList.add('selected');
|
||||
if (checkbox) { checkbox.checked = true; }
|
||||
if (checkMark) { checkMark.classList.remove('d-none'); }
|
||||
}
|
||||
console.log('[saas_trial_portal] selected apps:', selected);
|
||||
render();
|
||||
});
|
||||
|
||||
if (continueBtn) {
|
||||
continueBtn.addEventListener('click', function () {
|
||||
if (Object.keys(selected).length === 0) { return; }
|
||||
stepApps.classList.add('d-none');
|
||||
stepForm.classList.remove('d-none');
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
});
|
||||
}
|
||||
|
||||
if (changeBtn) {
|
||||
changeBtn.addEventListener('click', function () {
|
||||
stepForm.classList.add('d-none');
|
||||
stepApps.classList.remove('d-none');
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
});
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
// DOM already parsed by the time this script executed - run now.
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem id="menu_saas_root" name="SaaS Portal" sequence="5"/>
|
||||
|
||||
<menuitem id="menu_saas_trial_request" name="Trial Requests"
|
||||
parent="menu_saas_root" action="action_saas_trial_request" sequence="10"/>
|
||||
|
||||
<menuitem id="menu_saas_database" name="Tenant Databases"
|
||||
parent="menu_saas_root" action="action_saas_database" sequence="20"/>
|
||||
|
||||
<menuitem id="menu_saas_subscription" name="Subscriptions"
|
||||
parent="menu_saas_root" action="action_saas_subscription" sequence="30"/>
|
||||
|
||||
<menuitem id="menu_saas_config" name="Configuration"
|
||||
parent="menu_saas_root" sequence="90"/>
|
||||
<menuitem id="menu_saas_plan" name="Plans"
|
||||
parent="menu_saas_config" action="action_saas_plan" sequence="10"/>
|
||||
<menuitem id="menu_saas_app" name="Sellable Apps"
|
||||
parent="menu_saas_config" action="action_saas_app" sequence="20"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_saas_database_list" model="ir.ui.view">
|
||||
<field name="name">saas.database.list</field>
|
||||
<field name="model">saas.database</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="db_name"/>
|
||||
<field name="subdomain"/>
|
||||
<field name="worker_node"/>
|
||||
<field name="state" widget="badge" decoration-success="state=='ready'" decoration-danger="state=='error'"/>
|
||||
<field name="provisioning_seconds"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_saas_database_form" model="ir.ui.view">
|
||||
<field name="name">saas.database.form</field>
|
||||
<field name="model">saas.database</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_provision" type="object" string="Re-run Provisioning" class="btn-primary"
|
||||
invisible="state == 'ready'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,name_generated,db_created,modules_installed,configured,credentials_sent,ready"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="company_name"/>
|
||||
<field name="db_name"/>
|
||||
<field name="subdomain"/>
|
||||
</group>
|
||||
<group string="Instance Config">
|
||||
<field name="worker_node"/>
|
||||
<field name="worker_count"/>
|
||||
<field name="timeout_seconds"/>
|
||||
<field name="memory_limit_mb"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Admin Credentials">
|
||||
<field name="admin_login"/>
|
||||
<field name="admin_password" password="True"/>
|
||||
</group>
|
||||
<group string="Apps">
|
||||
<field name="app_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
<field name="error_message" invisible="state != 'error'"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_saas_database" model="ir.actions.act_window">
|
||||
<field name="name">Tenant Databases</field>
|
||||
<field name="res_model">saas.database</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_saas_plan_list" model="ir.ui.view">
|
||||
<field name="name">saas.plan.list</field>
|
||||
<field name="model">saas.plan</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="duration_months"/>
|
||||
<field name="price"/>
|
||||
<field name="currency_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_saas_plan" model="ir.actions.act_window">
|
||||
<field name="name">Plans</field>
|
||||
<field name="res_model">saas.plan</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record id="view_saas_app_list" model="ir.ui.view">
|
||||
<field name="name">saas.app.list</field>
|
||||
<field name="model">saas.app</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="technical_module_name"/>
|
||||
<field name="category_id"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_saas_app_resync" model="ir.actions.server">
|
||||
<field name="name">Resync from Installed Apps</field>
|
||||
<field name="model_id" ref="model_saas_app"/>
|
||||
<field name="binding_model_id" ref="model_saas_app"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._sync_from_installed_modules()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_saas_app" model="ir.actions.act_window">
|
||||
<field name="name">Sellable Apps</field>
|
||||
<field name="res_model">saas.app</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_saas_subscription_list" model="ir.ui.view">
|
||||
<field name="name">saas.subscription.list</field>
|
||||
<field name="model">saas.subscription</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="trial_request_id"/>
|
||||
<field name="plan_id"/>
|
||||
<field name="is_trial"/>
|
||||
<field name="is_premium"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="state" widget="badge" decoration-success="state=='active'" decoration-danger="state=='payment_failed'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_saas_subscription_form" model="ir.ui.view">
|
||||
<field name="name">saas.subscription.form</field>
|
||||
<field name="model">saas.subscription</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="state" widget="statusbar" statusbar_visible="trial,plan_selected,payment_pending,active"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Account">
|
||||
<field name="trial_request_id"/>
|
||||
<field name="is_trial"/>
|
||||
<field name="is_premium"/>
|
||||
<field name="expiry_date"/>
|
||||
</group>
|
||||
<group string="Billing">
|
||||
<field name="plan_id"/>
|
||||
<field name="payment_provider_code"/>
|
||||
<field name="payment_transaction_id"/>
|
||||
<field name="last_payment_status"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_saas_subscription" model="ir.actions.act_window">
|
||||
<field name="name">Subscriptions</field>
|
||||
<field name="res_model">saas.subscription</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_saas_trial_request_list" model="ir.ui.view">
|
||||
<field name="name">saas.trial.request.list</field>
|
||||
<field name="model">saas.trial.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="company_name"/>
|
||||
<field name="email"/>
|
||||
<field name="state" decoration-success="state=='ready'" decoration-danger="state=='failed'" widget="badge"/>
|
||||
<field name="trial_end_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_saas_trial_request_form" model="ir.ui.view">
|
||||
<field name="name">saas.trial.request.form</field>
|
||||
<field name="model">saas.trial.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,pending_verification,verified,trial_active,provisioning,ready"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Contact">
|
||||
<field name="name"/>
|
||||
<field name="company_name"/>
|
||||
<field name="email"/>
|
||||
<field name="phone"/>
|
||||
</group>
|
||||
<group string="Trial">
|
||||
<field name="app_ids" widget="many2many_tags"/>
|
||||
<field name="trial_start_date" readonly="1"/>
|
||||
<field name="trial_end_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Provisioning">
|
||||
<field name="database_id" readonly="1"/>
|
||||
<field name="subscription_id" readonly="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_saas_trial_request" model="ir.actions.act_window">
|
||||
<field name="name">Trial Requests</field>
|
||||
<field name="res_model">saas.trial.request</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,201 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Step: domain-name.com/trial -> "Choose your Apps" (Step 1: card grid +
|
||||
live sidebar showing count/selection/Continue) -> "Your Information"
|
||||
(Step 2: registration form) - mirrors the odoo.com/trial reference flow -->
|
||||
<template id="trial_signup_template" name="Trial Signup">
|
||||
<t t-call="website.layout">
|
||||
<div class="container-fluid py-5" id="trial_wizard">
|
||||
|
||||
<!-- ================= STEP 1: Choose your Apps ================= -->
|
||||
<div id="trial_step_apps">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-4">Choose your <span class="text-success" style="border-bottom:3px solid #2ECC71;">Apps</span></h1>
|
||||
<p class="lead">Free instant access. No credit card required.</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-9" style="padding-left:20%;">
|
||||
<t t-foreach="app_groups" t-as="group">
|
||||
<h4 class="mb-3 mt-4" t-out="group[0]"/>
|
||||
<div class="row g-3 mb-5">
|
||||
<t t-foreach="group[1]" t-as="app">
|
||||
<div class="col-md-4 pt-2">
|
||||
<label class="app-card d-flex align-items-center p-3 border rounded shadow-sm w-100 position-relative"
|
||||
t-att-for="'app_%s' % app.id"
|
||||
t-att-data-app-id="app.id"
|
||||
t-att-data-app-name="app.name">
|
||||
<input type="checkbox" class="app-card-checkbox d-none"
|
||||
t-att-value="app.id" t-att-id="'app_%s' % app.id"/>
|
||||
<span class="app-card-check d-none">✓</span>
|
||||
<img t-if="app.icon" t-att-src="'/web/image/saas.app/%s/icon' % app.id"
|
||||
class="app-card-icon me-3" alt=""/>
|
||||
<img t-elif="app.icon_url" t-att-src="app.icon_url"
|
||||
class="app-card-icon me-3" alt=""/>
|
||||
<span class="fw-bold" t-out="app.name"/>
|
||||
</label>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Live sidebar - hidden until the user selects at least 1 app -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sticky-top d-none" id="trial_sidebar" style="top: 20px;">
|
||||
<div class="border rounded shadow-sm p-4 mb-3">
|
||||
<h5><span id="selected_count">0</span> Apps selected</h5>
|
||||
<ul id="selected_apps_list" class="list-unstyled mt-3 mb-0"></ul>
|
||||
</div>
|
||||
<div class="border rounded p-3 mb-3 text-center" style="background:#e6f9ec; color:#1e7e42;">
|
||||
<strong>Free, with unlimited users, forever.</strong>
|
||||
</div>
|
||||
<button type="button" id="btn_continue" class="btn btn-primary btn-lg w-100">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= STEP 2: Your Information ================= -->
|
||||
<div id="trial_step_form" class="d-none">
|
||||
<div class="row mb-4">
|
||||
<div class="col-6">
|
||||
<strong><span id="selected_count_2">0</span> apps selected</strong>
|
||||
</div>
|
||||
<div class="col-6 text-end">
|
||||
<button type="button" id="btn_change_selection" class="btn btn-light">Change apps selection</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/trial/submit" method="post" id="trial_form" class="mx-auto" style="max-width:700px;">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="app_ids_json" id="app_ids_json"/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>First and Last Name</label>
|
||||
<input type="text" class="form-control" name="name" required="1"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>Company Name</label>
|
||||
<input type="text" class="form-control" name="company_name"/>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label>Email</label>
|
||||
<input type="email" class="form-control" name="email" required="1"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label>Phone Number</label>
|
||||
<input type="text" class="form-control" name="phone"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label>Country</label>
|
||||
<select class="form-select" name="country_id">
|
||||
<option value="">-</option>
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id" t-out="country.name"/>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label>Language</label>
|
||||
<select class="form-select" name="lang">
|
||||
<t t-foreach="languages" t-as="lang_item">
|
||||
<option t-att-value="lang_item[0]" t-out="lang_item[1]"/>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label>Company size</label>
|
||||
<select class="form-select" name="company_size">
|
||||
<option value="1-5">1 - 5 employees</option>
|
||||
<option value="6-20">6 - 20 employees</option>
|
||||
<option value="21-50">21 - 50 employees</option>
|
||||
<option value="51-200">51 - 200 employees</option>
|
||||
<option value="200+">200+ employees</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label>Primary Interest</label>
|
||||
<select class="form-select" name="primary_interest">
|
||||
<option value="my_company">Use it in my company</option>
|
||||
<option value="client">Use it for a client</option>
|
||||
<option value="explore">Just exploring</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small">
|
||||
By clicking on <strong>Start Now</strong>, you accept our
|
||||
Subscription Agreement and Privacy Policy.
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-5">Start Now</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Step: Create Account & Verify Email -->
|
||||
<template id="trial_check_email_template" name="Check Your Email">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5 text-center">
|
||||
<h2>Check your inbox 📩</h2>
|
||||
<p>We sent a verification link to <strong t-out="email"/>.</p>
|
||||
<p>Click the link in that email to activate your 15-day free trial.</p>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Step: 15-Day Free Trial Activated -->
|
||||
<template id="trial_activated_template" name="Trial Activated">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5 text-center">
|
||||
<h2>🎉 Your 15-day free trial is now active!</h2>
|
||||
<p>Trial ends on <t t-out="trial.trial_end_date"/>.</p>
|
||||
<form action="/trial/continue" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="id" t-att-value="trial.id"/>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Continue</button>
|
||||
</form>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Step: System Generates DB / Provisioning in progress -->
|
||||
<template id="trial_provisioning_template" name="Provisioning">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5 text-center">
|
||||
<h2>Setting up your instance...</h2>
|
||||
<p>This usually takes 30-60 seconds.</p>
|
||||
<div class="spinner-border text-primary" role="status"/>
|
||||
<p class="mt-4">
|
||||
Your instance URL will be:
|
||||
<strong t-out="database.subdomain"/>
|
||||
</p>
|
||||
<p t-if="database.state == 'ready'">
|
||||
✅ Ready! Admin credentials have been sent to your email.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="trial_error_template" name="Trial Error">
|
||||
<t t-call="website.layout">
|
||||
<div class="container py-5 text-center">
|
||||
<h2>Something went wrong</h2>
|
||||
<p t-out="message"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user