[ADD] base modules

This commit is contained in:
Muhammad
2024-04-07 12:43:39 +05:00
parent 311598a929
commit fa3d921e2d
276 changed files with 51186 additions and 0 deletions

6
kk_odoo_saas/__init__.py Executable file
View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
from . import utils
from . import wizards

50
kk_odoo_saas/__manifest__.py Executable file
View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
{
'name': "CT Odoo SaaS",
'summary': """
Out of the Box SaaS Module based on Kubernetes""",
'description': """
Out of the Box SaaS Module based on Kubernetes
""",
'author': "Muhammad Awais",
'website': "https://codetuple.io",
'category': 'Uncategorized',
'version': '2.0.0',
# any module necessary for this one to work correctly
'depends': ['base', 'smile_log', 'website_sale', 'product',
'portal', 'auth_signup_verify_email', 'sale_subscription',
'queue_job'],
'external_dependencies': {
'python': [
'kubernetes',
],
},
# always loaded
'data': [
"security/security.xml",
'security/ir.model.access.csv',
'wizards/saas_app_delete.xml',
'wizards/update_docker_image.xml',
'views/app_views.xml',
'views/config_views.xml',
'views/assets.xml',
'views/saas_app_website.xml',
'views/templates.xml',
'views/sale_subscription.xml',
'views/logs_viewer.xml',
'views/res_config_settings_views.xml',
'views/saas_package_views.xml',
'data/data.xml',
'data/email_templates.xml',
],
'qweb': [
'static/src/xml/base.xml',
],
"application": True,
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import logs_viewer

View File

@@ -0,0 +1,17 @@
from odoo.http import route, request, Controller
import logging
from odoo.addons.portal.controllers.portal import CustomerPortal
_logger = logging.getLogger(__name__)
class SaaSAppsLogViewer(CustomerPortal):
@route("/saas/instance/<int:app_id>", type="http", auth="user", methods=['GET'], website=True)
def saas_app_log_viewer(self, app_id, **values):
saas_app = request.env["kk_odoo_saas.app"].sudo().browse(app_id)
if request.params.get('_'):
logs = saas_app.get_timed_pod_logs(since_seconds=5)
return logs
return request.render(
"kk_odoo_saas.saas_app_log_viewer", values
)

103
kk_odoo_saas/data/data.xml Executable file
View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="product_attribute_subscription" model="product.attribute">
<field name="name">Subscription (SaaS)</field>
<field name="sequence">30</field>
<field name="display_type">select</field>
</record>
<record id="product_attribute_value_subscription_annually" model="product.attribute.value">
<field name="name">Annually</field>
<field name="attribute_id" ref="product_attribute_subscription"/>
<field name="sequence">10</field>
</record>
<record id="product_attribute_value_subscription_monthly" model="product.attribute.value">
<field name="name">Monthly</field>
<field name="attribute_id" ref="product_attribute_subscription"/>
<field name="sequence">20</field>
</record>
<record id="product_attribute_value_subscription_trial" model="product.attribute.value">
<field name="name">Trial</field>
<field name="attribute_id" ref="product_attribute_subscription"/>
<field name="sequence">30</field>
</record>
<record id="product_users" model="product.template">
<field name="name">Users</field>
<field name="sale_ok" eval="True" />
<field name="purchase_ok" eval="False" />
<field name="is_saas_product" eval="True" />
<field name="type">service</field>
<field name="recurring_invoice" eval="True"/>
<field name="list_price">0</field>
</record>
<record id="product_users_attribute_subscription_line" model="product.template.attribute.line">
<field name="product_tmpl_id" ref="product_users" />
<field name="attribute_id" ref="product_attribute_subscription" />
<field name="value_ids" eval="[(6, 0, [
ref('kk_odoo_saas.product_attribute_value_subscription_annually'),
ref('product_attribute_value_subscription_monthly'),
ref('product_attribute_value_subscription_trial'),
])]" />
</record>
<function model="ir.model.data" name="_update_xmlids">
<value model="base" eval="[{
'xml_id': 'kk_odoo_saas.product_users_attribute_subscription_value_annually',
'record': obj().env.ref('kk_odoo_saas.product_users_attribute_subscription_line').product_template_value_ids[0],
'noupdate': True,
}, {
'xml_id': 'kk_odoo_saas.product_users_attribute_subscription_value_monthly',
'record': obj().env.ref('kk_odoo_saas.product_users_attribute_subscription_line').product_template_value_ids[1],
'noupdate': True,
}, {
'xml_id': 'kk_odoo_saas.product_users_attribute_subscription_value_trial',
'record': obj().env.ref('kk_odoo_saas.product_users_attribute_subscription_line').product_template_value_ids[2],
'noupdate': True,
}]"/>
</function>
<record id="product_users_attribute_subscription_value_annually" model="product.template.attribute.value">
<field name="price_extra">120</field>
</record>
<record id="product_users_attribute_subscription_value_monthly" model="product.template.attribute.value">
<field name="price_extra">12.5</field>
</record>
<record id="product_users_attribute_subscription_value_trial" model="product.template.attribute.value">
<field name="price_extra">0</field>
</record>
<function model="ir.model.data" name="_update_xmlids">
<value model="base" eval="[{
'xml_id': 'kk_odoo_saas.product_users_monthly',
'record': obj().env.ref('kk_odoo_saas.product_users')._get_variant_for_combination(obj().env.ref('kk_odoo_saas.product_users_attribute_subscription_value_monthly')),
'noupdate': True,
}, {
'xml_id': 'kk_odoo_saas.product_users_annually',
'record': obj().env.ref('kk_odoo_saas.product_users')._get_variant_for_combination(obj().env.ref('kk_odoo_saas.product_users_attribute_subscription_value_annually')),
'noupdate': True,
}, {
'xml_id': 'kk_odoo_saas.product_users_trial',
'record': obj().env.ref('kk_odoo_saas.product_users')._get_variant_for_combination(obj().env.ref('kk_odoo_saas.product_users_attribute_subscription_value_trial')),
'noupdate': True,
},]"/>
</function>
<data noupdate='1'>
<record id="app_backup_sequence" model="ir.sequence">
<field name="name">Backup Name</field>
<field name="code">saas_app.backup</field>
<field name="prefix">BACKUP</field>
<field name="padding">6</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Email template for new users app -->
<record id="app_invitation_email" model="mail.template">
<field name="name">KK SaaS: App Invitation</field>
<field name="model_id" ref="kk_odoo_saas.model_kk_odoo_saas_app"/>
<field name="subject">Here are the Credentials of your Instance</field>
<field name="email_from">"${object.admin_user.company_id.name | safe}" &lt;${(object.admin_user.company_id.email ) | safe}&gt;</field>
<field name="email_to">${object.admin_user.email_formatted | safe}</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0"
style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size: 10px;">Welcome to SaaS</span>
<br/>
<span style="font-size: 20px; font-weight: bold;">
${object.admin_user.name}
</span>
</td>
<td valign="middle" align="right">
<img src="/logo.png?company=${object.admin_user.company_id.id}"
style="padding: 0px; margin: 0px; height: auto; width: 80px;"
alt="${object.admin_user.company_id.name}"/>
</td>
</tr>
<tr>
<td colspan="2" style="text-align:center;">
<hr width="100%"
style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td>
</tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr>
<td valign="top" style="font-size: 13px;">
<div>
Dear ${object.admin_user.name},
<br/>
<br/>
You have been invited by ${object.create_uid.name} of
${object.admin_user.company_id.name} to connect on SaaS App.
% set website_url = object.get_url()
<br/>
Your SaaS domain is:
<b>
<a href='${website_url}'>${website_url}</a>
</b>
<br/>
Your sign in email is:
<b>
<a href="${website_url}/web/login?login=${object.admin_user.email}"
target="_blank">${object.admin_user.email}
</a>
</b>
<br/>
Your Password is:
<b>
<p>${object.login_pwd}</p>
</b>
<br/>
<br/>
<br/>
<br/>
Enjoy SaaS!
<br/>
--<br/>The ${object.admin_user.company_id.name} Team
</div>
</td>
</tr>
<tr>
<td style="text-align:center;">
<hr width="100%"
style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td>
</tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr>
<td valign="middle" align="left">
${object.admin_user.company_id.name}
</td>
</tr>
<tr>
<td valign="middle" align="left" style="opacity: 0.7;">
${object.admin_user.company_id.phone}
% if object.admin_user.company_id.email
|
<a href="'mailto:%s' % ${object.admin_user.company_id.email}"
style="text-decoration:none; color: #454748;">
${object.admin_user.company_id.email}
</a>
% endif
% if object.admin_user.company_id.website
|
<a href="'%s' % ${object.admin_user.company_id.website}"
style="text-decoration:none; color: #454748;">
${object.admin_user.company_id.website}
</a>
% endif
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</field>
<field name="lang">${object.admin_user.lang}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

13
kk_odoo_saas/models/__init__.py Executable file
View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from . import models
from . import k8s_config
from . import saas_period_product_mixin
from . import product_template
from . import saas_app_website
from . import sale_subscription
from . import sale_order
from . import res_config_settings
from . import saas_package

80
kk_odoo_saas/models/cluster.py Executable file
View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SaasK8sClusterNS(models.Model):
_name = 'kk_odoo_saas.app.cluster.ns'
_description = 'SaaS Cluster NameSpace'
name = fields.Char()
status = fields.Char()
age = fields.Char()
all_json = fields.Text('Complete json')
class SaasK8sClusterPod(models.Model):
_name = 'kk_odoo_saas.app.cluster.pod'
_description = 'SaaS Cluster Pod'
name = fields.Char()
ns = fields.Char()
ready = fields.Char()
status = fields.Char()
restarts = fields.Char()
age = fields.Char()
all_json = fields.Text('Complete json')
class SaasK8sClusterDeployment(models.Model):
_name = 'kk_odoo_saas.app.cluster.deployment'
_description = 'SaaS Cluster Deployment'
name = fields.Char()
ns = fields.Char()
ready = fields.Char()
age = fields.Char()
all_json = fields.Text('Complete json')
class SaasK8sClusterIngress(models.Model):
_name = 'kk_odoo_saas.app.cluster.ingress'
_description = 'SaaS Cluster Ingress'
name = fields.Char()
ns = fields.Char()
hosts = fields.Char()
ing_class = fields.Char()
addresses = fields.Char()
ports = fields.Char()
age = fields.Char()
all_json = fields.Text('Complete json')
class SaasK8sClusterService(models.Model):
_name = 'kk_odoo_saas.app.cluster.service'
_description = 'SaaS Cluster Service'
name = fields.Char()
type_ = fields.Char()
cluster_ip = fields.Char()
external_ip = fields.Char()
ports = fields.Char()
age = fields.Char()
all_json = fields.Text('Complete json')
class SaasK8sClusterPV(models.Model):
_name = 'kk_odoo_saas.app.cluster.pv'
_description = 'SaaS Cluster PV'
name = fields.Char()
capacity = fields.Char()
access_modes = fields.Char()
reclaim_policy = fields.Char()
status = fields.Char()
claim = fields.Char()
storage_class = fields.Char()
reason = fields.Char()
age = fields.Char()
all_json = fields.Text('Complete json')

125
kk_odoo_saas/models/k8s_config.py Executable file
View File

@@ -0,0 +1,125 @@
import psycopg2
from odoo import models, fields, api
from kubernetes import config, client
from odoo.exceptions import UserError
import yaml
import logging
_logger = logging.getLogger(__name__)
class SaaSAppK8sConfig(models.Model):
_name = 'kk_odoo_saas.k8s.config'
_description = 'Kubernetes Cluster Configuration'
name = fields.Char()
config_file = fields.Text('Yaml Configuration File')
cluster_name = fields.Char('')
namespaces = fields.Text('Namespaces in Cluster')
domain_name = fields.Char('Domain Name')
def check_connectivity(self):
if self.config_file:
try:
data2 = yaml.safe_load(self.config_file)
config.load_kube_config_from_dict(data2)
v1 = client.CoreV1Api()
response = v1.list_namespace()
nss = []
for namespace in response.items:
nss.append(namespace.metadata.name)
nl = '\n'
self.namespaces = nl.join(nss)
except config.config_exception.ConfigException as e:
raise UserError("Unable to Connect K8s Cluster")
else:
raise UserError("Please Add config file")
def validate_domain_name(self):
if self.domain_name:
pass
def get_default_config(self):
for conf in self.search([], limit=1):
return conf
def update_cluster_nodes(self):
if self.config_file:
try:
data2 = yaml.safe_load(self.config_file)
config.load_kube_config_from_dict(data2)
v1 = client.CoreV1Api()
response = v1.list_node()
node_env = self.env['kk_odoo_saas.k8s.node']
if response:
node_env.search([]).unlink()
for node in response.items:
node_env.create({'name': node.metadata.name, 'labels': str(node.metadata.labels), 'annotations': str(node.metadata.annotations), 'taints': str(node.spec.taints), 'yaml_info': str(node)})
except config.config_exception.ConfigException as e:
_logger.error(e)
raise UserError("Unable to Connect K8s Cluster")
else:
raise UserError("Please Add config file")
class DockerImages(models.Model):
_name = 'kk_odoo_saas.k8s.docker.images'
name = fields.Char('Image Name', required=True)
tag = fields.Char('Tag Name', required=True, default='latest')
description = fields.Char('Description')
is_pvt_dkr_repo = fields.Boolean('Using Private Docker Repository')
b64_dkr_config = fields.Text('base64 docker config json file')
repo_link = fields.Char('Related Repository')
base_version = fields.Selection([('14.0', '14.0'), ('15.0', '15.0'), ('16.0', '16.0')], required=True)
# base_version is for pulling git code in folder e.g /var/lib/odoo/addons/14.0 etc.
@api.depends('name', 'tag')
def name_get(self):
res = []
for record in self:
name = record.name
if record.tag:
name = name + ':' + record.tag
res.append((record.id, name))
return res
class Node(models.Model):
_name = 'kk_odoo_saas.k8s.node'
name = fields.Char('Node Name')
labels = fields.Text('Labels')
annotations = fields.Text()
taints = fields.Text()
yaml_info = fields.Text("Yaml Description")
class MasterDbCreds(models.Model):
_name = 'kk_odoo_saas.k8s.master_db_creds'
name = fields.Char('DB Server Name')
master_username = fields.Char('Master Username', default='postgres', required=True)
master_pass = fields.Char('Master Password', required=True)
server_url = fields.Char('Server URL', required=True)
server_port = fields.Char('Server Port', default='5432', required=True)
status = fields.Selection([('connected', 'Connected'), ('not_connected', 'Not Connected')], default='not_connected')
def check_connectivity(self):
for rec in self:
if rec.master_username and rec.master_pass and rec.server_url:
try:
conn = psycopg2.connect(database='postgres',
user=rec.master_username,
password=rec.master_pass,
host=rec.server_url,
port=rec.server_port or 5432)
if conn:
_logger.info("Connected to PG DB server")
self.status = 'connected'
return conn
except Exception as e:
_logger.exception(e)
self.status = 'not_connected'
raise UserError('Unable to Connect Postgres.\nPlease Check Postgres Credentials...!')
else:
self.status = 'not_connected'
raise UserError('Please Enter Postgres Credentials...!')

486
kk_odoo_saas/models/models.py Executable file
View File

@@ -0,0 +1,486 @@
# -*- coding: utf-8 -*-
import base64
import psycopg2
import requests
import os
from odoo import models, fields, api, _
from ..utils import k8s_deployment as k8s
from ..utils import ingress, logs
from ..utils import del_git_code as dc
import re
from odoo.exceptions import ValidationError, MissingError
from odoo.addons.smile_log.tools import SmileDBLogger
import logging
import xmlrpc.client
import random
import string
from odoo.addons.queue_job.exception import RetryableJobError
from odoo.exceptions import AccessError
from datetime import timedelta
from ..utils import pg_server as pgx
_logger = logging.getLogger(__name__)
class SaaSAppSslSecret(models.Model):
_name = 'kk_odoo_saas.app.ssl_secret'
name = fields.Char('Secret Name')
class SaaSApp(models.Model):
_name = 'kk_odoo_saas.app'
_description = 'SaaS App'
_inherit = ['mail.thread', 'mail.activity.mixin']
app_name = fields.Char('App Unique Id',
default=lambda self: self.env['ir.sequence'].next_by_code('kk_odoo_saas.app'), tracking=True, copy=False)
name = fields.Char(tracking=True)
is_custom_image = fields.Boolean(default=True)
docker_image = fields.Many2one('kk_odoo_saas.k8s.docker.images')
is_pvt_dkr_repo = fields.Boolean('Using Private Docker Repository')
is_extra_addon = fields.Boolean('Use Extra Addons')
extra_addons = fields.Char('Git Url', tracking=True)
is_private_repo = fields.Boolean('Is Private Repository?')
git_token = fields.Char('Auth Token')
# K8s values
client = fields.Many2one('res.partner', related='admin_user.partner_id', tracking=True)
country_id = fields.Many2one(string="Country", comodel_name='res.country',
help="Country for which this instance is being deployed")
admin_user = fields.Many2one("res.users", "Client User", tracking=True)
def _get_default_cluster_config(self):
cluster = self.env['kk_odoo_saas.k8s.config'].search([], limit=1)
if cluster:
return cluster.id
return False
configuration = fields.Many2one('kk_odoo_saas.k8s.config', string='Configuration', default=_get_default_cluster_config)
domain_name = fields.Char(related='configuration.domain_name')
sub_domain_name = fields.Char(required=True)
is_dedicated_node = fields.Boolean(string='Any Dedicated Node')
node_id = fields.Many2one('kk_odoo_saas.k8s.node', string='Node')
node_key = fields.Char()
node_value = fields.Char()
demo_data = fields.Boolean('Install Demo Data')
status = fields.Selection([('d', 'Draft'), ('l', 'Launched'), ('m', 'Modified'), ('del', 'Deleted')],
string='Status', default='d', tracking=True)
expiry_date = fields.Date(tracking=True)
subscription_id = fields.Many2one('sale.subscription', string='Related Subscription', tracking=True)
notes = fields.Text()
module_ids = fields.Many2many(comodel_name='saas.app', string='Modules to install')
login_email = fields.Char('Login Email')
login_pwd = fields.Char('Login Pwd')
master_login_email = fields.Char('Master Login Email')
master_login_pwd = fields.Char('Master Login Pwd')
custom_domain_ids = fields.One2many('saas.app.custom.domain', 'saas_app_id', string='Custom Domains')
def _default_db_name(self):
return self.sub_domain_name
k8s_logs = fields.Many2many('smile.log', string='K8s Logs', compute='_get_k8s_logs')
# db server relation
def _get_default_db_server(self):
db_server = self.env['kk_odoo_saas.k8s.master_db_creds'].search([], limit=1)
if db_server:
return db_server
return False
db_server_id = fields.Many2one('kk_odoo_saas.k8s.master_db_creds', string="DB Server", default=_get_default_db_server)
client_db_name = fields.Char("Database Name", required=True)
login_url = fields.Char('Login URL', compute='_get_instance_login_url')
# _sql_constraints = [
# ('hostname_uniq', 'unique(hostname)', "A Domain already exists. Domain's name must be unique!"),
# ]
@api.model
def create(self, values):
_logger = logging.getLogger(__name__)
if self:
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
res = super(SaaSApp, self).create(values)
if not res.validate_domain_name():
_logger.error('Either Domain or Subdomain is not valid')
raise ValidationError('Either Domain or Subdomain is not valid')
return res
def write(self, vals):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
# if vals and 'status' not in vals and self.status not in ['d', 'del']:
# vals.update({'status': 'm'})
res = super(SaaSApp, self).write(vals)
if 'custom_domain_ids' in vals:
#todo: add validation, limit number of domains per instance
self.update_app()
if 'sub_domain_name' or 'domain_name' in vals:
if not self.validate_domain_name():
_logger.error('Either Domain or Subdomain is not valid')
raise ValidationError('Either Domain or Subdomain is not valid')
return res
@api.onchange('app_name')
def set_sub_domain_name(self):
self.sub_domain_name = self.app_name
# also set the database name
self.client_db_name = self.app_name
def validate_domain_name(self):
if self.domain_name and self.sub_domain_name:
full_name = self.sub_domain_name + self.domain_name
domain_regex = r'(([\da-zA-Z])([_\w-]{,62})\.){,127}(([\da-zA-Z])[_\w-]{,61})?([\da-zA-Z]\.((xn\-\-[a-zA-Z\d]+)|([a-zA-Z\d]{2,})))'
domain_regex = '{0}$'.format(domain_regex)
valid_domain_name_regex = re.compile(domain_regex, re.IGNORECASE)
full_name = full_name.lower().strip()
if re.match(valid_domain_name_regex, full_name):
return True
return
def deploy_app(self):
self.ensure_one()
k8s.create_deployment(app_name=self.app_name, config_file=self.configuration.config_file, self=self)
ingress.create_ingress(app_name=self.app_name, self=self)
self.status = 'l'
self.with_delay().post_init_tasks()
def delete_app_from_wizard(self, delete_db, delete_pv, delete_svc, delete_ing, delete_deployment):
# if delete_db:
# self.delete_database_remotely(db_master_pwd=db_master_pwd)
k8s.delete_app_with_options(self, delete_db, delete_pv, delete_svc, delete_ing, delete_deployment)
self.status = 'del'
def update_app(self):
k8s.update_app(self)
self.status = 'l'
def get_url(self):
return "http://{0}{1}".format(self.sub_domain_name, self.domain_name)
def deploy_apps_from_git(self):
k8s.deploy_apps_from_git(self)
def restart_odoo_service(self):
k8s.restart_odoo_service(self)
def action_show_subscription(self):
self.ensure_one()
assert self.subscription_id, "This app is not associated with any Subscription"
return {
"type": "ir.actions.act_window",
"name": "Subscription",
"res_model": "sale.subscription",
"res_id": self.subscription_id.id,
"view_mode": "form",
}
def action_create_subscription(self):
self.ensure_one()
assert not self.subscription_id, "This app is already associated with Subscription"
return {
"type": "ir.actions.act_window",
"name": "Subscription",
"res_model": "sale.subscription",
"view_mode": "form",
"context": {
"default_name": self.name + "'s SaaS Subscription",
"default_build_id": self.id,
"default_partner_id": self.client.id,
}
}
def _get_instance_login_url(self):
for app in self:
app.login_url = ''
response, db = pgx.get_admin_credentials(app)
if response and db:
app.login_url = "https://{0}{1}/saas/login?db={2}&login={3}&passwd={4}".format(self.sub_domain_name,
self.domain_name, db, response[0][0],
response[0][1])
else:
_logger.info("Unknown Error!")
def action_connect_instance(self):
self.ensure_one()
response, db = pgx.get_admin_credentials(self)
if response and db:
login = response[0][0]
password = response[0][1]
login_url = "https://{0}{1}/saas/login?db={2}&login={3}&passwd={4}".format(self.sub_domain_name, self.domain_name, db, login, password)
_logger.info("Login URL %r " % (login_url))
return {
'type': 'ir.actions.act_url',
'url': login_url,
'target': 'new',
}
else:
_logger.info("Unknown Error!")
def create_instance_admin_user_for_client(self, models1, db, uid, password, client_pwd):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
adm_user_id = models1.execute_kw(db, uid, password,
'res.users', 'search_read',
[[['login', '=', 'admin']]], {'fields': ['groups_id'], 'limit': 1}
)
if adm_user_id:
adm_user_id = adm_user_id[0]
groups_ids = adm_user_id.get('groups_id', False)
if groups_ids:
new_user_id = models1.execute_kw(db, uid, password, 'res.users', 'create',
[{'name': self.client.name,
'login': self.client.email,
'company_ids': [1], 'company_id': 1,
'password': client_pwd}])
if new_user_id:
_logger.info('Created client Account on instance')
return models1.execute_kw(db, uid, password, 'res.groups', 'write',
[groups_ids, {'users': [(4, new_user_id)]}])
except xmlrpc.client.Error as e:
_logger.error(str(e))
return
def reset_apps_admin_pwd(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
protocol = 'https'
url = protocol + '://{0}{1}'.format(self.sub_domain_name, self.domain_name)
db = self.sub_domain_name
new_pwd_client = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
new_pwd_master = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
username = 'admin'
password = 'admin'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, password, {})
_logger.info('Sending request to app with uid {}'.format(uid))
models1 = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
try:
if self.create_instance_admin_user_for_client(models1, db, uid, password, new_pwd_client):
_logger.info('Updated Client User\'s Access Rights on instance')
self.update({'login_pwd': new_pwd_client, 'login_email': self.client.email})
adm_user_id = models1.execute_kw(db, uid, password,
'res.users', 'search',
[[['login', '=', 'admin']]], {'limit': 1}
)[0]
if models1.execute_kw(db, uid, password, 'res.users', 'write', [[adm_user_id], {
'password': new_pwd_master,
}]):
self.update({'master_login_pwd': new_pwd_master, 'master_login_email': 'admin'})
self.send_app_pwd_cred_email()
_logger.info('Password and login changed Successfully')
except xmlrpc.client.Error as e:
_logger.error(str(e))
except xmlrpc.client.Error as e:
_logger.error(str(e))
def set_user_country(self):
country_code = self.country_id.code
if self.country_id and self.country_id.code:
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
protocol = 'https'
url = protocol + '://{0}{1}'.format(self.sub_domain_name, self.domain_name)
db = self.sub_domain_name
username = 'admin'
password = 'admin'
try:
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url))
uid = common.authenticate(db, username, password, {})
_logger.info('Sending request to app with uid {}'.format(uid))
models1 = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url))
try:
country = models1.execute_kw(db, uid, password,
'res.country', 'search_read',
[[['code', 'ilike', country_code]]], {'fields': ['id'], 'limit': 1}
)
if country:
country = country[0]
if country:
new_user_id = models1.execute_kw(db, uid, password, 'res.company', 'write',
[[1], {'country_id': country_code and self.country_id.id,
'currency_id': country_code and self.country_id.currency_id.id}])
if new_user_id:
_logger.info('Updated country of the user')
except xmlrpc.client.Error as e:
_logger.error(str(e))
except xmlrpc.client.Error as e:
_logger.error(str(e))
def post_init_tasks(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
if not self.check_site_assible():
_logger.info('Waiting for the App to become live....')
raise RetryableJobError('Unable to get the app live.')
else:
self.set_user_country()
self.reset_apps_admin_pwd()
def check_site_assible(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
resp = requests.get('http://' + self.sub_domain_name + self.domain_name)
_logger.info('App, sent this status code {}'.format(resp.status_code))
if resp.status_code == 200:
return True
else:
return False
except Exception as e:
_logger.error(str(e))
return False
def send_app_pwd_cred_email(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
template = False
try:
template = self.env.ref('kk_odoo_saas.app_invitation_email', raise_if_not_found=False)
except ValueError:
pass
assert template._name == 'mail.template'
template_values = {
'email_to': '${object.admin_user.email|safe}',
'email_cc': False,
'auto_delete': True,
'partner_to': False,
'scheduled_date': False,
}
template.write(template_values)
if not self.admin_user.email:
_logger.error(_("Cannot send email: user %s has no email address.", self.admin_user.name))
with self.env.cr.savepoint():
try:
template.send_mail(self.id, force_send=True, raise_exception=True)
except Exception as e:
_logger.error(str(e))
_logger.info(_("App Details email sent for user <%s> to <%s>", self.admin_user.login, self.admin_user.email))
def _message_get_suggested_recipients(self):
recipients = super(SaaSApp, self)._message_get_suggested_recipients()
try:
for saas_app in self:
if saas_app.client:
saas_app._message_add_suggested_recipient(recipients, partner=saas_app.client, reason=_('SaaS Client'))
except AccessError: # no read access rights -> just ignore suggested recipients because this imply modifying followers
pass
return recipients
def _get_k8s_logs(self):
for app in self:
app.k8s_logs = False
logs_ = self.env['smile.log'].search([('res_id', '=', app.id), ('model_name', '=', self._name)])
for log in logs_:
app.k8s_logs = [(4, log.id)]
def get_pod_logs(self):
output = logs.read_logs(app_name=self.app_name, config_file=self.configuration.config_file, self=self, tail_lines=None)
if output:
result = base64.b64encode(output.encode())
attachment_obj = self.env['ir.attachment']
# create attachment
attachment_id = attachment_obj.create(
{'name': self.app_name+'-odoo-logs.log', 'datas': result, 'public': False})
# prepare download url
download_url = '/web/content/' + str(attachment_id.id) + '?download=true'
# download
return {
"type": "ir.actions.act_url",
"url": str(download_url),
"target": "new",
}
else:
raise MissingError('Unable to get logs \nReason: Running Pod / Container not found')
def action_log_viewer(self):
return {
"type": "ir.actions.act_url",
"url": "/saas/instance/{app_id}".format(app_id=self.id),
"target": "new",
}
def get_timed_pod_logs(self, interval=None, since_seconds=None, previous=None, tail_lines=None):
output = logs.read_logs(app_name=self.app_name, config_file=self.configuration.config_file, self=self, since_seconds=since_seconds)
if output:
return output
def update_docker_image(self, container_arguments, env_vars=False):
patched_deployment = k8s.update_deployment(self=self, container_arguments=container_arguments, env_vars=env_vars)
if patched_deployment:
self.env['bus.bus'].sendone(
(self._cr.dbname, 'res.partner', self.env.user.partner_id.id),
{'type': 'simple_notification', 'title': 'Image Update in Progress',
'message': 'Deployment in Progress with latest docker image'}
)
return True
def get_odoo_deployment(self):
deployment = k8s.read_deployment(self=self)
if deployment:
return deployment
return False
def refresh_node_list(self):
if self.configuration:
self.configuration.update_cluster_nodes()
def get_pg_db_connection(self, db='postgres'):
for rec in self:
if rec.db_server_id.master_pass and rec.db_server_id.master_username and rec.db_server_id.server_url:
try:
_logger.info("Going to connect to PG DB server")
conn = psycopg2.connect(database=db,
user=rec.db_server_id.master_username,
password=rec.db_server_id.master_pass,
host=rec.db_server_id.server_url,
port=rec.db_server_id.server_port or 5432
)
if conn:
_logger.info("Connected to PG DB server")
return conn
except Exception as e:
_logger.exception(e)
return
else:
return
def del_git_dir(self):
base_version = self.docker_image.base_version
delete_path = "/var/lib/odoo/addons/" + str(base_version)
dc.del_git_dir(self, path=delete_path)
class SaaSAppDomain(models.Model):
_name = 'saas.app.custom.domain'
_description = 'SaaS App Custom Domain'
name = fields.Char('Domain Name', required=True)
saas_app_id = fields.Many2one('kk_odoo_saas.app')
ssl = fields.Boolean('Enable SSL?', default=True)
class DockerAccount(models.Model):
_name = 'saas.docker.hub.account'
username = fields.Char('docker hub username')
pwd = fields.Char('Password or Access Token')
# for more info https://docs.docker.com/docker-hub/access-tokens/

View File

@@ -0,0 +1,16 @@
from odoo import fields, models, api
class ProductTemplate(models.Model):
_inherit = "product.template"
saas_app_id = fields.Many2one("saas.app", ondelete="cascade", index=True)
saas_package_id = fields.Many2one("saas.package", ondelete="cascade", index=True)
is_saas_product = fields.Boolean("Is SaaS product?", default=False)
@api.model
def create(self, vals):
if vals.get("is_saas_product"):
vals["taxes_id"] = [(5,)]
vals["supplier_taxes_id"] = [(5,)]
return super(ProductTemplate, self).create(vals)

View File

@@ -0,0 +1,34 @@
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
show_packages = fields.Boolean(
"Show packages", config_parameter="kk_odoo_saas.show_packages"
)
show_apps = fields.Boolean("Show apps", config_parameter="kk_odoo_saas.show_apps")
show_buy_now_button = fields.Boolean(
"Show 'Buy now' button", config_parameter="kk_odoo_saas.show_buy_now_button"
)
show_try_trial_button = fields.Boolean(
"Show 'Try trial' button", config_parameter="kk_odoo_saas.show_try_trial_button"
)
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
select_type = self.env["ir.config_parameter"].sudo()
packages = select_type.get_param("kk_odoo_saas.show_packages")
apps = select_type.get_param("kk_odoo_saas.show_apps")
buy_now_button = select_type.get_param("kk_odoo_saas.show_buy_now_button")
try_trial_button = select_type.get_param("kk_odoo_saas.show_try_trial_button")
# fmt: off
res.update({
"show_packages": packages,
"show_apps": apps,
"show_buy_now_button": buy_now_button,
"show_try_trial_button": try_trial_button,
})
# fmt: on
return res

View File

View File

@@ -0,0 +1,102 @@
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class SaasApp(models.Model):
_name = "saas.app"
_description = "SaaS App"
_order = "name"
_inherit = "saas.period.product.mixin"
name = fields.Char("Technical Name", required=True, index=True)
shortdesc = fields.Char("Module Name", required=True)
dependency_ids = fields.Many2many("saas.app", "saas_apps_dependency_rel", "dep_id", "app_id", string="Dependencies")
icon_image = fields.Binary("Icon")
allow_to_sell = fields.Boolean(default=True, string="Sellable")
@api.model
def create(self, vals):
res = super(SaasApp, self).create(vals)
if not res.product_tmpl_id:
res.product_tmpl_id = self.env["product.template"].create({
"name": res.shortdesc,
"image_1920": res.icon_image,
"saas_app_id": res.id,
"is_saas_product": True,
"type": 'service',
"purchase_ok": False,
"subscription_template_id": self.env.ref("sale_subscription.monthly_subscription").id,
"recurring_invoice": True,
"website_published": True,
"list_price": 0,
})
return res
def write(self, vals):
res = super(SaasApp, self).write(vals)
if vals.get('month_price', None) is not None or vals.get('year_price', None) is not None:
self._update_variant_prices()
return res
def _update_variant_prices(self):
for app in self:
for variant in app.product_tmpl_id.product_variant_ids:
for attr in variant.product_template_attribute_value_ids:
if attr.name == "Monthly":
attr.update({'price_extra': app.month_price})
if attr.name == "Annually":
attr.update({'price_extra': app.year_price})
def _search_or_create(self, ir_module):
app = self.search([("name", "=", ir_module.name)])
if not app:
app = self.env["saas.app"].create({
"name": ir_module.name,
"shortdesc": ir_module.shortdesc,
"icon_image": ir_module.icon_image
})
return app
def dependencies_str(self):
self.ensure_one()
visited_saas_module_ids = set()
def make_list(deps):
result = []
for dep in deps:
if dep.id in visited_saas_module_ids:
continue
visited_saas_module_ids.add(dep.id)
result += [dep.name] + make_list(dep.dependency_ids)
return result
return ",".join(make_list(self.dependency_ids))
@api.model
def action_make_applist_from_local_instance(self):
for x in map(self.browse, self._search([])):
x.unlink()
def walk(parent_ir_module_name, parent_app_name=None):
modules = self.env["ir.module.module.dependency"].sudo().search([("name", "=", parent_ir_module_name)]).mapped("module_id")
for m in modules:
app_name = None
if m.application:
app = self.env["saas.app"]._search_or_create(m)
if parent_app_name:
app.dependency_ids |= self.env["saas.app"].search([("name", "=", parent_app_name)])
app_name = app.name
else:
app_name = parent_app_name
walk(m.name, app_name)
walk("base")

View File

@@ -0,0 +1,61 @@
import logging
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError, UserError
_logger = logging.getLogger(__name__)
class SaasPackage(models.Model):
_name = "saas.package"
_inherit = ["saas.period.product.mixin"]
is_published = fields.Boolean("Publish It?", default=True)
package_image = fields.Image(
string='Package image'
)
name = fields.Char(copy=False)
module_ids = fields.Many2many('saas.app', string="Modules to install")
docker_image = fields.Many2one('kk_odoo_saas.k8s.docker.images', 'Related Docker Image')
stripe_product_id = fields.Char('Stripe Id')
subscription_template = fields.Many2one('sale.subscription.template')
@api.model
def create(self, vals):
res = super(SaasPackage, self).create(vals)
if not res.product_tmpl_id:
res.product_tmpl_id = self.env["product.template"].create({
"name": res.name,
"image_1920": res.package_image,
"saas_package_id": res.id,
"is_saas_product": True,
"type": 'service',
"purchase_ok": False,
"subscription_template_id": self.env.ref("sale_subscription.monthly_subscription").id,
"recurring_invoice": True,
"website_published": True,
"list_price": 0,
})
return res
def write(self, vals):
res = super(SaasPackage, self).write(vals)
if vals.get('month_price', None) is not None or vals.get('year_price', None) is not None:
self._update_variant_prices()
return res
def _update_variant_prices(self):
for app in self:
for variant in app.product_tmpl_id.product_variant_ids:
for attr in variant.product_template_attribute_value_ids:
if attr.name == "Monthly":
attr.update({'price_extra': app.month_price})
if attr.name == "Annually":
attr.update({'price_extra': app.year_price})
def refresh_page(self):
# Empty-function for purpose of refreshing page
pass

View File

@@ -0,0 +1,54 @@
from odoo import api, models, fields
class SaasPeriodProductMixin(models.AbstractModel):
_name = "saas.period.product.mixin"
_description = "Period Product Mixin"
product_tmpl_id = fields.Many2one("product.template", ondelete="cascade", readonly=True)
month_product_id = fields.Many2one("product.product", string="Product for monthly subscription", compute="_compute_product_ids", store=True)
year_product_id = fields.Many2one("product.product", string="Product for annually subscription", compute="_compute_product_ids", store=True)
currency_id = fields.Many2one("res.currency", related="product_tmpl_id.currency_id")
# TODO: when following fields are written, you need to update prices on product.product
month_price = fields.Float("Month price", default=0.0)
year_price = fields.Float("Year price", default=0.0)
@api.depends("product_tmpl_id")
def _compute_product_ids(self):
patvs_month = self.env.ref("kk_odoo_saas.product_attribute_value_subscription_monthly")
patvs_year = self.env.ref("kk_odoo_saas.product_attribute_value_subscription_annually")
attr = self.env.ref("kk_odoo_saas.product_attribute_subscription")
for app in self:
if not app.product_tmpl_id:
app.month_product_id = app.year_product_id = self.env["product.product"]
continue
line = self.env["product.template.attribute.line"].sudo().search([
("product_tmpl_id", "=", app.product_tmpl_id.id),
("attribute_id", "=", attr.id),
])
if not line:
line = line.create({
"product_tmpl_id": app.product_tmpl_id.id,
"attribute_id": attr.id,
"value_ids": [(6, 0, [
patvs_year.id, patvs_month.id,
])]
})
ptv_ids = line.product_template_value_ids
month_ptv = ptv_ids.filtered(lambda x: x.product_attribute_value_id == patvs_month)
month_ptv.write({
"price_extra": app.month_price
})
app.month_product_id = month_ptv.ptav_product_variant_ids[:1]
year_ptv = ptv_ids.filtered(lambda x: x.product_attribute_value_id == patvs_year)
year_ptv.write({
"price_extra": app.year_price
})
app.year_product_id = year_ptv.ptav_product_variant_ids[:1]

View File

@@ -0,0 +1,86 @@
from odoo import fields, models
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
build_id = fields.Many2one("kk_odoo_saas.app")
is_pkg_pdt = fields.Boolean(default=False)
def _split_subscription_lines(self):
"""Split the order line according to subscription templates that must be created."""
self.ensure_one()
res = dict()
for line in self.order_line:
if line.product_id:
for p_id, p_name in line.product_id.name_get():
if '(Annually)' in p_name:
line.product_id.update(
{'subscription_template_id': self.env.ref('sale_subscription.yearly_subscription').id})
elif '(Monthly)' in p_name:
line.product_id.update(
{'subscription_template_id': self.env.ref('sale_subscription.monthly_subscription').id})
new_sub_lines = self.order_line.filtered(lambda
l: not l.subscription_id and l.product_id.subscription_template_id and l.product_id.recurring_invoice)
templates = new_sub_lines.mapped('product_id').mapped('subscription_template_id')
for template in templates:
lines = self.order_line.filtered(
lambda l: l.product_id.subscription_template_id == template and l.product_id.recurring_invoice)
res[template] = lines
return res
def _action_confirm(self):
"""Update and/or create subscriptions on order confirmation."""
res = super(SaleOrder, self)._action_confirm()
# self.create_saas_app_from_subscription()
return res
def create_saas_app_from_subscription(self):
for so in self:
lines = so.order_line.filtered(lambda l: l.subscription_id is not False)
p_ids = so.order_line.mapped('product_id')
if lines and p_ids:
saas_app_ids = [app.id for app in self.env['saas.app'].search([('year_product_id', 'in', p_ids.ids)])]
if not saas_app_ids:
saas_app_ids = [app.id for app in self.env['saas.app'].search([('month_product_id', 'in', p_ids.ids)])]
line = lines[0]
sub_id = line.subscription_id
pkg = False
if so.is_pkg_pdt:
pkg = self.env['saas.package'].search([('year_product_id', 'in', p_ids.ids)])
if not pkg:
pkg = self.env['saas.package'].search([('month_product_id', 'in', p_ids.ids)])
if pkg:
saas_app_ids = pkg.module_ids.ids
if so and so.build_id and sub_id:
so.build_id.update({'subscription_id': sub_id.id,
'module_ids': [(6, 0, saas_app_ids)]
})
sub_id.build_id = so.build_id
so.build_id.deploy_app()
else:
saas_app_env = self.env['kk_odoo_saas.app']
def_vals = saas_app_env.default_get(fields_list=['app_name', ])
if self.partner_id.user_ids:
def_vals['admin_user'] = self.partner_id.user_ids.ids[0]
configurations = self.env["kk_odoo_saas.k8s.config"]
config = configurations.get_default_config()
if config:
def_vals['configuration'] = config.id
def_vals['sub_domain_name'] = def_vals.get('app_name')
def_vals['subscription_id'] = sub_id.id
def_vals['module_ids'] = [(6, 0, saas_app_ids)]
def_vals['docker_image'] = pkg.docker_image.id
def_vals['name'] = '{}\'s SaaS App'.format(self.partner_id.name)
saas_app = saas_app_env.create(def_vals)
sub_id.build_id = saas_app.id
self.build_id = saas_app.id
_logger.info('Going to Deploy SaaS App, Subscription is going to start')
saas_app.deploy_app()
else:
_logger.error('Cant create SaaS App, No K8s configuration found')

View File

@@ -0,0 +1,20 @@
from odoo import fields, models, api
import logging
_logger = logging.getLogger(__name__)
class SaleSubscription(models.Model):
_inherit = 'sale.subscription'
build_id = fields.Many2one("kk_odoo_saas.app", string="Related SaaS Instance")
is_saas = fields.Boolean('Is SaaS Subscription')
def start_subscription(self):
res = super(SaleSubscription, self).start_subscription()
if self.build_id:
self.build_id.deploy_app()
return res
@api.model
def create(self, vals):
res = super(SaleSubscription, self).create(vals)
return res

View File

@@ -0,0 +1,17 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_saas_app_client,kk_odoo_saas.kk_odoo_saas,model_kk_odoo_saas_app,kk_odoo_saas.group_saas_client,1,0,0,0
access_kk_odoo_saas_k8s_config,kk_odoo_saas.kk_odoo_saas_k8s_config,model_kk_odoo_saas_k8s_config,base.group_user,1,1,1,1
access_kk_odoo_saas_k8s_docker_images_config,kk_odoo_saas.kk_odoo_saas_docker_images_config,model_kk_odoo_saas_k8s_docker_images,base.group_user,1,1,1,1
access_saas_app_user,access_saas_app_user,model_saas_app,base.group_user,1,1,1,1
access_saas_app_admin,access_saas_app_admin,model_saas_app,kk_odoo_saas.group_saas_manager,1,1,1,1
access_kk_odoo_saas_k8s_node,access_kk_odoo_saas.k8s.node,model_kk_odoo_saas_k8s_node,kk_odoo_saas.group_saas_manager,1,1,1,1
access_kk_odoo_saas_app_delete_wizard,access_saas_app_delete_wizard,model_kk_odoo_saas_app_delete_wizard,kk_odoo_saas.group_saas_manager,1,1,1,1
access_kk_odoo_saas_app_update_dkr_img_wizard,access_kk_odoo_saas_app_update_dkr_img_wizard,model_kk_odoo_saas_app_update_dkr_img_wizard,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_app_secret_admin,access_saas_app_secret_admin,model_kk_odoo_saas_app_ssl_secret,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_app_custom_domain,admin_access_saas_app_domain,model_saas_app_custom_domain,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_app_custom_domain_portal,portal_access_saas_app_domain,model_saas_app_custom_domain,base.group_portal,1,0,0,0
access_kk_odoo_saas_master_db_creds,kk_odoo_saas.kk_odoo_saas_master_db_creds,model_kk_odoo_saas_k8s_master_db_creds,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_package_admin,access_saas_package_admin,model_saas_package,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_app_container_argument,access_saas_app_container_argument,model_saas_app_container_argument,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_app_container_env_var,access_saas_app_container_env_var,model_saas_app_container_env_var,kk_odoo_saas.group_saas_manager,1,1,1,1
access_saas_app_manager,kk_odoo_saas.kk_odoo_saas,model_kk_odoo_saas_app,kk_odoo_saas.group_saas_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_saas_app_client kk_odoo_saas.kk_odoo_saas model_kk_odoo_saas_app kk_odoo_saas.group_saas_client 1 0 0 0
3 access_kk_odoo_saas_k8s_config kk_odoo_saas.kk_odoo_saas_k8s_config model_kk_odoo_saas_k8s_config base.group_user 1 1 1 1
4 access_kk_odoo_saas_k8s_docker_images_config kk_odoo_saas.kk_odoo_saas_docker_images_config model_kk_odoo_saas_k8s_docker_images base.group_user 1 1 1 1
5 access_saas_app_user access_saas_app_user model_saas_app base.group_user 1 1 1 1
6 access_saas_app_admin access_saas_app_admin model_saas_app kk_odoo_saas.group_saas_manager 1 1 1 1
7 access_kk_odoo_saas_k8s_node access_kk_odoo_saas.k8s.node model_kk_odoo_saas_k8s_node kk_odoo_saas.group_saas_manager 1 1 1 1
8 access_kk_odoo_saas_app_delete_wizard access_saas_app_delete_wizard model_kk_odoo_saas_app_delete_wizard kk_odoo_saas.group_saas_manager 1 1 1 1
9 access_kk_odoo_saas_app_update_dkr_img_wizard access_kk_odoo_saas_app_update_dkr_img_wizard model_kk_odoo_saas_app_update_dkr_img_wizard kk_odoo_saas.group_saas_manager 1 1 1 1
10 access_saas_app_secret_admin access_saas_app_secret_admin model_kk_odoo_saas_app_ssl_secret kk_odoo_saas.group_saas_manager 1 1 1 1
11 access_saas_app_custom_domain admin_access_saas_app_domain model_saas_app_custom_domain kk_odoo_saas.group_saas_manager 1 1 1 1
12 access_saas_app_custom_domain_portal portal_access_saas_app_domain model_saas_app_custom_domain base.group_portal 1 0 0 0
13 access_kk_odoo_saas_master_db_creds kk_odoo_saas.kk_odoo_saas_master_db_creds model_kk_odoo_saas_k8s_master_db_creds kk_odoo_saas.group_saas_manager 1 1 1 1
14 access_saas_package_admin access_saas_package_admin model_saas_package kk_odoo_saas.group_saas_manager 1 1 1 1
15 access_saas_app_container_argument access_saas_app_container_argument model_saas_app_container_argument kk_odoo_saas.group_saas_manager 1 1 1 1
16 access_saas_app_container_env_var access_saas_app_container_env_var model_saas_app_container_env_var kk_odoo_saas.group_saas_manager 1 1 1 1
17 access_saas_app_manager kk_odoo_saas.kk_odoo_saas model_kk_odoo_saas_app kk_odoo_saas.group_saas_manager 1 1 1 1

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record model="ir.module.category" id="module_category_saas">
<field name="name">SaaS Management</field>
<field name="sequence">22</field>
</record>
<record id="group_saas_manager" model="res.groups">
<field name="name">SaaS Manager</field>
<field name="category_id" ref="module_category_saas" />
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
/>
</record>
<record id="group_saas_client" model="res.groups">
<field name="name">SaaS Client</field>
<field name="category_id" ref="module_category_saas" />
</record>
<!-- manager rules start-->
<!-- manager rules end-->
<!-- customer rules start-->
<!-- only show its own apps to customers-->
<record id="rule_own_saas_apps_only" model="ir.rule">
<field name="name">Personal SaaS App Visibility to Customer</field>
<field ref="model_kk_odoo_saas_app" name="model_id"/>
<field name="domain_force">[('admin_user','=',user.id)]</field>
<field name="groups" eval="[(4, ref('kk_odoo_saas.group_saas_client'))]"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

10037
kk_odoo_saas/static/src/css/bootstrap.css vendored Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
.col-lg-12{
flex: 0 0 100%;
max-width: 100%;
}
#price{
display: inline;
}
.pricing-card-title{
font-size: 32px;
}
.users-qty-change-buttons{
max-width: 30px;
max-height: 30px;
opacity: 0.5;
cursor: pointer;
}
.loader{
position: fixed;
z-index: 99;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: white;
display: flex;
justify-content: center;
align-items: center;
opacity: 0.5;
}
.status{
font-size: 26px;
color: black;
position: fixed;
margin-top: -70px;
}
.loader > img {
width: 100px;
opacity: 0.6;
transform: rotate(-30deg);
}
.transition {
transition: 0.3s;
}
.app, .package{
max-height: 95px;
display: flex;
margin-bottom: 10px;
min-width: 174px;
cursor: pointer;
}
.app-data{
margin-left: 5%;
}
.price-value{
display: inline;
}
.period{
display: inline;
}
.green-border{
border: 2px solid green;
}
.normal-border{
border: 2px solid #FFFFFF;
}
.hid{
display: none;
}
.fnt-24{
font-size: 24px;
}
.leftstr, .rightstr {
float: left;
width: 50%;
}
.rightstr {
text-align: right;
}
.fnt-larger{
font-size: larger;
}
@media (min-width: 1400px) {
#price-window
{
position: fixed;
right: 30%;
top: 20%;
width: 15%;
min-width: 20%;
z-index: 1;
}
.page-alignment{
min-width: 640px;
}
.main-column{
margin-right: auto;
margin-left: 10%;
max-width: 50%;
}
}
@media (min-width: 999px) {
#price-window
{
position: fixed;
right: 15%;
top: 15%;
width: 20%;
z-index: 1;
}
.main-column{
margin-right: auto;
margin-left: 10%;
max-width: 50%;
}
.page-alignment{
min-width: 640px;
}
.app, .package{
margin-left: 7px;
max-width: 31%;
}
}
@media (max-width: 799px) {
#price-window
{
position: fixed;
right: 0;
bottom: -6%;
width: 100%;
z-index: 1;
}
.container{
margin-left: auto !important;
}
.main-column{
margin-right: auto;
margin-left: 10%;
max-width: 80%;
}
}
.col-lg-9{
padding-left: 0px;
}

2338
kk_odoo_saas/static/src/css/font-awesome.css vendored Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
html, body {
height: 100%;
}
.o-logs-container{
height: auto;
}
.o-logs{
word-wrap: break-word;
font-family: monospace;
font-size: 13px;
line-height: normal;
margin: 0;
padding-top:80px;
}
.header{
position: fixed;
top: 0;
width: 100%;
background: #f9f9f9;
border-bottom: 1px solid #d3d3d3;
padding: 6px 15px;
box-shadow: 0 0 6px #ddd;
}
.status-bar {
text-align: right;
padding-right: 15px;
}
.button-pause{
position: relative;
color: #555;
font-size: 16px;
padding: 0;
margin-right: 10px;
}
.button-pause i{
margin-left: 9px;
margin-right: 5px;
}
.loader{
position: absolute;
top: -4px;
border: 2px solid #f3f3f3;
border-top: 2px solid #555;
border-radius: 50%;
width: 30px;
height: 30px;
z-index: -1;
}
.loader.loading{
-webkit-animation: spin 1s linear infinite;
animation: spin 1s linear infinite;
}
.form-inline .form-group {
margin-bottom: 0;
vertical-align: middle;
display: inline-block;
}
.form-inline .form-group input {
display: inline-block;
width: auto;
}
@-webkit-keyframes spin {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

10364
kk_odoo_saas/static/src/js/jquery.js vendored Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
'use strict';
var FETCH_SIZE = 10000;
var FETCH_INTERVAL_TIME = 5000;
var AUTO_PAUSE_TIME = 1000 * 60 * 5;
function fetch_logs(begin, end) {
return $.ajax({
dataType: "text",
cache: false,
headers: {Range: 'bytes=' + (begin === false ? '' : begin) + '-' + (end === false ? '' : end)},
}).then(function (data, s, xhr) {
data = data.replace(/^\n/, "");
data = data.replace(/\n$/, "");
var content_range = xhr.getResponseHeader("Content-Range");
var bytes = content_range ? /bytes ([0-9]*)-([0-9]*)\/([0-9]*)/.exec(content_range) : undefined;
var begin = bytes ? parseInt(bytes[1]) : 0;
var end = bytes ? parseInt(bytes[2]) : data.length;
var size = bytes ? parseInt(bytes[3]) : data.length + 1;
return {
data: data,
begin: begin,
end: end,
size: size,
};
});
}
$(document).ready(function () {
var fetch_interval;
var min;
var max;
var auto_scroll = true;
var def_top;
var def_bottom;
function init() {
$('.o-logs span').remove();
return fetch_logs(false, 1).then(function (result) {
min = max = Math.max(0, result.size - FETCH_SIZE);
def_bottom = def_top = undefined;
});
}
// borrowed from https://github.com/janl/mustache.js/blob/master/mustache.js
function _escapeHTML(string) {
var entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap(s) {
return entityMap[s];
});
}
function _prepare_line(line) {
var result = '<span class="o-log-line">'
line.split('\n').forEach(function (l) {
result += '<span style="white-space: pre;">' + _escapeHTML(l) + '</span><br/>';
});
result += '</span>'
result = $(result);
filter(result);
return result;
}
function append_logs() {
if (!def_bottom || def_bottom.state() !== 'pending') {
def_bottom = fetch_logs(max, false).then(function (result) {
if (max !== result.end) {
max = result.end;
var splits = result.data.split(/\s+(?=[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3})/)
splits.forEach(function (line) {
$('.o-logs').append(_prepare_line(line));
});
if (auto_scroll) {
window.scrollTo(0, document.body.scrollHeight);
}
}
}).fail(function (xhr) {
if (xhr.status === 416) {
return init();
}
});
}
return def_bottom;
}
function prepend_logs() {
if (min > 0 && (!def_top || def_top.state() !== 'pending')) {
def_top = fetch_logs(Math.max(0, min - FETCH_SIZE), min).then(function (result) {
min = result.begin;
var lines = result.data.split('\n');
var first_line = lines.pop();
if (first_line) {
$('.o-logs span').first().prepend(_escapeHTML(first_line));
}
lines.reverse();
lines.forEach(function (line) {
$('.o-logs').prepend(_prepare_line(line));
});
window.scrollTo(0, 20);
}).fail(function (xhr) {
if (xhr.status === 416) {
return init();
}
});
}
return def_top;
}
function toggle_pause() {
$('i').toggle();
$('.loader').toggleClass('loading');
if (fetch_interval) {
clearInterval(fetch_interval);
fetch_interval = undefined;
} else {
fetch_interval = setInterval(append_logs, FETCH_INTERVAL_TIME);
setTimeout(toggle_pause, AUTO_PAUSE_TIME);
}
}
function filter(elements) {
var filter = $('#filter').val();
elements.filter(':contains(' + filter + ')').show();
elements.filter(':not(:contains(' + filter + '))').hide();
}
$(window).scroll(function () {
if ($(window).scrollTop() + $(window).height() === $(document).height()) {
auto_scroll = true;
} else {
auto_scroll = false;
}
if ($(window).scrollTop() === 0) {
prepend_logs();
}
});
function fill_page() {
if (!def_bottom) {
append_logs().then(fill_page);
} else if ($(window).height() === $(document).height() && min != 0 && (!def_top || def_top.state() !== 'pending')) {
prepend_logs().then(fill_page);
} else {
$(window).scrollTop($(document).height());
}
}
init().then(function () {
fill_page();
toggle_pause();
});
$('.button-pause').click(toggle_pause);
$('#filter').on('input', function () {
filter($('.o-logs .o-log-line'));
});
$('#filter').keypress(function (event) {
if (event.keyCode === 10 || event.keyCode === 13) {
event.preventDefault();
}
});
});

View File

@@ -0,0 +1,29 @@
odoo.define('saas_apps.filter_button', function (require) {
"use strict";
var core = require('web.core');
var session = require('web.session');
var ListController = require('web.ListController');
ListController.include({
renderButtons: function($node) {
this._super.apply(this, arguments);
if (this.$buttons) {
var refresh_apps_button = this.$buttons.find('.refresh_apps_button');
if (refresh_apps_button.length) {
refresh_apps_button.on("click", this.proxy('refresh_apps_button'));
}
}
},
refresh_apps_button: function () {
// Loading all modules in saas.line from ir.module.module
this._rpc({
"model": "saas.app",
"method": "action_make_applist_from_local_instance",
"args": [],
}).then(function (result) {
window.location.reload()
});
}
});
});

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<template xml:space="preserve">
<t t-extend="ListView.buttons">
<t t-jquery="button.o_list_button_add" t-operation="after">
<button t-if="widget.modelName == 'saas.app'" type="button" class="btn btn-primary refresh_apps_button" accesskey="f">
Refresh
</button>
</t>
</t>
</template>

1
kk_odoo_saas/utils/__init__.py Executable file
View File

@@ -0,0 +1 @@
from . import k8s_deployment

View File

@@ -0,0 +1,52 @@
from kubernetes import config, client
from kubernetes.stream import stream
from odoo.addons.smile_log.tools import SmileDBLogger
from odoo.exceptions import UserError
import yaml
# import git_aggregator
def del_git_dir(self, path):
"""
It will delete addons directory inside running container
"""
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
if self.app_name and path:
try:
data2 = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data2)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
core_v1_api = client.CoreV1Api()
try:
pod = core_v1_api.list_namespaced_pod(namespace='default', label_selector='app={}'.format(self.app_name))
except Exception as e:
raise UserError("Unable to connect to cluster")
resp1 = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=['chmod', '-R', 'ugo+rw', path],
stderr=True, stdin=False,
stdout=True, tty=False)
resp = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=['rm', '-rf', path ],
stderr=True, stdin=False,
stdout=True, tty=False)
resp3 = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=['mkdir', path ],
stderr=True, stdin=False,
stdout=True, tty=False)
_logger.info(str(resp1))
_logger.info(str(resp))
_logger.info(str(resp3))
_logger.info(str(path))
_logger.info(str("code deleted"))

167
kk_odoo_saas/utils/deployment.py Executable file
View File

@@ -0,0 +1,167 @@
from kubernetes import client
from odoo.addons.smile_log.tools import SmileDBLogger
from odoo.exceptions import UserError, ValidationError
import logging
_logger = logging.getLogger(__name__)
def create_deployment(meta_data, specs, namespace="default", self=False):
# Deployment
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
deployment = client.V1Deployment(
api_version="apps/v1",
kind="Deployment",
metadata=meta_data,
spec=specs)
k8s_apps_v1 = client.AppsV1Api()
try:
resp = k8s_apps_v1.create_namespaced_deployment(
body=deployment, namespace=namespace)
_logger.info("Deployment created. name='%s'" % resp.metadata.name)
except client.exceptions.ApiException as e:
_logger.error(str(e))
def create_docker_repo_secret(app_name, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
k8s_apps_v1 = client.CoreV1Api()
secret = client.V1Secret(
metadata=client.V1ObjectMeta(
name=app_name+'-dkr-registry-key',
labels={
"app": app_name,
"tier": "backend"
}
),
data={
'.dockerconfigjson': self.docker_image.b64_dkr_config
},
type='kubernetes.io/dockerconfigjson',
)
try:
resp = k8s_apps_v1.create_namespaced_secret(
body=secret, namespace=namespace)
_logger.info("Secret created. name='%s'" % resp.metadata.name)
return True
except client.exceptions.ApiException as e:
_logger.error(str(e))
return False
def delete_docker_repo_secret(app_name, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
k8s_apps_v1 = client.CoreV1Api()
try:
resp = k8s_apps_v1.delete_namespaced_secret(app_name+'-dkr-registry-key', namespace=namespace)
_logger.info(str(resp))
return True
except client.exceptions.ApiException as e:
_logger.error(str(e))
return False
def create_odoo_deployment(app_name, namespace="default", self=False):
image = 'odoo:15.0'
res_limits = {'ephemeral-storage': '1Gi'}
image_pull_secrets = []
if self.is_custom_image and self.docker_image:
image = "{0}:{1}".format(self.docker_image.name, self.docker_image.tag)
if self.docker_image.is_pvt_dkr_repo and self.docker_image.b64_dkr_config:
if create_docker_repo_secret(app_name, namespace, self):
sec_name = app_name+"-dkr-registry-key"
image_pull_secrets.append(client.V1LocalObjectReference(name=sec_name))
meta_data = client.V1ObjectMeta(name=app_name + "-odoo-deployment",
labels={"app": app_name})
args_odoo = ['--database=' + self.sub_domain_name]
# args_odoo = []
if self.demo_data:
args_odoo.append('--without-demo=False')
else:
args_odoo.append('--without-demo=True')
if self.module_ids:
module_names = ''
for module in self.module_ids:
module_names = module_names + module.name + ','
args_odoo.append("--init={0}".format(module_names))
if self.db_server_id:
_logger.critical('Cant deploy app, PG username or password cant find')
UserError("Cant deploy app, PG username or password cant find")
limits = client.V1ResourceRequirements(limits=res_limits)
tolerations = []
node_selector = {}
if self and self.is_dedicated_node and self.node_id:
# tolerations = [client.V1Toleration(effect='NoSchedule', key=self.node_key, value=self.node_value, operator='Equal')]
# specific for aws clusters
node_selector['kubernetes.io/hostname'] = self.node_id.name
odoo_container = client.V1Container(
name="odoo",
image=image,
env=[
client.V1EnvVar(name="HOST", value=self.db_server_id.server_url),
client.V1EnvVar(name="USER", value=self.db_server_id.master_username),
client.V1EnvVar(name="PASSWORD", value=self.db_server_id.master_pass),
client.V1EnvVar(name="PORT", value=self.db_server_id.server_port),
client.V1EnvVar(name="ODOO_HTTP_SOCKET_TIMEOUT", value="100"),
],
ports=[client.V1ContainerPort(container_port=8069, name="odoo-port"),
client.V1ContainerPort(container_port=8072, name="longpolling")],
args=args_odoo,
image_pull_policy='Always',
# command=['chown', '-R', '101:101', '/mnt/extra-addons'],
resources=limits,
# comment following line, if you want to run as odoo user
# security_context=client.V1SecurityContext(run_as_user=0, run_as_group=0),
volume_mounts=[client.V1VolumeMount(name=app_name + "-odoo-web-pv-storage", mount_path="/var/lib/odoo/")]
)
# pod Volume Claim
volume_claim = client.V1PersistentVolumeClaimVolumeSource(claim_name=app_name + "-odoo-web-pv-claim")
# pod volume
volume = client.V1Volume(name=app_name + "-odoo-web-pv-storage", persistent_volume_claim=volume_claim)
# Strategy
strategy = client.V1DeploymentStrategy(type="Recreate")
# Template
# for running as a odoo user changes instead of stash
spec = client.V1PodSpec(containers=[odoo_container], volumes=[volume], image_pull_secrets=image_pull_secrets,
security_context=client.V1PodSecurityContext(run_as_group=101, run_as_user=101,
fs_group=101, fs_group_change_policy='Always'),
node_selector=node_selector)
template = client.V1PodTemplateSpec(
metadata=client.V1ObjectMeta(labels={"app": app_name, "tier": "backend"}),
spec=spec
)
selector = client.V1LabelSelector(match_labels={"app": app_name, "tier": "backend"})
# Spec
specs = client.V1DeploymentSpec(
replicas=1,
strategy=strategy,
selector=selector,
template=template,
)
create_deployment(meta_data, specs, namespace, self=self)
def delete_odoo_deployment(app_name, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
dep_name = app_name + "-odoo-deployment"
core_v1_api = client.AppsV1Api()
try:
deployment = core_v1_api.delete_namespaced_deployment(name=dep_name, namespace=namespace)
if self.is_custom_image and self.docker_image:
if self.docker_image.is_pvt_dkr_repo and self.docker_image.b64_dkr_config:
delete_docker_repo_secret(app_name, namespace, self)
_logger.info(str(deployment))
except client.exceptions.ApiException as e:
_logger.error(str(e))

235
kk_odoo_saas/utils/ingress.py Executable file
View File

@@ -0,0 +1,235 @@
from kubernetes import client, config
from odoo.exceptions import ValidationError
import logging
from odoo.addons.smile_log.tools import SmileDBLogger
def create_ingress(app_name, self=False):
_logger = logging.getLogger(__name__)
if self:
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
# create_ingress
if not self.domain_name and self.sub_domain_name:
return ValidationError('Either Domain name or Subdomain name is not Valid')
else:
host = self.sub_domain_name + self.domain_name
networking_v1_api = client.NetworkingV1Api()
rules = [client.V1IngressRule(
host=host,
http=client.V1HTTPIngressRuleValue(
paths=[
client.V1HTTPIngressPath(
path='/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=80,
),
name=app_name + '-odoo-service', )
)
),
client.V1HTTPIngressPath(
path='/longpolling/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=8072,
),
name=app_name + '-odoo-service', )
)
)
]
)
)]
tls_hosts = [host]
if self and self.custom_domain_ids:
for custom_domain in self.custom_domain_ids:
rules.append(client.V1IngressRule(
host=custom_domain.name,
http=client.V1HTTPIngressRuleValue(
paths=[
client.V1HTTPIngressPath(
path='/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=80,
),
name=app_name + '-odoo-service', )
)
),
client.V1HTTPIngressPath(
path='/longpolling/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=8072,
),
name=app_name + '-odoo-service', )
)
)
]
)
)
)
tls_hosts.append(custom_domain.name)
body = client.V1Ingress(
kind='Ingress',
metadata=client.V1ObjectMeta(name=app_name + '-ingress',
labels={"app": app_name},
annotations={'kubernetes.io/ingress.class': 'nginx',
'cert-manager.io/cluster-issuer': 'letsencrypt-prod'
}),
spec=client.V1IngressSpec(
rules=rules,
tls=[client.V1IngressTLS(
hosts=tls_hosts,
secret_name=self.app_name + 'tls',
)]
)
)
try:
networking_v1_api.create_namespaced_ingress(
namespace='default',
body=body
)
except client.exceptions.ApiException as e:
_logger.error(str(e))
def delete_odoo_ingress(app_name, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
networking_v1_api = client.NetworkingV1Api()
ing_name = app_name + '-ingress'
try:
ing = networking_v1_api.delete_namespaced_ingress(name=ing_name, namespace=namespace)
_logger.info(str(ing))
except client.exceptions.ApiException as e:
_logger.error(str(e))
def update_odoo_ingress(app_name, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
networking_v1_api = client.NetworkingV1Api()
ing_name = app_name + '-ingress'
if not self.domain_name and self.sub_domain_name:
return ValidationError('Either Domain name or Subdomain name is not Valid')
else:
host = self.sub_domain_name + self.domain_name
rules = [client.V1IngressRule(
host=host,
http=client.V1HTTPIngressRuleValue(
paths=[
client.V1HTTPIngressPath(
path='/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=80,
),
name=app_name + '-odoo-service', )
)
),
client.V1HTTPIngressPath(
path='/longpolling/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=8072,
),
name=app_name + '-odoo-service', )
)
)
]
)
)]
tls_hosts = [host]
if self and self.custom_domain_ids:
for custom_domain in self.custom_domain_ids:
rules.append(client.V1IngressRule(
host=custom_domain.name,
http=client.V1HTTPIngressRuleValue(
paths=[
client.V1HTTPIngressPath(
path='/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=80,
),
name=app_name + '-odoo-service', )
)
),
client.V1HTTPIngressPath(
path='/longpolling/',
path_type='ImplementationSpecific',
backend=client.V1IngressBackend(
service=client.V1IngressServiceBackend(
port=client.V1ServiceBackendPort(
number=8072,
),
name=app_name + '-odoo-service', )
)
)
]
)
)
)
tls_hosts.append(custom_domain.name)
body = client.V1Ingress(
kind='Ingress',
metadata=client.V1ObjectMeta(name=app_name + '-ingress',
labels={"app": app_name},
annotations={'kubernetes.io/ingress.class': 'nginx',
}),
spec=client.V1IngressSpec(
rules=rules,
tls=[client.V1IngressTLS(
hosts=tls_hosts,
secret_name=self.app_name + 'tls'
)]
)
)
try:
ing = networking_v1_api.patch_namespaced_ingress(name=ing_name, namespace=namespace, body=body)
_logger.info(str(ing))
except client.exceptions.ApiException as e:
_logger.error(str(e))

View File

@@ -0,0 +1,249 @@
from kubernetes import config, client
import yaml
from kubernetes.stream import stream
from odoo.exceptions import UserError
from .odoo_components import deploy_odoo_components, delete_odoo_components, delete_odoo_components_from_options,\
update_odoo_components
from .pg_server import delete_databases
from odoo.addons.smile_log.tools import SmileDBLogger
from .utils import generate_commit_sha
def create_deployment(app_name, config_file, self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
# Configs can be set in Configuration class directly or using helper
# utility. If no argument provided, the config will be loaded from
# default location.
try:
data2 = yaml.safe_load(config_file)
config.load_kube_config_from_dict(data2)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if app_name:
deploy_odoo_components(app_name=app_name, namespace="default", self=self)
else:
_logger.error("Cant find App Name")
raise UserError("Cant find App Name")
def delete_app_with_options(self, delete_db, delete_pv, delete_svc, delete_ing, delete_deployment):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if self.app_name:
delete_odoo_components_from_options(app_name=self.app_name, namespace="default", self=self, delete_db=delete_db,
delete_pv=delete_pv, delete_svc=delete_svc,
delete_ing=delete_ing, delete_deployment=delete_deployment)
if delete_db:
delete_databases(self)
else:
_logger.error("Cant find App Name")
raise UserError("Cant find App Name")
def update_app(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if self.app_name:
update_odoo_components(app_name=self.app_name, namespace="default", self=self)
else:
_logger.error("Cant find App Name")
raise UserError("Cant find App Name")
def fetch_secrets_from_cluster(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data2 = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data2)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
secs = []
if self.app_name:
core_v1_api = client.CoreV1Api()
secrs = core_v1_api.list_namespaced_secret(namespace='default')
for sec in secrs.items:
secs.append(sec.metadata.name)
return secs
def deploy_apps_from_git(self):
"""
To pull code from github inside running container
"""
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data2 = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data2)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if self.app_name:
core_v1_api = client.CoreV1Api()
pod = core_v1_api.list_namespaced_pod(namespace='default', label_selector='app={}'.format(self.app_name))
if self.is_extra_addon and self.extra_addons and pod and pod.items:
base_version = self.docker_image.base_version
clone_path = "/var/lib/odoo/addons/" + str(base_version)
if self.is_private_repo and self.git_token:
url = self.extra_addons
url = url.replace("http://", "")
url = url.replace("https://", "")
url = url.replace("www.", "")
git_url = "https://oauth2:{0}@{1}".format(self.git_token, url)
else:
git_url = self.extra_addons
is_clone_error = False
error = ''
exec_command = ['git', '-C', clone_path, 'pull']
resp = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=exec_command,
stderr=True, stdin=True,
stdout=True, tty=False,
_preload_content=False)
while resp.is_open():
resp.update(timeout=10)
if resp.peek_stdout():
_logger.info(str(resp.read_stdout()))
if resp.peek_stderr():
is_clone_error = True
error = resp.read_stderr()
_logger.error(str(error))
break
resp.close()
if is_clone_error:
if error and "not a git repository (or any" in error:
resp1 = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=['chmod', '-R', 'ugo+rw', clone_path],
stderr=True, stdin=False,
stdout=True, tty=False,
_preload_content=False)
resp = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=['git', 'clone', git_url, clone_path],
stderr=True, stdin=False,
stdout=True, tty=False,
_preload_content=False)
while resp.is_open():
resp.update(timeout=25)
if resp.peek_stdout():
_logger.info(str(resp.read_stdout()))
if resp.peek_stderr():
error = resp.read_stderr()
_logger.error(str(error))
else:
_logger.info(str(
"No Response"
))
resp.close()
else:
return False
def restart_odoo_service(self):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data2 = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data2)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if self.app_name:
core_v1_api = client.CoreV1Api()
pod = core_v1_api.list_namespaced_pod(namespace='default', label_selector='app={}'.format(self.app_name))
exec_command = ['./mnt/restart_odoo.sh']
resp = stream(core_v1_api.connect_get_namespaced_pod_exec,
pod.items[0].metadata.name,
'default',
command=exec_command,
stderr=True, stdin=True,
stdout=True, tty=False,
_preload_content=False)
while resp.is_open():
resp.update(timeout=10)
if resp.peek_stdout():
_logger.info(str(resp.read_stdout()))
if resp.peek_stderr():
error = resp.read_stderr()
_logger.error(str(error))
break
resp.close()
def read_deployment(self, dep_type='odoo'):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if self.app_name:
dep_name = self.app_name + "-odoo-deployment"
core_v1_api = client.AppsV1Api()
try:
deployment = core_v1_api.read_namespaced_deployment(name=dep_name, namespace='default')
if deployment:
return deployment
return
except Exception as e:
pass
else:
_logger.error("Cant find App Name")
raise UserError("Cant find App Name")
def update_deployment(self, container_arguments, dep_type='odoo', env_vars=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
try:
data = yaml.safe_load(self.configuration.config_file)
config.load_kube_config_from_dict(data)
except config.config_exception.ConfigException as e:
_logger.error(str(e))
raise UserError("Unable to Connect K8s Cluster")
if self.app_name:
core_v1_api = client.AppsV1Api()
try:
deployment = read_deployment(self=self)
if container_arguments:
deployment.spec.template.spec.containers[0].args = eval(container_arguments)
if env_vars:
deployment.spec.template.spec.containers[0].env = env_vars
deployment.spec.template.metadata.labels['COMMIT_SHA'] = generate_commit_sha(10)
patched_deployment = core_v1_api.patch_namespaced_deployment(name=deployment.metadata.name,
namespace='default',
body=deployment)
return patched_deployment
except Exception as e:
_logger.error(str(e))
else:
_logger.error("Cant find App Name")
raise UserError("Cant find App Name")

34
kk_odoo_saas/utils/logs.py Executable file
View File

@@ -0,0 +1,34 @@
from kubernetes import config, client
import yaml
from kubernetes.client.rest import ApiException
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
def read_logs(app_name, self=False, config_file=None, since_seconds=None, previous=False, tail_lines=None):
# Configs can be set in Configuration class directly or using helper
# utility. If no argument provided, the config will be loaded from
# default location.
try:
data2 = yaml.safe_load(config_file)
config.load_kube_config_from_dict(data2)
except config.config_exception.ConfigException as e:
raise UserError("Unable to Connect K8s Cluster")
try:
api_instance = client.CoreV1Api()
odoo_pods = api_instance.list_namespaced_pod(namespace='default',
label_selector='app={0},tier={1}'.format(str(self.app_name),
'backend'))
for pod in odoo_pods.items:
if pod.metadata and pod.metadata.name and (self.app_name + '-odoo-deployment' in pod.metadata.name):
odoo_logs = api_instance.read_namespaced_pod_log(name=pod.metadata.name, namespace='default',
tail_lines=tail_lines, since_seconds=since_seconds)
return odoo_logs
return False
except ApiException as e:
_logger.error(e)
return False

View File

@@ -0,0 +1,37 @@
from .service import create_odoo_service, delete_odoo_service
from .deployment import create_odoo_deployment, delete_odoo_deployment
from .pv_claim import create_odoo_pv_claim, delete_odoo_pv_claim
from .ingress import delete_odoo_ingress, update_odoo_ingress
from .utils import delete_job_task
def deploy_odoo_components(app_name, namespace, self=False):
create_odoo_pv_claim(app_name, namespace, self=self)
create_odoo_service(app_name, namespace, self=self)
create_odoo_deployment(app_name, namespace, self=self)
def delete_odoo_components(app_name, namespace, self=False):
delete_odoo_pv_claim(app_name, namespace, self=self)
delete_odoo_service(app_name, namespace, self=self)
delete_odoo_deployment(app_name, namespace, self=self)
delete_odoo_ingress(app_name, namespace, self=self)
delete_job_task(self)
def delete_odoo_components_from_options(app_name, namespace, self=False, delete_db=False,
delete_pv=False, delete_svc=False,
delete_ing=False, delete_deployment=False):
if delete_pv:
delete_odoo_pv_claim(app_name, namespace, self=self)
if delete_svc:
delete_odoo_service(app_name, namespace, self=self)
if delete_deployment:
delete_odoo_deployment(app_name, namespace, self=self)
if delete_ing:
delete_odoo_ingress(app_name, namespace, self=self)
delete_job_task(self)
def update_odoo_components(app_name, namespace, self=False):
update_odoo_ingress(app_name, namespace, self)

61
kk_odoo_saas/utils/pg_query.py Executable file
View File

@@ -0,0 +1,61 @@
import psycopg2
import sys
import logging
_logger = logging.getLogger(__name__)
class PgQuery(object):
"""
USAGE:
postgresX = ['localhost', 'sadsadsad', 'admin', 'codetuple']
pgX = TaskMigration(*postgresX)
with pgX:
result = pgX.selectQuery(query)
"""
def __init__(self, host, database, user, password, port=5432):
self.host = host
self.database = database
self.user = user
self.password = password
self.dbConnection = False
self.cursor = False
self.port = port
def __enter__(self):
try:
self.dbConnection = psycopg2.connect(host=self.host, database=self.database, user=self.user,
password=self.password, port=self.port)
self.cursor = self.dbConnection.cursor()
except Exception as e:
_logger.info("Error in Postgres Connection: %r" % e)
sys.exit()
return self.dbConnection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.dbConnection:
# self.dbConnection.close()
pass
def select_query(self, queryString):
status = True
try:
self.cursor.execute(queryString)
except Exception as e:
status = False
return status
else:
return self.cursor.fetchall()
def execute_query(self, queryString):
status = True
try:
self.cursor.execute(queryString)
self.dbConnection.commit()
except Exception as e:
_logger.info(queryString)
_logger.info(e)
status = False
finally:
return status

50
kk_odoo_saas/utils/pg_server.py Executable file
View File

@@ -0,0 +1,50 @@
from psycopg2 import sql, connect
import odoo
from contextlib import closing
import logging
from odoo.exceptions import AccessError, UserError
from .utils import generate_temp_password
_logger = logging.getLogger(__name__)
def drop_db(self, db_name):
if self:
child_conn = self.get_pg_db_connection(db=db_name)
child_conn.set_session(autocommit=True)
with closing(child_conn.cursor()) as cr:
odoo.service.db._drop_conn(cr, db_name)
try:
cr.execute(sql.SQL('DROP DATABASE {}').format(sql.Identifier(db_name)))
except Exception as e:
_logger.info('DROP DB: %s failed:\n%s', db_name, e)
child_conn.close()
raise UserError("Couldn't drop database %s: %s" % (db_name, e))
else:
child_conn.close()
_logger.info('DROP DB: %s', db_name)
return True
def delete_databases(self):
if self and self.client_db_name:
# dbs = get_databases(self)
drop_db(self, self.client_db_name)
def get_admin_credentials(self):
if self and self.client_db_name:
# FOR admin user_id = 2
child_conn = self.get_pg_db_connection(db=self.client_db_name)
query = sql.SQL("SELECT login, COALESCE(password, '') FROM res_users WHERE id=2;")
with closing(child_conn.cursor()) as cr:
try:
cr.execute(query)
res = cr.fetchall()
child_conn.close()
except Exception:
_logger.exception('Getting Credentials failed')
res = False
child_conn.close()
return res, self.client_db_name
return False, False

60
kk_odoo_saas/utils/pv_claim.py Executable file
View File

@@ -0,0 +1,60 @@
from kubernetes import client, config
import logging
from odoo.addons.smile_log.tools import SmileDBLogger
def create_pv_claim(meta_data, specs, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
k8s_apps_v1 = client.CoreV1Api()
dep = client.V1PersistentVolumeClaim(
api_version='v1',
kind='PersistentVolumeClaim',
metadata=meta_data,
spec=specs
)
try:
resp = k8s_apps_v1.create_namespaced_persistent_volume_claim(
body=dep, namespace=namespace)
_logger.info("Volume created. status='%s'" % resp.metadata.name)
except client.exceptions.ApiException as e:
_logger.error(msg=str(e))
def create_odoo_pv_claim(app_name, namespace="default", self=False):
specs = client.V1PersistentVolumeClaimSpec(
access_modes=[
'ReadWriteOnce'
],
storage_class_name="gp2",
resources=client.V1ResourceRequirements(
requests={
'storage': '8Gi'
}
)
)
meta_data = client.V1ObjectMeta(
name=app_name + "-odoo-web-pv-claim",
labels={"app": app_name}
)
create_pv_claim(meta_data=meta_data, specs=specs, namespace=namespace, self=self)
def delete_odoo_pv_claim(app_name, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
claim_name = app_name + "-odoo-web-pv-claim"
core_v1_api = client.CoreV1Api()
try:
pv = core_v1_api.delete_namespaced_persistent_volume_claim(name=claim_name, namespace=namespace)
_logger.info(str(pv))
except client.exceptions.ApiException as e:
_logger.error(str(e))

69
kk_odoo_saas/utils/service.py Executable file
View File

@@ -0,0 +1,69 @@
from kubernetes import client, config
import random
from urllib.parse import urlparse
import logging
_logger = logging.getLogger(__name__)
from odoo.addons.smile_log.tools import SmileDBLogger
def create_service(specs, metadata, namespace="default", self=False):
_logger = SmileDBLogger(self._cr.dbname, self._name, self.id, self._uid)
core_v1_api = client.CoreV1Api()
body = client.V1Service(
api_version="v1",
kind="Service",
metadata=metadata,
spec=specs
)
# Creation of the Deployment in specified namespace
try:
service = core_v1_api.create_namespaced_service(namespace=namespace, body=body)
_logger.info("Service created. status='%s'" % service.metadata.name)
except client.exceptions.ApiException as e:
_logger.error(str(e))
def create_odoo_service(app_name, namespace, self=False):
service_name = app_name + "-odoo-service"
specs = client.V1ServiceSpec(
selector={"app": app_name, "tier": "backend"},
ports=[client.V1ServicePort(
name='odoo-port',
protocol="TCP",
port=80,
target_port=8069,
),
client.V1ServicePort(
name='longpolling',
protocol="TCP",
port=8072,
target_port=8072,
)
],
type="NodePort"
)
metadata = client.V1ObjectMeta(
name=service_name,
labels={"app": app_name}
)
create_service(metadata=metadata, specs=specs, namespace=namespace, self=self)
def delete_odoo_service(app_name, namespace, self=False):
service_name = app_name + "-odoo-service"
core_v1_api = client.CoreV1Api()
try:
service = core_v1_api.delete_namespaced_service(name=service_name, namespace=namespace)
_logger.info(service)
except client.exceptions.ApiException as e:
_logger.error(str(e))

29
kk_odoo_saas/utils/utils.py Executable file
View File

@@ -0,0 +1,29 @@
def generate_temp_password(length):
if not isinstance(length, int) or length < 8:
raise ValueError("temp password must have positive length")
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*()"
from os import urandom
# Python 3 (urandom returns bytes)
return "".join(chars[c % len(chars)] for c in urandom(length))
def generate_commit_sha(length):
if not isinstance(length, int) or length < 8:
raise ValueError("sha must have positive length")
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
from os import urandom
# Python 3 (urandom returns bytes)
return "".join(chars[c % len(chars)] for c in urandom(length))
def delete_job_task(self):
if self and self.id:
job_q_env = self.env['queue.job']
jobs = job_q_env.search([
"|", "|", "|", ("state", "=", "pending"), (
"state", "=", "enqueued"), ("state", "=", "started"), ("state", "=", "failed"),
('func_string', '=', "kk_odoo_saas.app({0},).post_init_tasks()".format(self.id))])
for job in jobs:
job.button_done()

303
kk_odoo_saas/views/app_views.xml Executable file
View File

@@ -0,0 +1,303 @@
<odoo>
<data noupdate="1">
<!-- Sequences for kk_odoo_saas.app -->
<record id="kk_odoo_saas_app_sequence" model="ir.sequence">
<field name="name">SaaS App Sequence</field>
<field name="code">kk_odoo_saas.app</field>
<field name="prefix">saas-app</field>
<field name="padding">3</field>
<field name="company_id" eval="False"/>
</record>
</data>
<data>
<record id="kk_odoo_saas_app_view_form" model="ir.ui.view">
<field name="name">kk_odoo_saas_app_view_form</field>
<field name="model">kk_odoo_saas.app</field>
<field name="arch" type="xml">
<form>
<header>
<button name="deploy_app" string="Deploy App" type="object"
attrs="{'invisible':[('status','not in',['d', 'del'])]}" class="btn-primary"/>
<button name="kk_odoo_saas.action_saas_app_delete_wizard"
confirm="Are you sure to DELETE this Deployment? it will DELETE all Data of this App ."
string="Delete Deployment" type="action" icon="fa-trash"
attrs="{'invisible':[('status','not in',['l', 'm'])]}" class="btn-primary"/>
<!-- <button name="update_app" string="Update Deployment" type="object"-->
<!-- attrs="{'invisible':[('status','not in',['m'])]}" class="btn-primary"/>-->
<button name="deploy_apps_from_git" string="Update Git Code" type="object"
attrs="{'invisible':[('is_extra_addon','=',False)]}" class="btn-secondary"/>
<button name="del_git_dir" string="Delete Git Code" type="object"
attrs="{'invisible':[('is_extra_addon','=',False)]}" class="btn-secondary"/>
<button name="restart_odoo_service" string="Restart Odoo Service" type="object"
class="btn-secondary" icon="fa-refresh"/>
<field name="status" widget="statusbar"/>
</header>
<header>
<button name="action_create_subscription"
type="object"
string="Create Subscription"
attrs="{'invisible': [('subscription_id','!=',False)]}"
/>
</header>
<sheet>
<div class="oe_button_box" style="justify-content: space-between; display: flex;">
<div>
<button name="action_connect_instance"
type="object" icon="fa-rocket"
class="oe_stat_button pl-3"
string="Connect"
attrs="{'invisible': [('status','in',['d','del'])]}"
/>
<button name="kk_odoo_saas.action_saas_app_update_dkr_img_wizard"
string="Update Docker Image" type="action" icon="fa-cloud-upload"
attrs="{'invisible':[('status','not in',['l', 'm'])]}" class="oe_stat_button"/>
</div>
<button name="action_show_subscription"
type="object" icon="fa-file"
string="Show Subscription"
attrs="{'invisible': [('subscription_id','=',False)]}"
class="oe_stat_button"
/>
</div>
<group>
<group string="App Info">
<field name="app_name" readonly="1" force_save="1"/>
<field name="name" required="1"/>
</group>
<group string="K8s Cluster Configuration">
<field name="configuration" options="{'no_create': True, 'no_edit': True}" required="1" attrs="{'readonly': [('status', 'in', ['l','m'])]}"/>
</group>
<group string="Node Configuration">
<field name="is_dedicated_node" required="0" attrs="{'readonly': [('status', 'in', ['l','m'])]}" string="Any Specific Node"/>
<field name="node_id"
attrs="{'readonly': [('status', 'in', ['l','m'])], 'required': [('is_dedicated_node', '=', True)], 'invisible': [('is_dedicated_node', '=', False)]}"
options="{'no_create': True}"
/>
<!-- <field name="node_key"-->
<!-- attrs="{'readonly': [('status', 'in', ['l','m'])], 'invisible': [('is_dedicated_node', '=', False)]}"-->
<!-- />-->
<!-- <field name="node_value"-->
<!-- attrs="{'readonly': [('status', 'in', ['l','m'])], 'invisible': [('is_dedicated_node', '=', False)]}"-->
<!-- />-->
<div>
<p>
<button name="refresh_node_list"
attrs="{'invisible': [('is_dedicated_node', '=', False)]}"
type="object" string="Click to Refresh Nodes List."
class="oe_link" />
</p>
</div>
</group>
<group string="Domain Configuration">
<div>
<field name="sub_domain_name" class="oe_inline"/>
<field name="domain_name" class="oe_inline"/>
</div>
</group>
<group string="Database Server">
<field name="db_server_id" options="{'no_create': True, 'no_edit': True}" domain="[('status', '=', 'connected')]" required="1"/>
<field name="client_db_name" required="1"/>
</group>
<group string="Extra Addons from Git">
<field name="is_extra_addon" widget="boolean_toggle"/>
<field name="extra_addons"
attrs="{'invisible': [('is_extra_addon', '=', False)], 'required':[('is_extra_addon', '=', True)]}"/>
<field name="is_private_repo" attrs="{'invisible': [('is_extra_addon', '=', False)]}"/>
<field name="git_token" password="True"
attrs="{'invisible': ['|', ('is_extra_addon', '=', False), ('is_private_repo', '=', False)], 'required':[('is_private_repo', '=', True)]}"/>
</group>
<group string="App Version (Docker Image)">
<field name="docker_image" required="1" attrs="{'readonly': [('status', 'in', ['l','m'])]}"/>
</group>
<group string="Demo Data">
<field name="demo_data" widget="boolean_toggle" attrs="{'readonly': [('status', 'in', ['l','m'])]}"/>
</group>
<group string="Odoo Modules">
<field name="module_ids" widget="many2many_tags" attrs="{'readonly': [('status', 'in', ['l','m'])]}"/>
</group>
<group string="App Credentials">
<field name="login_email" string="Email / Username"/>
<label for="login_pwd" string="Password"/>
<!-- <div>-->
<field name="login_pwd" password="True" widget="CopyClipboardChar"/>
<!-- <button class="oe_inline btn btn-primary" type="object" name="reset_app_password" string="Reset Credentials"/>-->
<!-- </div>-->
<field name="master_login_email"/>
<field name="master_login_pwd" password="True" widget="CopyClipboardChar"/>
</group>
</group>
<notebook>
<page string="Client Details">
<group>
<group>
<field name="client" force_save="1"/>
</group>
<group>
<field name="country_id" options="{'no_create': True}" attrs="{'readonly': [('status', 'in', ['l','m'])]}"/>
<field name="admin_user" context="{'form_view_ref': 'base.view_users_form',}" required="1" attrs="{'readonly': [('status', 'in', ['l','m'])]}"/>
</group>
</group>
</page>
<page string="Subscription Details">
<group>
<group>
<field name="subscription_id"/>
</group>
<group>
</group>
</group>
</page>
<page name="k8s_logs" string="K8s Logs">
<field name="k8s_logs"/>
</page>
<page name="instance_logs" string="Instance Logs">
<button name="get_pod_logs" type="object" string="Download Logs File"/>
<button name="action_log_viewer" type="object" string="See Realtime Logs"/>
</page>
<!-- <page name="redeploy_app" string="Update Docker Image">-->
<!-- </page>-->
<page name="custom_domains" string="Custom Domains">
<field name="custom_domain_ids"/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" groups="base.group_user"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="kk_odoo_saas_app_view_kanban" model="ir.ui.view">
<field name="name">kk_odoo_saas_app_view_kanban</field>
<field name="model">kk_odoo_saas.app</field>
<field name="arch" type="xml">
<kanban records_draggable="0">
<field name="app_name"/>
<field name="name"/>
<field name="status"/>
<field name="domain_name"/>
<field name="sub_domain_name"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click container">
<field name="name"/>
<br/>
<field name="app_name"/>
<t t-if="record.status.raw_value in ['l', 'm']">
<p>
<field name="sub_domain_name"/>
<field name="domain_name"/>
</p>
</t>
<div>Custom Domains: </div><field name="custom_domain_ids" widget="many2many_tags"/>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="kk_odoo_saas_app_view_search" model="ir.ui.view">
<field name="name">kk_odoo_saas.app.search</field>
<field name="model">kk_odoo_saas.app</field>
<field name="arch" type="xml">
<search string="Apps">
<field name="name" string="Apps"
filter_domain="[('name','ilike',self)]"/>
<field name="app_name" string="Unique Id"/>
<field name="sub_domain_name" string="Domain Name"/>
<field name="status" string="State"/>
<group expand="0" string="Group By">
<filter name="group_status" string="State" domain="[]" context="{'group_by':'status'}"/>
<filter name="group_configuration" string="Configuration" domain="[]"
context="{'group_by':'configuration'}"/>
</group>
</search>
</field>
</record>
<record id="kk_odoo_saas_app_view_tree" model="ir.ui.view">
<field name="name">kk_odoo_saas_app_view_tree</field>
<field name="model">kk_odoo_saas.app</field>
<field name="arch" type="xml">
<tree>
<field name="app_name"/>
<field name="name"/>
<field name="configuration"/>
<field name="client"/>
</tree>
</field>
</record>
<record id="kk_odoo_saas_app_action" model="ir.actions.act_window">
<field name="name">Apps</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">kk_odoo_saas.app</field>
<field name="view_mode">kanban,list,form</field>
<field name="context">{'search_default_group_status': True}</field>
</record>
<!-- Top menu item -->
<menuitem name="SaaS" id="menu_root"
web_icon="kk_odoo_saas,static/description/icon.png"
sequence="-40"
/>
<!-- actions -->
<menuitem name="Apps" id="kk_odoo_saas_apps" parent="menu_root"
action="kk_odoo_saas.kk_odoo_saas_app_action"/>
<menuitem id="menu_kk_odoo_saas_smile_log" parent="menu_root" name="Logs" action="smile_log.act_smile_log"/>
<menuitem id="menu_kk_odoo_saas_customer" parent="menu_root" name="Customers / Orders"/>
<menuitem id="menu_kk_odoo_saas_customer_customers" parent="menu_kk_odoo_saas_customer" name="Customers"
action="base.action_partner_customer_form" sequence="10"/>
<menuitem id="menu_kk_odoo_saas_customer_subscriptions" parent="menu_kk_odoo_saas_customer" name="Subscriptions"
action="sale_subscription.sale_subscription_action" sequence="20"/>
<record id="action_orders_ecommerce" model="ir.actions.act_window">
<field name="name">Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form,kanban,activity</field>
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="search_view_id" ref="website_sale.view_sales_order_filter_ecommerce"/>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
There is no confirmed order from the website
</p>
</field>
</record>
<menuitem id="menu_kk_odoo_saas_customer_orders" parent="menu_kk_odoo_saas_customer" name="Orders"
action="action_orders_ecommerce" sequence="30"/>
<menuitem id="menu_kk_odoo_saas_users" parent="menu_kk_odoo_saas_customer" name="Portal Users"
action="base.action_res_users" sequence="30"/>
</data>
</odoo>

10
kk_odoo_saas/views/assets.xml Executable file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="line assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/kk_odoo_saas/static/src/js/refresh_button.js"></script>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,274 @@
<odoo>
<data>
<!-- explicit list view definition -->
<record model="ir.ui.view" id="kk_odoo_saas_k8s_config_list">
<field name="name">kk_odoo_saas k8s config list</field>
<field name="model">kk_odoo_saas.k8s.config</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
</tree>
</field>
</record>
<record id="kk_odoo_saas_k8s_config_kanban" model="ir.ui.view">
<field name="name">kk_odoo_saas k8s config kanban</field>
<field name="model">kk_odoo_saas.k8s.config</field>
<field name="arch" type="xml">
<kanban>
<field name="name"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click container">
<field name="name"/>
<!-- <t t-raw="record.name"/>-->
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="kk_odoo_saas_k8s_config_view_form" model="ir.ui.view">
<field name="name">kk_odoo_saas_k8s_config_view_form</field>
<field name="model">kk_odoo_saas.k8s.config</field>
<field name="arch" type="xml">
<form>
<header>
<button name="update_cluster_nodes" string="Update Nodes" type="object"/>
</header>
<sheet>
<group>
<field name="name" required="1"/>
<field name="config_file" required="1" widget="ace" class="oe_edit_only"/>
<field name="domain_name"
placeholder=".yourdomain.com | A DNS Should be set and configures as *.yourdomain.com"
required="1"/>
<field name="namespaces" readonly="1"/>
<!-- <button name="check_connectivity" string="Check Connection" type="object"/>-->
</group>
<group>
<button name="check_connectivity" string="Check Connection" type="object"/>
</group>
<!-- <notebook>-->
<!-- <page string="NameSpaces" name="ns">-->
<!-- </page>-->
<!-- <page string="Pods" name="pod">-->
<!-- </page>-->
<!-- <page string="deployments" name="deployment">-->
<!-- </page>-->
<!-- <page string="Ingresses" name="ingress">-->
<!-- </page>-->
<!-- <page string="Service" name="service">-->
<!-- </page>-->
<!-- <page string="PVs" name="pv">-->
<!-- </page>-->
<!-- </notebook>-->
</sheet>
</form>
</field>
</record>
<record id="kk_odoo_saas_k8s_config_action" model="ir.actions.act_window">
<field name="name">K8s Configuration</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">kk_odoo_saas.k8s.config</field>
<field name="view_mode">kanban,list,form</field>
</record>
<record model="ir.ui.view" id="kk_odoo_saas_k8s_docker_images_list">
<field name="name">kk_odoo_saas k8s config list</field>
<field name="model">kk_odoo_saas.k8s.docker.images</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="tag"/>
<field name="description"/>
</tree>
</field>
</record>
<record id="kk_odoo_saas_k8s_docker_images_kanban" model="ir.ui.view">
<field name="name">kk_odoo_saas k8s docker images kanban</field>
<field name="model">kk_odoo_saas.k8s.docker.images</field>
<field name="arch" type="xml">
<kanban>
<field name="name"/>
<field name="tag"/>
<field name="description"/>
<field name="is_pvt_dkr_repo"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click container">
<strong>Image Name:</strong> <field name="name"/>
<br/>
<strong>Tag Name:</strong> <field name="tag"/>
<br/>
<strong>Description:</strong> <field name="description"/>
<br/>
<strong>Is Private Repo?</strong> <field name="is_pvt_dkr_repo"/>
<!-- <t t-raw="record.name"/>-->
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="kk_odoo_saas_k8s_docker_images_view_form" model="ir.ui.view">
<field name="name">kk_odoo_saas_k8s_docker_images_view_form</field>
<field name="model">kk_odoo_saas.k8s.docker.images</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="tag"/>
<field name="description"/>
<field name="repo_link"/>
<field name="base_version"/>
</group>
<group>
<field name="is_pvt_dkr_repo" widget="boolean_toggle"/>
<field name="b64_dkr_config" class="oe_edit_only" attrs="{'invisible': [('is_pvt_dkr_repo', '=', False)],
'required':[('is_pvt_dkr_repo', '=', True)]}"
placeholder="Place your .docker/config.json after encoding it in base64"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="kk_odoo_saas_k8s_docker_images_action" model="ir.actions.act_window">
<field name="name">Docker Images</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">kk_odoo_saas.k8s.docker.images</field>
<field name="view_mode">kanban,list,form</field>
</record>
<record id="kk_odoo_saas_k8s_node_view_tree" model="ir.ui.view">
<field name="name">kk_odoo_saas_k8s_node_view_tree</field>
<field name="model">kk_odoo_saas.k8s.node</field>
<field name="arch" type="xml">
<tree string="Cluster Nodes" create="false">
<field name="name"/>
<field name="create_date"/>
</tree>
</field>
</record>
<record id="kk_odoo_saas_k8s_node_view_form" model="ir.ui.view">
<field name="name">kk_odoo_saas_k8s_node_view_form</field>
<field name="model">kk_odoo_saas.k8s.node</field>
<field name="arch" type="xml">
<form string="Cluster Nodes" create="false">
<sheet>
<group>
<field name="name"/>
</group>
<group>
<field name="labels" widget="ace"/>
</group>
<group>
<field name="annotations" widget="ace"/>
</group>
<group>
<field name="taints" widget="ace"/>
</group>
</sheet>
<sheet>
<group>
<field name="yaml_info" widget="ace"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="kk_master_db_server_view_tree" model="ir.ui.view">
<field name="name">kk_master_db_server_view_tree</field>
<field name="model">kk_odoo_saas.k8s.master_db_creds</field>
<field name="arch" type="xml">
<tree string="DB Server Credentials" >
<field name="name"/>
<field name="server_url"/>
</tree>
</field>
</record>
<record id="kk_master_db_server_view_form" model="ir.ui.view">
<field name="name">kk_master_db_server_view_form</field>
<field name="model">kk_odoo_saas.k8s.master_db_creds</field>
<field name="arch" type="xml">
<form string="DB Server Credentials">
<header>
<button name="check_connectivity" string="Check Connectivity" type="object"/>
<field name="status" widget="statusbar"/>
</header>
<sheet>
<group>
<field name="name"/>
</group>
<group>
<field name="master_username" />
<field name="server_port" />
</group>
<group>
<field name="master_pass" password="True"/>
</group>
<group>
<field name="server_url" />
</group>
</sheet>
</form>
</field>
</record>
<record id="kk_odoo_saas_k8s_node_action" model="ir.actions.act_window">
<field name="name">Cluster Nodes</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">kk_odoo_saas.k8s.node</field>
<field name="view_mode">tree,form</field>
</record>
<record id="kk_odoo_saas_master_db_creds_action" model="ir.actions.act_window">
<field name="name">Master DB Creds</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">kk_odoo_saas.k8s.master_db_creds</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem name="Configuration" id="kk_odoo_saas_configuration_root" parent="menu_root"/>
<menuitem name="Clusters Configurations" id="kk_odoo_saas_configuration"
parent="kk_odoo_saas_configuration_root"
action="kk_odoo_saas.kk_odoo_saas_k8s_config_action"/>
<menuitem name="Docker Images" id="kk_odoo_saas_docker_images" parent="kk_odoo_saas_configuration_root"
action="kk_odoo_saas.kk_odoo_saas_k8s_docker_images_action"/>
<menuitem name="Cluster Nodes" id="kk_odoo_saas_cluster_nodes" parent="kk_odoo_saas_configuration_root"
action="kk_odoo_saas.kk_odoo_saas_k8s_node_action"/>
<menuitem name="Master DB Server" id="kk_odoo_saas_master_db_server" parent="kk_odoo_saas_configuration_root"
action="kk_odoo_saas.kk_odoo_saas_master_db_creds_action"/>
</data>
</odoo>

View File

@@ -0,0 +1,49 @@
<odoo>
<!-- Header -->
<template id="saas_app_log_viewer">
<html>
<head>
<meta charset="utf-8"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<title>Logs Viewer</title>
<link rel="stylesheet" href="/kk_odoo_saas/static/src/css/bootstrap.css"/>
<link rel="stylesheet" href="/kk_odoo_saas/static/src/css/font-awesome.css"/>
<link rel="stylesheet" href="/kk_odoo_saas/static/src/css/logs-viewer.css"/>
</head>
<body>
<div id="wrapper">
<div class="container-fluid o-logs-container">
<div class="row">
<div class="o-logs col-md-12">
</div>
</div>
</div>
<div class="header">
<div class="status-bar">
<form class="form-inline d-flex justify-content-end" _lpchecked="1">
<a class="btn button-pause">
<i class="fa fa-pause" aria-hidden="true" style="display: none;"></i>
<i class="fa fa-play" aria-hidden="true" style=""></i>
<div class="loader"></div>
</a>
<div class="form-group d-flex">
<label for="filter">Filter:</label>
<input type="text" class="form-control ml-2" id="filter" placeholder="ERROR"/>
</div>
</form>
</div>
</div>
</div>
<script type="text/javascript" src="/kk_odoo_saas/static/src/js/jquery.js"></script>
<script type="text/javascript" src="/kk_odoo_saas/static/src/js/logs-viewer.js"></script>
</body>
</html>
</template>
</odoo>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.website.apps</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='website_settings']" position="after">
<h2>SaaS pricing page</h2>
<div class="row mt16 o_settings_container" id="apps_general_settings">
<div class="col-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="show_packages"/>
</div>
<div class="o_setting_right_pane">
<label for="show_packages" string="Show packages"/>
<div class="text-muted">
Show packages at "price" page
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="show_apps"/>
</div>
<div class="o_setting_right_pane">
<label for="show_apps" string="Show apps"/>
<div class="text-muted">
Show applications at "price" page
</div>
</div>
</div>
<div class="col-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="show_buy_now_button"/>
</div>
<div class="o_setting_right_pane">
<label for="show_buy_now_button" string="Show 'Buy now' button"/>
</div>
</div>
<div class="col-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="show_try_trial_button"/>
</div>
<div class="o_setting_right_pane">
<label for="show_try_trial_button" string="Show 'Try trial' button"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="saas_app_view_form" model="ir.ui.view">
<field name="name">saas.app.form</field>
<field name="model">saas.app</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="icon_image" widget="image" class="oe_avatar o_field_image"/>
<group>
<field name="name"/>
<field name="shortdesc"/>
<field name="allow_to_sell"/>
<field name="month_price" class="oe_inline" widget='monetary'/>
<field name="year_price" class="oe_inline" widget='monetary'/>
<field name="currency_id" invisible="1"/>
<field name="dependency_ids"/>
<field name="product_tmpl_id"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="saas_app_search" model="ir.ui.view">
<field name="name">saas.app.search</field>
<field name="model">saas.app</field>
<field name="arch" type="xml">
<search string="Search Redirect">
<field name="shortdesc"/>
<field name="allow_to_sell"/>
</search>
</field>
</record>
<record id="saas_app_view_tree" model="ir.ui.view">
<field name="name">saas.app.list</field>
<field name="model">saas.app</field>
<field name="arch" type="xml">
<tree string="Manage Apps">
<field name="shortdesc"/>
<field name="allow_to_sell"/>
</tree>
</field>
</record>
<record id="action_saas_app_list" model="ir.actions.act_window">
<field name="name">Website Apps</field>
<field name="res_model">saas.app</field>
<field name="view_mode">form</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="saas_app_view_tree"/>
<field name="target">current</field>
<field name="search_view_id" ref="saas_app_search"/>
</record>
<menuitem name="Website"
id="menu_website"
parent="kk_odoo_saas.menu_root"
/>
<menuitem name="Manage Apps"
id="menu_website_manage_apps_list"
action="action_saas_app_list"
parent="menu_website"
sequence="40"
/>
<menuitem name="Dashboard"
id="menu_website_dashboard"
action="website.backend_dashboard"
parent="menu_website"
sequence="60"
/>
</data>
</odoo>

View File

@@ -0,0 +1,73 @@
<odoo>
<record id='saas_package_view_tree' model='ir.ui.view'>
<field name="name">saas.package.tree</field>
<field name="model">saas.package</field>
<field name="arch" type="xml">
<tree>
<field name="name" string="Package Name"/>
<field name="month_price" string="Monthly Price"/>
<field name="year_price" string="Yearly Price"/>
<field name="month_product_id"/>
<field name="year_product_id"/>
</tree>
</field>
</record>
<record id="saas_package_form_view" model="ir.ui.view">
<field name="name">saas.package.form</field>
<field name="model">saas.package</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="package_image" widget="image" class="oe_avatar o_field_image"/>
<div class="oe_title">
<h1>
<field name="name" readonly="0" placeholder="Name"/>
</h1>
</div>
<group>
<group>
<field name="is_published"/>
<field name="docker_image" required="1"/>
</group>
<group>
<field name="month_price"/>
<field name="year_price"/>
</group>
<group>
<field name="month_product_id"/>
<field name="year_product_id"/>
</group>
<group>
<field name="module_ids" widget="many2many_tags"/>
</group>
<group>
<field name="product_tmpl_id" string="Product Template"/>
</group>
<group>
<field name="stripe_product_id" password="True"/>
<field name="subscription_template" required="True"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_saas_package_list" model="ir.actions.act_window">
<field name="name">Portal Packages</field>
<field name="res_model">saas.package</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem name="Manage Packages"
id="menu_website_manage_package_list"
action="action_saas_package_list"
parent="menu_website"
sequence="40"
/>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="sale_subscription_view_form" model="ir.ui.view">
<field name="name">sale.subscription.inherit.form.view</field>
<field name="model">sale.subscription</field>
<field name="inherit_id" ref="sale_subscription.sale_subscription_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="build_id"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template inherit_id="auth_signup.fields" id="additional_fields" name="Auth Signup additional form fields">
<xpath expr="//div[hasclass('form-group', 'field-name')]" position="after">
<t t-if="not only_passwords">
<input type="hidden" name="operator_id" t-att-value="operator_id" />
<div class="loader hid transition">
<img src="/kk_odoo_saas/static/src/img/loader.gif" draggable="false">
<p class="status">Creating database...</p>
</img>
</div>
<div class="form-group field-company_name">
<label for="company_name">Company name</label>
<input type="text" name="company_name" id="company_name" class="form-control form-control-sm"
t-att-value="company_name"
required="required"/>
</div>
<div class="form-group field-database_name">
<label for="database_name">Database name</label>
<input type="text" name="database_name" id="database_name" class="form-control form-control-sm"
t-att-value="database_name"
required="required"/>
<small id="build-domain-helper">
<span class="text-danger build-domain-helper_status build-domain-helper_status-false" style="display: none">Domain is not available</span>
<span class="text-success build-domain-helper_status build-domain-helper_status-true" style="display: none">
Your domain
<span class="domain"></span>
</span>
<span class="build-domain-helper_status build-domain-helper_status-loading" style="display: none">Detecting is domain available</span>
</small>
</div>
<div class="form-group field-phone">
<label for="phone">Phone</label>
<input type="text" name="phone" id="phone" class="form-control form-control-sm"
t-att-value="phone"
required="required"/>
</div>
<div class="form-group field-database_lang">
<label for="database_lang">Language</label>
<select id="database_lang" name="database_lang" class="form-control" autocomplete="off">
<option value=""></option>
<t t-foreach="langs" t-as="item">
<option t-att-value="item[0]" t-esc="item[1]" t-att-selected="'selected' if database_lang == item[0] else None"/>
</t>
</select>
</div>
<div class="form-group field-country_code">
<label for="country_code">Country</label>
<select id="country_code" name="country_code" class="form-control" autocomplete="off">
<option value=""></option>
<t t-foreach="countries" t-as="item">
<option t-att-value="item[0]" t-esc="item[1]" t-att-selected="'selected' if country_code == item[0] else None"/>
</t>
</select>
</div>
</t>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import saas_app_delete
from . import update_docker_image

View File

@@ -0,0 +1,31 @@
from odoo import fields, models
class SaaSAppDelete(models.TransientModel):
_name = "kk_odoo_saas.app.delete.wizard"
_description = "Wizard to Destroy a SaaS App/Instance"
delete_database = fields.Boolean('Delete Database?', default=False)
delete_pv = fields.Boolean('Delete Attachments and Web Data?', default=False)
delete_svc = fields.Boolean('Delete Services and Ingress Rules?', default=True)
delete_ing = fields.Boolean('Delete Ingress Rules?', default=True)
delete_deployment = fields.Boolean('Delete Container / Pod(s) ?', default=True)
def _default_app_id(self):
res = False
context = self.env.context
if context.get("active_model") == "kk_odoo_saas.app" and context.get("active_id"):
res = context["active_id"]
return res
app_id = fields.Many2one(
comodel_name="kk_odoo_saas.app", string="SaaS App", default=lambda r: r._default_app_id()
)
def delete_saas_instance(self):
if self.app_id:
self.app_id.delete_app_from_wizard(delete_db=self.delete_database, delete_pv=self.delete_pv,
delete_svc=self.delete_svc, delete_ing=self.delete_ing,
delete_deployment=self.delete_deployment)
return {"type": "ir.actions.act_window_close"}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_saas_app_delete" model="ir.ui.view">
<field name="name">Delete SaaS Instance</field>
<field name="model">kk_odoo_saas.app.delete.wizard</field>
<field name="arch" type="xml">
<form string="Delete SaaS Instance">
<group string="Please choose options you want to delete.">
<!-- <field name="job_ids" nolabel="1" />-->
<field name="delete_database"/>
<field name="delete_pv"/>
<field name="delete_svc"/>
<field name="delete_ing"/>
<field name="delete_deployment"/>
<field name="app_id" invisible="1"/>
</group>
<footer>
<button
name="delete_saas_instance"
string="Delete Instance"
type="object"
class="oe_highlight"
/>
<button string="Cancel" class="oe_link" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_saas_app_delete_wizard" model="ir.actions.act_window">
<field name="name">Delete SaaS Instance</field>
<field name="res_model">kk_odoo_saas.app.delete.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_saas_app_delete" />
<field name="target">new</field>
<field name="binding_model_id" ref="kk_odoo_saas.model_kk_odoo_saas_app" />
</record>
</odoo>

View File

@@ -0,0 +1,99 @@
from odoo import fields, models, api
import logging
_logger = logging.getLogger(__name__)
class ContainerArgument(models.TransientModel):
_name = 'saas.app.container.argument'
_description = "Show the Container Arguments while Updating Docker Image"
name = fields.Char("Argument Name")
value = fields.Char("Argument Value")
update_wizard_id = fields.Many2one('kk_odoo_saas.app.update.dkr.img.wizard')
class ContainerEnvVar(models.TransientModel):
_name = 'saas.app.container.env.var'
_description = "Show Container's Env Vars while Updating Docker Image"
name = fields.Char("Variable Name")
value = fields.Char("Variable Value")
value_from = fields.Char("Value From")
update_wizard_id = fields.Many2one('kk_odoo_saas.app.update.dkr.img.wizard')
class SaaSAppUpdateDockerImage(models.TransientModel):
_name = "kk_odoo_saas.app.update.dkr.img.wizard"
_description = "Wizard to Update Docker Image of a SaaS App/Instance"
def _default_app_id(self):
res = False
context = self.env.context
if context.get("active_model") == "kk_odoo_saas.app" and context.get("active_id"):
res = context["active_id"]
return res
@api.model
def default_get(self, default_fields):
deployment_yaml = False
container_arguments = False
context = self.env.context
evs = []
if context.get("active_model") == "kk_odoo_saas.app" and context.get("active_id"):
app_id = context["active_id"]
if app_id:
app_obj = self.env['kk_odoo_saas.app'].browse(app_id)
if app_obj:
deployment = app_obj.get_odoo_deployment()
if deployment:
deployment_yaml = str(deployment)
container_arguments = deployment.spec.template.spec.containers[0].args
container_env_vars = deployment.spec.template.spec.containers[0].env
for cev in container_env_vars:
evs.append({'name': cev.name, 'value': cev.value, 'value_from': False})
cas = ['--database=pos', '--without-demo=True']
container_argument_ids = []
container_env_var_ids = []
for i in range(len(cas)):
key, val = cas[i].split('=')
if key == '--database':
if val == app_obj.client_db_name:
pass
else:
pass
# logic when database name is conflict
else:
container_argument_ids.append((0, 0, {'name': key, 'value': val}))
for i in range(len(evs)):
container_env_var_ids.append((0, 0, evs[i]))
contextual_self = self.with_context(default_deployment_yaml=deployment_yaml,
default_container_arguments=container_arguments or '[]',
default_container_argument_ids=container_argument_ids,
default_container_env_var_ids=container_env_var_ids,
)
return super(SaaSAppUpdateDockerImage, contextual_self).default_get(default_fields)
app_id = fields.Many2one(
comodel_name="kk_odoo_saas.app", string="SaaS App", default=lambda r: r._default_app_id()
)
deployment_yaml = fields.Text('Yaml of kubernetes Deployment')
container_arguments = fields.Char()
container_argument_ids = fields.One2many('saas.app.container.argument', 'update_wizard_id')
container_env_var_ids = fields.One2many('saas.app.container.env.var', 'update_wizard_id')
# container_db = fields.Char('Database name from Container')
# app_db = fields.Char('Database name from App')
is_cft_db = fields.Char("Is Database name Conflicting")
def update_docker_image(self):
if self.app_id:
envs = []
for env_var in self.container_env_var_ids:
envs.append({'name': env_var.name, 'value': env_var.value})
_logger.info(envs)
self.app_id.update_docker_image(container_arguments=self.container_arguments, env_vars=envs)
return {"type": "ir.actions.act_window_close"}

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_saas_app_update_dkr_img" model="ir.ui.view">
<field name="name">Update Docker Image of Instance</field>
<field name="model">kk_odoo_saas.app.update.dkr.img.wizard</field>
<field name="arch" type="xml">
<form string="Update Docker Image of Instance">
<group string="Please choose settings.">
<field name="container_arguments"/>
<field name="container_argument_ids">
<tree string="Options" editable="bottom">
<field name="name" required="1"/>
<field name="value" required="1"/>
</tree>
</field>
<field name="container_env_var_ids">
<tree string="Options" editable="bottom">
<field name="name" required="1"/>
<field name="value" required="1"/>
</tree>
</field>
</group>
<group>
<div class="accordion md-accordion" id="accordionEx" role="tablist" aria-multiselectable="true">
<!-- Accordion card -->
<div class="card">
<!-- Card header -->
<div class="card-header" role="tab" id="headingOne1">
<a data-toggle="collapse" data-parent="#accordionEx" href="#collapseOne1"
aria-expanded="true"
aria-controls="collapseOne1">
<h5 class="mb-0">
<i class="fa fa-arrow-right"/>
Click To See Yaml of Deployment (Advanced)
</h5>
</a>
</div>
<!-- Card body -->
<div id="collapseOne1" class="collapse " role="tabpanel" aria-labelledby="headingOne1"
data-parent="#accordionEx">
<field name="deployment_yaml" widget="ace" options="{'mode': 'yaml'}" nolabel="1"/>
</div>
</div>
</div>
</group>
<footer>
<button
name="update_docker_image"
string="Update Docker Image"
type="object"
class="oe_highlight"
/>
<button string="Cancel" class="oe_link" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_saas_app_update_dkr_img_wizard" model="ir.actions.act_window">
<field name="name">Update Docker Image of Instance</field>
<field name="res_model">kk_odoo_saas.app.update.dkr.img.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_saas_app_update_dkr_img"/>
<field name="target">new</field>
<field name="binding_model_id" ref="kk_odoo_saas.model_kk_odoo_saas_app"/>
</record>
</odoo>