[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>

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
{
'name': "Stripe Integration for SaaS Portal",
'summary': """Stripe Payment Integration for SaaS Portal""",
'author': "CodeTuple Solutions",
'website': "https://codetuple.io",
# Categories can be used to filter modules in modules listing
# Check https://github.com/odoo/odoo/blob/13.0/odoo/addons/base/data/ir_module_category_data.xml
# for the full list
'category': 'Invoicing',
'version': '14.0.0.0',
"license": "OPL-1",
# 'images': [
# 'static/description/icon.png',
# ],
# any module necessary for this one to work correctly
'depends': ['base', 'web', 'kk_odoo_saas', 'rest_api', 'sale_subscription'],
# always loaded
'data': [
'security/ir.model.access.csv',
'views/views.xml',
'views/sale_subscription.xml',
'views/account_move.xml',
'views/res_partner.xml',
# 'views/res_config_settings_views.xml',
],
"application": True,
}

View File

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

View File

@ -0,0 +1,96 @@
import json
from datetime import datetime
from odoo.addons.rest_api.controllers.main import *
from odoo import http, tools, _, SUPERUSER_ID
import stripe
import logging
_logger = logging.getLogger(__name__)
class StripePayment(http.Controller):
@http.route('/api/stripe/create-checkout-session', methods=['POST'], type='http', csrf=False, auth='none', cors=rest_cors_value)
@check_permissions
def create_checkout_session(self, **kw):
cr, uid = request.cr, request.session.uid
api_key = request.env(cr, uid)['ir.config_parameter'].sudo().get_param('stripe_secret_api_key')
saas_portal_url = request.env(cr, uid)['ir.config_parameter'].sudo().get_param('portal_url', 'https://saas.vercel.app')
pscs = request.env(cr, uid)['portal.stripe.checkout.session']
if not api_key:
return error_response_400__invalid_object_id()
stripe.api_key = api_key
try:
body = json.loads(request.httprequest.data)
except Exception as e:
_logger.error(e)
return error_response_400__invalid_object_id()
if body.get('plan_id'):
user = request.env(cr, uid)['res.users'].browse([uid])
partner = user.partner_id
plan = request.env(cr, uid)['saas.package'].sudo().search([('id', '=', body.get('plan_id'))])
if plan and plan.stripe_product_id:
try:
checkout_session = stripe.checkout.Session.create(
line_items=[
{
'price': plan.stripe_product_id,
'quantity': 1,
},
],
client_reference_id=uid,
mode='subscription',
success_url=str(saas_portal_url) + '/payment/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url=str(saas_portal_url) + '/payment/canceled',
customer_email=partner.email if partner.email and not partner.related_stripe_id else None,
customer=partner.related_stripe_id if partner.related_stripe_id else None,
)
pscs.sudo().create({'name': datetime.now(), 'session_id': checkout_session.id, 'user_id': uid})
return successful_response(200, {'redirect_url': checkout_session.url})
except Exception as e:
_logger.error(e)
return error_response_400__invalid_object_id()
return error_response_400__invalid_object_id()
@http.route('/api/stripe/webhooks', methods=['POST'], type='json', csrf=False, auth='public', cors='*')
# @check_permissions
def stripe_webhook(self, **kw):
event = None
stripe_signature = request.httprequest.headers.get('Stripe-Signature')
cr, uid = request.cr, request.session.uid
endpoint_secret = request.env(cr, uid)['ir.config_parameter'].sudo().get_param('stripe_endpoint_secret')
if not stripe_signature or not endpoint_secret:
return json.dumps({'error': 'stripe signature or endpoint secret not found'})
payload = request.httprequest.data
try:
event = stripe.Webhook.construct_event(payload, stripe_signature, endpoint_secret)
if event.get('type') == 'checkout.session.completed':
pscs = request.env(cr, uid)['portal.stripe.checkout.session']
pscs.with_delay().post_session_completion_tasks(session=json.loads(payload)) #<<<-------------
# pscs.post_session_completion_tasks(session=json.loads(payload))
return json.dumps({'response': 'Success! Creating Subscription... '})
except ValueError as e:
# Invalid payload
_logger.error(e)
return json.dumps({'error': 'ValueError occurred'})
except stripe.error.SignatureVerificationError as e:
# Invalid signature
_logger.error(e)
return json.dumps({'error': 'Unable to verify Signature'})
return json.dumps({'error': 'Unknown Error Occurred'})

View File

@ -0,0 +1,4 @@
from . import models
from . import subscription
from . import account_move
from . import res_partner

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
related_stripe_id = fields.Char(string="Related Stripe Invoice")

View File

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
import base64
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class StripeCheckoutSession(models.Model):
_name = 'portal.stripe.checkout.session'
name = fields.Char()
user_id = fields.Many2one('res.users', 'Client User')
session_id = fields.Char()
session_completed = fields.Boolean(default=False)
completion_payload = fields.Text()
def post_session_completion_tasks(self, session=None):
if session is None:
session = {}
_logger.error('session is None, Exiting')
session_object = session.get('data', {'object': False}).get('object')
if session_object:
session_id = session_object.get('id')
client_reference_id = session_object.get('client_reference_id')
if session_id and client_reference_id:
db_session = self.sudo().search([('session_id', '=', session_id), ('session_completed', '=', False),
('user_id', '=', int(client_reference_id))], limit=1)
if db_session: # <<<----------
db_session.sudo().write({'completion_payload': str(session), 'session_completed': True})
stripe_sub_id = session_object.get('subscription')
if stripe_sub_id:
self.env['sale.subscription'].sudo().create_from_stripe_api(user_id=client_reference_id,
stripe_sub_id=stripe_sub_id)
else:
_logger.error('Unable to find stripe checkout session on db')
else:
_logger.error('Session id is not found, Exiting')
else:
_logger.error('session object not found, Exiting')

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class Partner(models.Model):
_inherit = 'res.partner'
related_stripe_id = fields.Char()

View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
import datetime
from odoo import models, fields, api, _
import stripe
import logging
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class SaleSubscription(models.Model):
_inherit = 'sale.subscription'
stripe_subscription_id = fields.Char(string="Related Stripe Subscription")
def create_from_stripe_api(self, user_id=False, stripe_sub_id=False):
if not stripe_sub_id or not user_id:
return
stripe.api_key = self.env['ir.config_parameter'].sudo().get_param('stripe_secret_api_key')
ssub = stripe.Subscription.retrieve(str(stripe_sub_id))
if ssub:
try:
stripe_price_id = ssub.get('items').get('data')[0].get('price').get('id')
if stripe_price_id:
saas_package = self.env['saas.package'].sudo().search([('stripe_product_id', '=', stripe_price_id)])
if saas_package and saas_package.subscription_template:
self._create_from_stripe_api(user_id, saas_package, stripe_sub_id)
else:
_logger.error('Unable to find SaaS package or Subscription Template')
else:
_logger.error('Unable to find Stripe Price ID')
except Exception as e:
_logger.error(str(e))
else:
_logger.error('Unable to find Stripe Subscription object')
def _create_from_stripe_api(self, user_id, package, stripe_sub_id):
user = self.env['res.users'].sudo().search([('id', '=', user_id)], limit=1)
if user and package and stripe_sub_id:
new_sub = self.env['sale.subscription'].sudo().create({
'partner_id': user.partner_id.id,
'template_id': package.subscription_template.id,
'stripe_subscription_id': stripe_sub_id,
'recurring_invoice_line_ids':
[(0, 0,
{'product_id': package.year_product_id.id, 'name': package.name, 'price_unit': package.year_price,
'uom_id': package.year_product_id.uom_id.id})]
})
started = new_sub.sudo().start_subscription()
if started:
try:
invoice_action = new_sub.sudo().generate_recurring_invoice()
invoice_id = invoice_action.get('res_id')
if invoice_id:
invoice_obj = self.env['account.move'].sudo().browse([invoice_id])
if invoice_obj:
invoice_obj.action_post()
new_sub.sudo().create_saas_app_from_subscription(user_id=user_id, package=package)
# Payment = self.env['account.payment'].with_context(default_line_ids=invoice_obj.invoice_line_ids.ids, default_invoice_ids=[(4, invoice_id, False)])
# print(Payment)
# payment_vals = {
# 'date': datetime.date.today(),
# 'amount': invoice_obj.amount_total,
# 'payment_type': 'inbound',
# 'partner_type': 'customer',
# 'partner_id': user.partner_id.id,
# 'line_ids': invoice_obj.invoice_line_ids,
# # 'ref': self.communication,
# # 'journal_id': self.journal_id.id,
# # 'currency_id': self.currency_id.id,
# # 'partner_bank_id': self.partner_bank_id.id,
# # 'payment_method_id': self.payment_method_id.id,
# # 'destination_account_id': self.line_ids[0].account_id.id
# }
#
# payment = Payment.sudo().create(payment_vals)
# print(payment.action_post())
except UserError as e:
_logger.error(e)
else:
_logger.error("Unable to start subscription")
else:
_logger.error('Stripe Subscription Id not found')
def create_saas_app_from_subscription(self, user_id=False, package=False):
saas_app_env = self.env['kk_odoo_saas.app']
def_vals = saas_app_env.default_get(fields_list=['app_name'])
if user_id:
def_vals['admin_user'] = user_id
else:
def_vals['admin_user'] = self.partner_id.user_ids.ids[0]
app_name = def_vals.get('app_name')
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'] = app_name
def_vals['subscription_id'] = self.id
def_vals['client_db_name'] = app_name
# def_vals['module_ids'] = [(6, 0, saas_app_ids)]
if package:
def_vals['docker_image'] = package.docker_image.id
def_vals['name'] = '{}'.format(self.code)
saas_app = saas_app_env.create(def_vals)
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,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_saas_portal_api_stripe_session,access_saas_portal_api_stripe_session,model_portal_stripe_checkout_session,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_portal_api_stripe_session access_saas_portal_api_stripe_session model_portal_stripe_checkout_session kk_odoo_saas.group_saas_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_move_form_inherit_stripe" model="ir.ui.view">
<field name="name">account.move.form.inherit</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='payment_reference']" position="after">
<field name="related_stripe_id"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record model="ir.ui.view" id="res_partner_view_inherit_stripe_integ">
<field name="name">partner.view.stripe.integration</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<field name="category_id" position="after">
<field name="related_stripe_id"/>
</field>
</field>
</record>
</data>
</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="stripe_subscription_id"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,34 @@
<odoo>
<data>
<record id="saas_portal_stripe_checkout_session" model="ir.actions.act_window">
<field name="name">Stripe Checkout Session</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">portal.stripe.checkout.session</field>
<field name="view_mode">tree,form</field>
</record>
<record id="stripe_checkout_session_view_form" model="ir.ui.view">
<field name="name">saas_portal_stripe_view_form</field>
<field name="model">portal.stripe.checkout.session</field>
<field name="arch" type="xml">
<form >
<sheet>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="session_id"/>
<field name="session_completed"/>
<field name="completion_payload"/>
</group>
</sheet>
</form>
</field>
</record>
<menuitem name="Payment" id="saas_payment" parent="kk_odoo_saas.menu_root"/>
<menuitem name="Stripe Checkout Sessions" id="portal_stripe_checkout_session"
parent="saas_payment"
action="saas_portal_stripe_checkout_session"/>
</data>
</odoo>

384
queue_job/README.rst Executable file
View File

@ -0,0 +1,384 @@
=========
Job Queue
=========
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png
:target: https://odoo-community.org/page/development-status
:alt: Mature
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fqueue-lightgray.png?logo=github
:target: https://github.com/OCA/queue/tree/14.0/queue_job
:alt: OCA/queue
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/queue-14-0/queue-14-0-queue_job
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/230/14.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
This addon adds an integrated Job Queue to Odoo.
It allows to postpone method calls executed asynchronously.
Jobs are executed in the background by a ``Jobrunner``, in their own transaction.
Example:
.. code-block:: python
from odoo import models, fields, api
class MyModel(models.Model):
_name = 'my.model'
def my_method(self, a, k=None):
_logger.info('executed with a: %s and k: %s', a, k)
class MyOtherModel(models.Model):
_name = 'my.other.model'
def button_do_stuff(self):
self.env['my.model'].with_delay().my_method('a', k=2)
In the snippet of code above, when we call ``button_do_stuff``, a job **capturing
the method and arguments** will be postponed. It will be executed as soon as the
Jobrunner has a free bucket, which can be instantaneous if no other job is
running.
Features:
* Views for jobs, jobs are stored in PostgreSQL
* Jobrunner: execute the jobs, highly efficient thanks to PostgreSQL's NOTIFY
* Channels: give a capacity for the root channel and its sub-channels and
segregate jobs in them. Allow for instance to restrict heavy jobs to be
executed one at a time while little ones are executed 4 at a times.
* Retries: Ability to retry jobs by raising a type of exception
* Retry Pattern: the 3 first tries, retry after 10 seconds, the 5 next tries,
retry after 1 minutes, ...
* Job properties: priorities, estimated time of arrival (ETA), custom
description, number of retries
* Related Actions: link an action on the job view, such as open the record
concerned by the job
**Table of contents**
.. contents::
:local:
Installation
============
Be sure to have the ``requests`` library.
Configuration
=============
* Using environment variables and command line:
* Adjust environment variables (optional):
- ``ODOO_QUEUE_JOB_CHANNELS=root:4`` or any other channels configuration.
The default is ``root:1``
- if ``xmlrpc_port`` is not set: ``ODOO_QUEUE_JOB_PORT=8069``
* Start Odoo with ``--load=web,queue_job``
and ``--workers`` greater than 1. [1]_
* Using the Odoo configuration file:
.. code-block:: ini
[options]
(...)
workers = 6
server_wide_modules = web,queue_job
(...)
[queue_job]
channels = root:2
* Confirm the runner is starting correctly by checking the odoo log file:
.. code-block::
...INFO...queue_job.jobrunner.runner: starting
...INFO...queue_job.jobrunner.runner: initializing database connections
...INFO...queue_job.jobrunner.runner: queue job runner ready for db <dbname>
...INFO...queue_job.jobrunner.runner: database connections ready
* Create jobs (eg using ``base_import_async``) and observe they
start immediately and in parallel.
* Tip: to enable debug logging for the queue job, use
``--log-handler=odoo.addons.queue_job:DEBUG``
.. [1] It works with the threaded Odoo server too, although this way
of running Odoo is obviously not for production purposes.
Usage
=====
To use this module, you need to:
#. Go to ``Job Queue`` menu
Developers
~~~~~~~~~~
**Configure default options for jobs**
In earlier versions, jobs could be configured using the ``@job`` decorator.
This is now obsolete, they can be configured using optional ``queue.job.function``
and ``queue.job.channel`` XML records.
Example of channel:
.. code-block:: XML
<record id="channel_sale" model="queue.job.channel">
<field name="name">sale</field>
<field name="parent_id" ref="queue_job.channel_root" />
</record>
Example of job function:
.. code-block:: XML
<record id="job_function_sale_order_action_done" model="queue.job.function">
<field name="model_id" ref="sale.model_sale_order"</field>
<field name="method">action_done</field>
<field name="channel_id" ref="channel_sale" />
<field name="related_action" eval='{"func_name": "custom_related_action"}' />
<field name="retry_pattern" eval="{1: 60, 2: 180, 3: 10, 5: 300}" />
</record>
The general form for the ``name`` is: ``<model.name>.method``.
The channel, related action and retry pattern options are optional, they are
documented below.
When writing modules, if 2+ modules add a job function or channel with the same
name (and parent for channels), they'll be merged in the same record, even if
they have different xmlids. On uninstall, the merged record is deleted when all
the modules using it are uninstalled.
**Job function: channel**
The channel where the job will be delayed. The default channel is ``root``.
**Job function: related action**
The *Related Action* appears as a button on the Job's view.
The button will execute the defined action.
The default one is to open the view of the record related to the job (form view
when there is a single record, list view for several records).
In many cases, the default related action is enough and doesn't need
customization, but it can be customized by providing a dictionary on the job
function:
.. code-block:: python
{
"enable": False,
"func_name": "related_action_partner",
"kwargs": {"name": "Partner"},
}
* ``enable``: when ``False``, the button has no effect (default: ``True``)
* ``func_name``: name of the method on ``queue.job`` that returns an action
* ``kwargs``: extra arguments to pass to the related action method
Example of related action code:
.. code-block:: python
class QueueJob(models.Model):
_inherit = 'queue.job'
def related_action_partner(self, name):
self.ensure_one()
model = self.model_name
partner = self.records
action = {
'name': name,
'type': 'ir.actions.act_window',
'res_model': model,
'view_type': 'form',
'view_mode': 'form',
'res_id': partner.id,
}
return action
**Job function: retry pattern**
When a job fails with a retryable error type, it is automatically
retried later. By default, the retry is always 10 minutes later.
A retry pattern can be configured on the job function. What a pattern represents
is "from X tries, postpone to Y seconds". It is expressed as a dictionary where
keys are tries and values are seconds to postpone as integers:
.. code-block:: python
{
1: 10,
5: 20,
10: 30,
15: 300,
}
Based on this configuration, we can tell that:
* 5 first retries are postponed 10 seconds later
* retries 5 to 10 postponed 20 seconds later
* retries 10 to 15 postponed 30 seconds later
* all subsequent retries postponed 5 minutes later
**Bypass jobs on running Odoo**
When you are developing (ie: connector modules) you might want
to bypass the queue job and run your code immediately.
To do so you can set `TEST_QUEUE_JOB_NO_DELAY=1` in your enviroment.
**Bypass jobs in tests**
When writing tests on job-related methods is always tricky to deal with
delayed recordsets. To make your testing life easier
you can set `test_queue_job_no_delay=True` in the context.
Tip: you can do this at test case level like this
.. code-block:: python
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(
cls.env.context,
test_queue_job_no_delay=True, # no jobs thanks
))
Then all your tests execute the job methods synchronously
without delaying any jobs.
Known issues / Roadmap
======================
* After creating a new database or installing ``queue_job`` on an
existing database, Odoo must be restarted for the runner to detect it.
* When Odoo shuts down normally, it waits for running jobs to finish.
However, when the Odoo server crashes or is otherwise force-stopped,
running jobs are interrupted while the runner has no chance to know
they have been aborted. In such situations, jobs may remain in
``started`` or ``enqueued`` state after the Odoo server is halted.
Since the runner has no way to know if they are actually running or
not, and does not know for sure if it is safe to restart the jobs,
it does not attempt to restart them automatically. Such stale jobs
therefore fill the running queue and prevent other jobs to start.
You must therefore requeue them manually, either from the Jobs view,
or by running the following SQL statement *before starting Odoo*:
.. code-block:: sql
update queue_job set state='pending' where state in ('started', 'enqueued')
Changelog
=========
.. [ The change log. The goal of this file is to help readers
understand changes between version. The primary audience is
end users and integrators. Purely technical changes such as
code refactoring must not be mentioned here.
This file may contain ONE level of section titles, underlined
with the ~ (tilde) character. Other section markers are
forbidden and will likely break the structure of the README.rst
or other documents where this fragment is included. ]
Next
~~~~
* [ADD] Run jobrunner as a worker process instead of a thread in the main
process (when running with --workers > 0)
* [REF] ``@job`` and ``@related_action`` deprecated, any method can be delayed,
and configured using ``queue.job.function`` records
* [MIGRATION] from 13.0 branched at rev. e24ff4b
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/queue/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/queue/issues/new?body=module:%20queue_job%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Camptocamp
* ACSONE SA/NV
Contributors
~~~~~~~~~~~~
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
* Stéphane Bidoul <stephane.bidoul@acsone.eu>
* Matthieu Dietrich <matthieu.dietrich@camptocamp.com>
* Jos De Graeve <Jos.DeGraeve@apertoso.be>
* David Lefever <dl@taktik.be>
* Laurent Mignon <laurent.mignon@acsone.eu>
* Laetitia Gangloff <laetitia.gangloff@acsone.eu>
* Cédric Pigeon <cedric.pigeon@acsone.eu>
* Tatiana Deribina <tatiana.deribina@avoin.systems>
* Souheil Bejaoui <souheil.bejaoui@acsone.eu>
* Eric Antones <eantones@nuobit.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px
:target: https://github.com/guewen
:alt: guewen
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-guewen|
This module is part of the `OCA/queue <https://github.com/OCA/queue/tree/14.0/queue_job>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

9
queue_job/__init__.py Executable file
View File

@ -0,0 +1,9 @@
from . import controllers
from . import fields
from . import models
from . import wizards
from . import jobrunner
from .post_init_hook import post_init_hook
# shortcuts
from .job import identity_exact

29
queue_job/__manifest__.py Executable file
View File

@ -0,0 +1,29 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
{
"name": "Job Queue",
"version": "14.0.1.3.1",
"author": "Camptocamp,ACSONE SA/NV,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/queue",
"license": "LGPL-3",
"category": "Generic Modules",
"depends": ["mail"],
"external_dependencies": {"python": ["requests"]},
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"views/queue_job_views.xml",
"views/queue_job_channel_views.xml",
"views/queue_job_function_views.xml",
"wizards/queue_jobs_to_done_views.xml",
"wizards/queue_requeue_job_views.xml",
"views/queue_job_menus.xml",
"data/queue_data.xml",
"data/queue_job_function_data.xml",
],
"installable": True,
"development_status": "Mature",
"maintainers": ["guewen"],
"post_init_hook": "post_init_hook",
}

View File

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

144
queue_job/controllers/main.py Executable file
View File

@ -0,0 +1,144 @@
# Copyright (c) 2015-2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2013-2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
import traceback
from io import StringIO
from psycopg2 import OperationalError
from werkzeug.exceptions import Forbidden
import odoo
from odoo import _, http, tools
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
from ..exception import FailedJobError, NothingToDoJob, RetryableJobError
from ..job import ENQUEUED, Job
_logger = logging.getLogger(__name__)
PG_RETRY = 5 # seconds
class RunJobController(http.Controller):
def _try_perform_job(self, env, job):
"""Try to perform the job."""
job.set_started()
job.store()
env.cr.commit()
_logger.debug("%s started", job)
job.perform()
job.set_done()
job.store()
env["base"].flush()
env.cr.commit()
_logger.debug("%s done", job)
@http.route("/queue_job/runjob", type="http", auth="none", save_session=False)
def runjob(self, db, job_uuid, **kw):
http.request.session.db = db
env = http.request.env(user=odoo.SUPERUSER_ID)
def retry_postpone(job, message, seconds=None):
job.env.clear()
with odoo.api.Environment.manage():
with odoo.registry(job.env.cr.dbname).cursor() as new_cr:
job.env = job.env(cr=new_cr)
job.postpone(result=message, seconds=seconds)
job.set_pending(reset_retry=False)
job.store()
new_cr.commit()
# ensure the job to run is in the correct state and lock the record
env.cr.execute(
"SELECT state FROM queue_job WHERE uuid=%s AND state=%s FOR UPDATE",
(job_uuid, ENQUEUED),
)
if not env.cr.fetchone():
_logger.warning(
"was requested to run job %s, but it does not exist, "
"or is not in state %s",
job_uuid,
ENQUEUED,
)
return ""
job = Job.load(env, job_uuid)
assert job and job.state == ENQUEUED
try:
try:
self._try_perform_job(env, job)
except OperationalError as err:
# Automatically retry the typical transaction serialization
# errors
if err.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY:
raise
retry_postpone(
job, tools.ustr(err.pgerror, errors="replace"), seconds=PG_RETRY
)
_logger.debug("%s OperationalError, postponed", job)
except NothingToDoJob as err:
if str(err):
msg = str(err)
else:
msg = _("Job interrupted and set to Done: nothing to do.")
job.set_done(msg)
job.store()
env.cr.commit()
except RetryableJobError as err:
# delay the job later, requeue
retry_postpone(job, str(err), seconds=err.seconds)
_logger.debug("%s postponed", job)
except (FailedJobError, Exception):
buff = StringIO()
traceback.print_exc(file=buff)
_logger.error(buff.getvalue())
job.env.clear()
with odoo.api.Environment.manage():
with odoo.registry(job.env.cr.dbname).cursor() as new_cr:
job.env = job.env(cr=new_cr)
job.set_failed(exc_info=buff.getvalue())
job.store()
new_cr.commit()
raise
return ""
@http.route("/queue_job/create_test_job", type="http", auth="user")
def create_test_job(
self, priority=None, max_retries=None, channel="root", description="Test job"
):
if not http.request.env.user.has_group("base.group_erp_manager"):
raise Forbidden(_("Access Denied"))
if priority is not None:
try:
priority = int(priority)
except ValueError:
priority = None
if max_retries is not None:
try:
max_retries = int(max_retries)
except ValueError:
max_retries = None
delayed = (
http.request.env["queue.job"]
.with_delay(
priority=priority,
max_retries=max_retries,
channel=channel,
description=description,
)
._test_job()
)
return delayed.db_record().uuid

37
queue_job/data/queue_data.xml Executable file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data noupdate="1">
<record id="ir_cron_queue_job_garbage_collector" model="ir.cron">
<field name="name">Jobs Garbage Collector</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field ref="model_queue_job" name="model_id" />
<field name="state">code</field>
<field name="code">model.requeue_stuck_jobs()</field>
</record>
<!-- Queue-job-related subtypes for messaging / Chatter -->
<record id="mt_job_failed" model="mail.message.subtype">
<field name="name">Job failed</field>
<field name="res_model">queue.job</field>
<field name="default" eval="True" />
</record>
<record id="ir_cron_autovacuum_queue_jobs" model="ir.cron">
<field name="name">AutoVacuum Job Queue</field>
<field ref="model_queue_job" name="model_id" />
<field eval="True" name="active" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
<field name="state">code</field>
<field name="code">model.autovacuum()</field>
</record>
</data>
<data noupdate="0">
<record model="queue.job.channel" id="channel_root">
<field name="name">root</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,6 @@
<odoo noupdate="1">
<record id="job_function_queue_job__test_job" model="queue.job.function">
<field name="model_id" ref="queue_job.model_queue_job" />
<field name="method">_test_job</field>
</record>
</odoo>

43
queue_job/exception.py Executable file
View File

@ -0,0 +1,43 @@
# Copyright 2012-2016 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
class BaseQueueJobError(Exception):
"""Base queue job error"""
class JobError(BaseQueueJobError):
"""A job had an error"""
class NoSuchJobError(JobError):
"""The job does not exist."""
class FailedJobError(JobError):
"""A job had an error having to be resolved."""
class RetryableJobError(JobError):
"""A job had an error but can be retried.
The job will be retried after the given number of seconds. If seconds is
empty, it will be retried according to the ``retry_pattern`` of the job or
by :const:`odoo.addons.queue_job.job.RETRY_INTERVAL` if nothing is defined.
If ``ignore_retry`` is True, the retry counter will not be increased.
"""
def __init__(self, msg, seconds=None, ignore_retry=False):
super().__init__(msg)
self.seconds = seconds
self.ignore_retry = ignore_retry
# TODO: remove support of NothingToDo: too dangerous
class NothingToDoJob(JobError):
"""The Job has nothing to do."""
class ChannelNotFound(BaseQueueJobError):
"""A channel could not be found"""

118
queue_job/fields.py Executable file
View File

@ -0,0 +1,118 @@
# copyright 2016 Camptocamp
# license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import json
from datetime import date, datetime
import dateutil
import lxml
from odoo import fields, models
from odoo.tools.func import lazy
class JobSerialized(fields.Field):
"""Provide the storage for job fields stored as json
A base_type must be set, it must be dict, list or tuple.
When the field is not set, the json will be the corresponding
json string ("{}" or "[]").
Support for some custom types has been added to the json decoder/encoder
(see JobEncoder and JobDecoder).
"""
type = "job_serialized"
column_type = ("text", "text")
_base_type = None
# these are the default values when we convert an empty value
_default_json_mapping = {
dict: "{}",
list: "[]",
tuple: "[]",
models.BaseModel: lambda env: json.dumps(
{"_type": "odoo_recordset", "model": "base", "ids": [], "uid": env.uid}
),
}
def __init__(self, string=fields.Default, base_type=fields.Default, **kwargs):
super().__init__(string=string, _base_type=base_type, **kwargs)
def _setup_attrs(self, model, name):
super()._setup_attrs(model, name)
if self._base_type not in self._default_json_mapping:
raise ValueError("%s is not a supported base type" % (self._base_type))
def _base_type_default_json(self, env):
default_json = self._default_json_mapping.get(self._base_type)
if not isinstance(default_json, str):
default_json = default_json(env)
return default_json
def convert_to_column(self, value, record, values=None, validate=True):
return self.convert_to_cache(value, record, validate=validate)
def convert_to_cache(self, value, record, validate=True):
# cache format: json.dumps(value) or None
if isinstance(value, self._base_type):
return json.dumps(value, cls=JobEncoder)
else:
return value or None
def convert_to_record(self, value, record):
default = self._base_type_default_json(record.env)
return json.loads(value or default, cls=JobDecoder, env=record.env)
class JobEncoder(json.JSONEncoder):
"""Encode Odoo recordsets so that we can later recompose them"""
def default(self, obj):
if isinstance(obj, models.BaseModel):
return {
"_type": "odoo_recordset",
"model": obj._name,
"ids": obj.ids,
"uid": obj.env.uid,
"su": obj.env.su,
}
elif isinstance(obj, datetime):
return {"_type": "datetime_isoformat", "value": obj.isoformat()}
elif isinstance(obj, date):
return {"_type": "date_isoformat", "value": obj.isoformat()}
elif isinstance(obj, lxml.etree._Element):
return {
"_type": "etree_element",
"value": lxml.etree.tostring(obj, encoding=str),
}
elif isinstance(obj, lazy):
return obj._value
return json.JSONEncoder.default(self, obj)
class JobDecoder(json.JSONDecoder):
"""Decode json, recomposing recordsets"""
def __init__(self, *args, **kwargs):
env = kwargs.pop("env")
super().__init__(object_hook=self.object_hook, *args, **kwargs)
assert env
self.env = env
def object_hook(self, obj):
if "_type" not in obj:
return obj
type_ = obj["_type"]
if type_ == "odoo_recordset":
model = self.env(user=obj.get("uid"), su=obj.get("su"))[obj["model"]]
return model.browse(obj["ids"])
elif type_ == "datetime_isoformat":
return dateutil.parser.parse(obj["value"])
elif type_ == "date_isoformat":
return dateutil.parser.parse(obj["value"]).date()
elif type_ == "etree_element":
return lxml.etree.fromstring(obj["value"])
return obj

826
queue_job/i18n/de.po Executable file
View File

@ -0,0 +1,826 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * queue_job
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2020-07-22 12:20+0000\n"
"Last-Translator: c2cdidier <didier.donze@camptocamp.com>\n"
"Language-Team: none\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 3.10\n"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid ""
"<span class=\"oe_grey oe_inline\"> If the max. retries is 0, the number of "
"retries is infinite.</span>"
msgstr ""
"<span class=\"oe_grey oe_inline\">Wenn die maximale Anzahl der Wiederholung "
"auf 0 gesetzt ist, wird dies als unendlich interpretiert.</span>"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Access Denied"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction
msgid "Action Needed"
msgstr "Aktion notwendig"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids
msgid "Activities"
msgstr "Aktivitäten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration
#, fuzzy
msgid "Activity Exception Decoration"
msgstr "Exception-Information"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state
msgid "Activity State"
msgstr "Aktivitätsstatus"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__args
msgid "Args"
msgstr "Argumente"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count
msgid "Attachment Count"
msgstr "Anzahl der Anhänge"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_autovacuum_queue_jobs
#: model:ir.cron,name:queue_job.ir_cron_autovacuum_queue_jobs
msgid "AutoVacuum Job Queue"
msgstr "AutoVacuum für Job-Warteschlange"
#. module: queue_job
#: model:ir.model,name:queue_job.model_base
msgid "Base"
msgstr "Basis"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Cancel"
msgstr "Abbrechen"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot change the root channel"
msgstr "Der Root-Kanal kann nicht geändert werden"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot remove the root channel"
msgstr "Der Root-Kanal kann nicht entfernt werden"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Channel"
msgstr "Kanal"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name
msgid "Channel Method Name"
msgstr "Kanal-Methodenname"
#. module: queue_job
#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq
msgid "Channel complete name must be unique"
msgstr "Der vollständige Name des Kanals muss eindeutig sein"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel
#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search
msgid "Channels"
msgstr "Kanäle"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id
msgid "Company"
msgstr "Unternehmen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel
msgid "Complete Name"
msgstr "Vollständiger Name"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created
msgid "Created Date"
msgstr "Erstellt am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid
msgid "Created by"
msgstr "Erstellt von"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date
msgid "Created on"
msgstr "Erstellt am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry
msgid "Current try"
msgstr "Aktueller Versuch"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Current try / max. retries"
msgstr "Aktueller Versuch / max. Anzahl der Wiederholung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done
msgid "Date Done"
msgstr "Erledigt am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__name
msgid "Description"
msgstr "Beschreibung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name
msgid "Display Name"
msgstr "Anzeigename"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Done"
msgstr "Erledigt"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued
msgid "Enqueue Time"
msgstr "Zeit der Einreihung"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Enqueued"
msgstr "Eingereiht"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info
msgid "Exception Info"
msgstr "Exception-Info"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Exception Information"
msgstr "Exception-Information"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta
msgid "Execute only after"
msgstr "Erst ausführen nach"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Failed"
msgstr "Fehlgeschlagen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype
msgid "Field Type"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_ir_model_fields
msgid "Fields"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids
msgid "Followers"
msgstr "Abonnenten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_channel_ids
msgid "Followers (Channels)"
msgstr "Abonnenten (Kanäle)"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids
msgid "Followers (Partners)"
msgstr "Abonnenten (Partner)"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Group By"
msgstr "Gruppieren nach"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id
msgid "ID"
msgstr "ID"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon
msgid "Icon"
msgstr "Symbol"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Symbol zur Kennzeichnung einer Ausnahmeaktivität."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key
msgid "Identity Key"
msgstr "Identitätsschlüssel"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, fuzzy, python-format
msgid "If both parameters are 0, ALL jobs will be requeued!"
msgstr "Die ausgewählten Jobs werden erneut eingereiht."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread
msgid "If checked, new messages require your attention."
msgstr "Wenn es gesetzt ist, erfordern neue Nachrichten Ihre Aufmerksamkeit."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr ""
"Wenn es gesetzt ist, gibt es einige Nachrichten mit einem Übertragungsfehler."
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Invalid job function: {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower
msgid "Is Follower"
msgstr "Ist Abonnent"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job_channel
msgid "Job Channels"
msgstr "Job-Kanäle"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Job Function"
msgstr "Job-Funktion"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_function
#: model:ir.model,name:queue_job.model_queue_job_function
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job_function
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
msgid "Job Functions"
msgstr "Job-Funktionen"
#. module: queue_job
#: model:ir.module.category,name:queue_job.module_category_queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue_job_root
msgid "Job Queue"
msgstr "Job-Warteschlange"
#. module: queue_job
#: model:res.groups,name:queue_job.group_queue_job_manager
msgid "Job Queue Manager"
msgstr "Job-Warteschlangenverwalter"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized
#, fuzzy
msgid "Job Serialized"
msgstr "Job ist fehlgeschlagen"
#. module: queue_job
#: model:mail.message.subtype,name:queue_job.mt_job_failed
msgid "Job failed"
msgstr "Job ist fehlgeschlagen"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Job interrupted and set to Done: nothing to do."
msgstr "Job unterbrochen und als Erledigt markiert: Es ist nicht zu tun."
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Jobs"
msgstr "Jobs"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_queue_job_garbage_collector_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_queue_job_garbage_collector
#: model:ir.cron,name:queue_job.ir_cron_queue_job_garbage_collector
msgid "Jobs Garbage Collector"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs
msgid "Kwargs"
msgstr "Kwargs"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job____last_update
msgid "Last Modified on"
msgstr "Zuletzt geändert am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid
msgid "Last Updated by"
msgstr "Zuletzt aktualisiert von"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date
msgid "Last Updated on"
msgstr "Zuletzt aktualisiert am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_main_attachment_id
msgid "Main Attachment"
msgstr "Haupt-Anhang"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Manually set to done by %s"
msgstr "Manuell als Erledigt markiert von: %s"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries
msgid "Max. retries"
msgstr "max. Anzahl von Wiederholungen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error
msgid "Message Delivery error"
msgstr "Fehler bei Nachrichtenübermittlung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids
msgid "Messages"
msgstr "Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method
#, fuzzy
msgid "Method"
msgstr "Methodenname"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name
msgid "Method Name"
msgstr "Methodenname"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id
msgid "Model"
msgstr "Modell"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Model {} not found"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name
msgid "Name"
msgstr "Bezeichnung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Fälligkeit der nächsten Aktivität"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary
msgid "Next Activity Summary"
msgstr "Zusammenfassung der nächsten Aktivität"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id
msgid "Next Activity Type"
msgstr "Typ der nächsten Aktivität"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "No action available for this job"
msgstr "Für diesen Job ist keine Aktion verfügbar"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Not allowed to change field(s): {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter
msgid "Number of Actions"
msgstr "Anzahl der Aktionen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter
msgid "Number of errors"
msgstr "Anzahl der Fehler"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr "Das ist die Anzahl von Nachrichten, die eine Aktion benötigen"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Das ist die Anzahl von Nachrichten mit Übermittlungsfehler"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread_counter
msgid "Number of unread messages"
msgstr "Das ist die Anzahl von ungelesenen Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__override_channel
msgid "Override Channel"
msgstr "Kanal überschreiben"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id
msgid "Parent Channel"
msgstr "Übergeordneter Kanal"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Parent channel required."
msgstr "Es ist ein übergeordneter Kanal notwendig."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern
msgid ""
"Pattern expressing from the count of retries on retryable errors, the number "
"of of seconds to postpone the next execution.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Pending"
msgstr "Ausstehend"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority
msgid "Priority"
msgstr "Priorität"
#. module: queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue
msgid "Queue"
msgstr "Warteschlange"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job
msgid "Queue Job"
msgstr "Job einreihen"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Queue jobs must created by calling 'with_delay()'."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__record_ids
msgid "Record"
msgstr "Datensatz"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__records
#, fuzzy
msgid "Record(s)"
msgstr "Datensatz"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Related"
msgstr "Zugehörige Aktion anzeigen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action
#, fuzzy
msgid "Related Action"
msgstr "Zugehöriger Datensatz"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action
msgid "Related Action (serialized)"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Record"
msgstr "Zugehöriger Datensatz"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Records"
msgstr "Zugehörige Datensätze"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval
msgid "Removal Interval"
msgstr "Entfernungsintervall"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue"
msgstr "Erneut einreihen"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Requeue Job"
msgstr "Job erneut einreihen"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue Jobs"
msgstr "Jobs erneut einreihen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id
msgid "Responsible User"
msgstr "Verantwortlicher Benutzer"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__result
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Result"
msgstr "Ergebnis"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern
msgid "Retry Pattern"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern
msgid "Retry Pattern (serialized)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Fehler bei der SMS Nachrichtenübermittlung"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_jobs_to_done
msgid "Set all selected jobs to done"
msgstr "Alle ausgewählten Jobs als Erledigt markieren"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set jobs done"
msgstr "Jobs als Erledigt markieren"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done
msgid "Set jobs to done"
msgstr "Jobs als Erledigt markieren"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Set to 'Done'"
msgstr "Als Erledigt markieren"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set to done"
msgstr "Als Erledigt markieren"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid ""
"Something bad happened during the execution of the job. More details in the "
"'Exception Information' section."
msgstr ""
"Bei der Ausführung des Jobs ist etwas Ungewöhnliches passiert. Beachten Sie "
"die Details im Abschnitt \"Exception-Information\"."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started
msgid "Start Date"
msgstr "Gestartet am"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Started"
msgstr "Gestartet"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__state
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "State"
msgstr "Status"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Der Status hängt von den Aktivitäten ab.\n"
"Überfällig: Das Fälligkeitsdatum der Aktivität ist überschritten.\n"
"Heute: Die Aktivität findet heute statt.\n"
"Geplant: Die Aktivitäten findet in der Zukunft statt."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string
msgid "Task"
msgstr "Aufgabe"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action
msgid ""
"The action when the button *Related Action* is used on a job. The default "
"action is to open the view of the record related to the job. Configured as a "
"dictionary with optional keys: enable, func_name, kwargs.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries
msgid ""
"The job will fail if the number of tries reach the max. retries.\n"
"Retries are infinite when empty."
msgstr ""
"Der Job wird fehlschlagen, wenn die Anzahl der Versuche gleich der maximalen "
"Anzahl der Wiederholungen ist.\n"
"Wenn Letzteres nicht gesetzt ist, werden unendlich viele Versuche "
"unternommen."
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "The selected jobs will be requeued."
msgstr "Die ausgewählten Jobs werden erneut eingereiht."
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "The selected jobs will be set to done."
msgstr "Die ausgewählten Jobs werden als Erledigt markiert."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Typ der Ausnahmeaktivität im Datensatz."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid
msgid "UUID"
msgstr "UUID"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs"
"\" {{\"limit\": 10}}}}"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread
msgid "Unread Messages"
msgstr "Ungelesene Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread_counter
msgid "Unread Messages Counter"
msgstr "Zähler für ungelesene Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id
msgid "User ID"
msgstr "Benutzer"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_requeue_job
msgid "Wizard to requeue a selection of jobs"
msgstr "Assistent zur erneuten Einreihung einer Job-Auswahl"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid
msgid "Worker Pid"
msgstr ""
#~ msgid "Website Messages"
#~ msgstr "Website Nachrichten"
#~ msgid "Website communication history"
#~ msgstr "Historie der Website-Kommunikation"
#~ msgid "If checked new messages require your attention."
#~ msgstr ""
#~ "Wenn es gesetzt ist, erfordern neue Nachrichten Ihre Aufmerksamkeit."
#~ msgid "Overdue"
#~ msgstr "Überfällig"
#~ msgid "Planned"
#~ msgstr "Geplant"
#~ msgid "Today"
#~ msgstr "Heute"

782
queue_job/i18n/queue_job.pot Executable file
View File

@ -0,0 +1,782 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * queue_job
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid ""
"<span class=\"oe_grey oe_inline\"> If the max. retries is 0, the number of "
"retries is infinite.</span>"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Access Denied"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction
msgid "Action Needed"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids
msgid "Activities"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state
msgid "Activity State"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__args
msgid "Args"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count
msgid "Attachment Count"
msgstr ""
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_autovacuum_queue_jobs
#: model:ir.cron,name:queue_job.ir_cron_autovacuum_queue_jobs
msgid "AutoVacuum Job Queue"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_base
msgid "Base"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Cancel"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot change the root channel"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot remove the root channel"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Channel"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name
msgid "Channel Method Name"
msgstr ""
#. module: queue_job
#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq
msgid "Channel complete name must be unique"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel
#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search
msgid "Channels"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id
msgid "Company"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel
msgid "Complete Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created
msgid "Created Date"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid
msgid "Created by"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date
msgid "Created on"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry
msgid "Current try"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Current try / max. retries"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done
msgid "Date Done"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__name
msgid "Description"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name
msgid "Display Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Done"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued
msgid "Enqueue Time"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Enqueued"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info
msgid "Exception Info"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Exception Information"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta
msgid "Execute only after"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Failed"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype
msgid "Field Type"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_ir_model_fields
msgid "Fields"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids
msgid "Followers"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_channel_ids
msgid "Followers (Channels)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids
msgid "Followers (Partners)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Group By"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id
msgid "ID"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon
msgid "Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key
msgid "Identity Key"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "If both parameters are 0, ALL jobs will be requeued!"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread
msgid "If checked, new messages require your attention."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Invalid job function: {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower
msgid "Is Follower"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job_channel
msgid "Job Channels"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Job Function"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_function
#: model:ir.model,name:queue_job.model_queue_job_function
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job_function
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
msgid "Job Functions"
msgstr ""
#. module: queue_job
#: model:ir.module.category,name:queue_job.module_category_queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue_job_root
msgid "Job Queue"
msgstr ""
#. module: queue_job
#: model:res.groups,name:queue_job.group_queue_job_manager
msgid "Job Queue Manager"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized
msgid "Job Serialized"
msgstr ""
#. module: queue_job
#: model:mail.message.subtype,name:queue_job.mt_job_failed
msgid "Job failed"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Job interrupted and set to Done: nothing to do."
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Jobs"
msgstr ""
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_queue_job_garbage_collector_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_queue_job_garbage_collector
#: model:ir.cron,name:queue_job.ir_cron_queue_job_garbage_collector
msgid "Jobs Garbage Collector"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs
msgid "Kwargs"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job____last_update
msgid "Last Modified on"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid
msgid "Last Updated by"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date
msgid "Last Updated on"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_main_attachment_id
msgid "Main Attachment"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Manually set to done by %s"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries
msgid "Max. retries"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error
msgid "Message Delivery error"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids
msgid "Messages"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method
msgid "Method"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name
msgid "Method Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id
msgid "Model"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Model {} not found"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name
msgid "Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline
msgid "Next Activity Deadline"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary
msgid "Next Activity Summary"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id
msgid "Next Activity Type"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "No action available for this job"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Not allowed to change field(s): {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter
msgid "Number of Actions"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter
msgid "Number of errors"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread_counter
msgid "Number of unread messages"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__override_channel
msgid "Override Channel"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id
msgid "Parent Channel"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Parent channel required."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern
msgid ""
"Pattern expressing from the count of retries on retryable errors, the number of of seconds to postpone the next execution.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Pending"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority
msgid "Priority"
msgstr ""
#. module: queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue
msgid "Queue"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job
msgid "Queue Job"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Queue jobs must created by calling 'with_delay()'."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__record_ids
msgid "Record"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__records
msgid "Record(s)"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Related"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action
msgid "Related Action"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action
msgid "Related Action (serialized)"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Record"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Records"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval
msgid "Removal Interval"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Requeue Job"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue Jobs"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id
msgid "Responsible User"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__result
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Result"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern
msgid "Retry Pattern"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern
msgid "Retry Pattern (serialized)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_sms_error
msgid "SMS Delivery error"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_jobs_to_done
msgid "Set all selected jobs to done"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set jobs done"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done
msgid "Set jobs to done"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Set to 'Done'"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set to done"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid ""
"Something bad happened during the execution of the job. More details in the "
"'Exception Information' section."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started
msgid "Start Date"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Started"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__state
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "State"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string
msgid "Task"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action
msgid ""
"The action when the button *Related Action* is used on a job. The default action is to open the view of the record related to the job. Configured as a dictionary with optional keys: enable, func_name, kwargs.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries
msgid ""
"The job will fail if the number of tries reach the max. retries.\n"
"Retries are infinite when empty."
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "The selected jobs will be requeued."
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "The selected jobs will be set to done."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid
msgid "UUID"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" {{\"limit\": 10}}}}"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread
msgid "Unread Messages"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread_counter
msgid "Unread Messages Counter"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id
msgid "User ID"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_requeue_job
msgid "Wizard to requeue a selection of jobs"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid
msgid "Worker Pid"
msgstr ""

820
queue_job/i18n/zh_CN.po Executable file
View File

@ -0,0 +1,820 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * queue_job
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2020-03-23 06:13+0000\n"
"Last-Translator: 黎伟杰 <674416404@qq.com>\n"
"Language-Team: none\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 3.10\n"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid ""
"<span class=\"oe_grey oe_inline\"> If the max. retries is 0, the number of "
"retries is infinite.</span>"
msgstr ""
"<span class=\"oe_grey oe_inline\">如果最大重试次数是0则重试次数是无限的。</"
"span>"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Access Denied"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction
msgid "Action Needed"
msgstr "前置操作"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids
msgid "Activities"
msgstr "活动"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "活动异常装饰"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state
msgid "Activity State"
msgstr "活动状态"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__args
msgid "Args"
msgstr "位置参数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count
msgid "Attachment Count"
msgstr "附件数量"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_autovacuum_queue_jobs
#: model:ir.cron,name:queue_job.ir_cron_autovacuum_queue_jobs
msgid "AutoVacuum Job Queue"
msgstr "自动清空作业队列"
#. module: queue_job
#: model:ir.model,name:queue_job.model_base
msgid "Base"
msgstr "基础"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Cancel"
msgstr "取消"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot change the root channel"
msgstr "无法更改root频道"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot remove the root channel"
msgstr "无法删除root频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Channel"
msgstr "频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name
msgid "Channel Method Name"
msgstr "频道方法名称"
#. module: queue_job
#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq
msgid "Channel complete name must be unique"
msgstr "频道完整名称必须是唯一的"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel
#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search
msgid "Channels"
msgstr "频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id
msgid "Company"
msgstr "公司"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel
msgid "Complete Name"
msgstr "完整名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created
msgid "Created Date"
msgstr "创建日期"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid
msgid "Created by"
msgstr "创建者"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date
msgid "Created on"
msgstr "创建时间"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry
msgid "Current try"
msgstr "当前尝试"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Current try / max. retries"
msgstr "当前尝试/最大重试次数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done
msgid "Date Done"
msgstr "完成日期"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__name
msgid "Description"
msgstr "说明"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name
msgid "Display Name"
msgstr "显示名称"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Done"
msgstr "完成"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued
msgid "Enqueue Time"
msgstr "排队时间"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Enqueued"
msgstr "排队"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info
msgid "Exception Info"
msgstr "异常信息"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Exception Information"
msgstr "异常信息"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta
msgid "Execute only after"
msgstr "仅在此之后执行"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Failed"
msgstr "失败"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype
msgid "Field Type"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_ir_model_fields
msgid "Fields"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids
msgid "Followers"
msgstr "关注者"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_channel_ids
msgid "Followers (Channels)"
msgstr "关注者(频道)"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids
msgid "Followers (Partners)"
msgstr "关注者(业务伙伴)"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Group By"
msgstr "分组"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id
msgid "ID"
msgstr "ID"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon
msgid "Icon"
msgstr "图标"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "指示异常活动的图标。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key
msgid "Identity Key"
msgstr "身份密钥"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, fuzzy, python-format
msgid "If both parameters are 0, ALL jobs will be requeued!"
msgstr "所选作业将重新排队。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread
msgid "If checked, new messages require your attention."
msgstr "确认后, 出现提示消息。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "如果勾选此项, 某些消息将会产生传递错误。"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Invalid job function: {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower
msgid "Is Follower"
msgstr "关注者"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job_channel
msgid "Job Channels"
msgstr "作业频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Job Function"
msgstr "作业函数"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_function
#: model:ir.model,name:queue_job.model_queue_job_function
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job_function
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
msgid "Job Functions"
msgstr "作业函数"
#. module: queue_job
#: model:ir.module.category,name:queue_job.module_category_queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue_job_root
msgid "Job Queue"
msgstr "作业队列"
#. module: queue_job
#: model:res.groups,name:queue_job.group_queue_job_manager
msgid "Job Queue Manager"
msgstr "作业队列管理员"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized
#, fuzzy
msgid "Job Serialized"
msgstr "作业失败"
#. module: queue_job
#: model:mail.message.subtype,name:queue_job.mt_job_failed
msgid "Job failed"
msgstr "作业失败"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Job interrupted and set to Done: nothing to do."
msgstr "作业中断并设置为已完成:无需执行任何操作。"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Jobs"
msgstr "作业"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_queue_job_garbage_collector_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_queue_job_garbage_collector
#: model:ir.cron,name:queue_job.ir_cron_queue_job_garbage_collector
msgid "Jobs Garbage Collector"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs
msgid "Kwargs"
msgstr "关键字参数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job____last_update
msgid "Last Modified on"
msgstr "最后修改日"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid
msgid "Last Updated by"
msgstr "最后更新者"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date
msgid "Last Updated on"
msgstr "最后更新时间"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_main_attachment_id
msgid "Main Attachment"
msgstr "附件"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Manually set to done by %s"
msgstr "由%s手动设置为完成"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries
msgid "Max. retries"
msgstr "最大重试次数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error
msgid "Message Delivery error"
msgstr "消息递送错误"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids
msgid "Messages"
msgstr "消息"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method
#, fuzzy
msgid "Method"
msgstr "方法名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name
msgid "Method Name"
msgstr "方法名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id
msgid "Model"
msgstr "模型"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Model {} not found"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name
msgid "Name"
msgstr "名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "下一活动截止日期"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary
msgid "Next Activity Summary"
msgstr "下一活动摘要"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id
msgid "Next Activity Type"
msgstr "下一活动类型"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "No action available for this job"
msgstr "此作业无法执行任何操作"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Not allowed to change field(s): {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter
msgid "Number of Actions"
msgstr "操作次数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter
msgid "Number of errors"
msgstr "错误数量"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr "需要操作消息数量"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "递送错误消息数量"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread_counter
msgid "Number of unread messages"
msgstr "未读消息数量"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__override_channel
msgid "Override Channel"
msgstr "覆盖频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id
msgid "Parent Channel"
msgstr "父频道"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Parent channel required."
msgstr "父频道必填。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern
msgid ""
"Pattern expressing from the count of retries on retryable errors, the number "
"of of seconds to postpone the next execution.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Pending"
msgstr "等待"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority
msgid "Priority"
msgstr "优先级"
#. module: queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue
msgid "Queue"
msgstr "队列"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job
msgid "Queue Job"
msgstr "队列作业"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Queue jobs must created by calling 'with_delay()'."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__record_ids
msgid "Record"
msgstr "记录"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__records
#, fuzzy
msgid "Record(s)"
msgstr "记录"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Related"
msgstr "相关的"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action
#, fuzzy
msgid "Related Action"
msgstr "相关记录"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action
msgid "Related Action (serialized)"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Record"
msgstr "相关记录"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Records"
msgstr "相关记录"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval
msgid "Removal Interval"
msgstr "清除间隔"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue"
msgstr "重新排队"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Requeue Job"
msgstr "重新排队作业"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue Jobs"
msgstr "重新排队作业"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id
msgid "Responsible User"
msgstr "负责的用户"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__result
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Result"
msgstr "结果"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern
msgid "Retry Pattern"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern
msgid "Retry Pattern (serialized)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_sms_error
msgid "SMS Delivery error"
msgstr "短信传递错误"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_jobs_to_done
msgid "Set all selected jobs to done"
msgstr "将所有选定的作业设置为完成"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set jobs done"
msgstr "设置作业完成"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done
msgid "Set jobs to done"
msgstr "将作业设置为完成"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Set to 'Done'"
msgstr "设置为“完成”"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set to done"
msgstr "设置为完成"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid ""
"Something bad happened during the execution of the job. More details in the "
"'Exception Information' section."
msgstr ""
"在执行作业期间发生了一些不好的事情。有关详细信息,请参见“异常信息”部分。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started
msgid "Start Date"
msgstr "开始日期"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Started"
msgstr "开始"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__state
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "State"
msgstr "状态"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"基于活动的状态\n"
"逾期:已经超过截止日期\n"
"现今:活动日期是当天\n"
"计划:未来的活动。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string
msgid "Task"
msgstr "任务"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action
msgid ""
"The action when the button *Related Action* is used on a job. The default "
"action is to open the view of the record related to the job. Configured as a "
"dictionary with optional keys: enable, func_name, kwargs.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries
msgid ""
"The job will fail if the number of tries reach the max. retries.\n"
"Retries are infinite when empty."
msgstr ""
"如果尝试次数达到最大重试次数,作业将失败。\n"
"空的时候重试是无限的。"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "The selected jobs will be requeued."
msgstr "所选作业将重新排队。"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "The selected jobs will be set to done."
msgstr "所选作业将设置为完成。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "记录的异常活动的类型。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid
msgid "UUID"
msgstr "UUID"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs"
"\" {{\"limit\": 10}}}}"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread
msgid "Unread Messages"
msgstr "未读消息"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread_counter
msgid "Unread Messages Counter"
msgstr "未读消息计数器"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id
msgid "User ID"
msgstr "用户"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_requeue_job
msgid "Wizard to requeue a selection of jobs"
msgstr "重新排队向导所选的作业"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid
msgid "Worker Pid"
msgstr ""
#~ msgid "Website Messages"
#~ msgstr "网站消息"
#~ msgid "Website communication history"
#~ msgstr "网站交流历史"
#~ msgid "If checked new messages require your attention."
#~ msgstr "查看是否有需要留意的新消息。"
#~ msgid "Overdue"
#~ msgstr "逾期"
#~ msgid "Planned"
#~ msgstr "计划"
#~ msgid "Today"
#~ msgstr "今天"

736
queue_job/job.py Executable file
View File

@ -0,0 +1,736 @@
# Copyright 2013-2020 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import hashlib
import inspect
import logging
import os
import sys
import uuid
from datetime import datetime, timedelta
from random import randint
import odoo
from .exception import FailedJobError, NoSuchJobError, RetryableJobError
PENDING = "pending"
ENQUEUED = "enqueued"
DONE = "done"
STARTED = "started"
FAILED = "failed"
STATES = [
(PENDING, "Pending"),
(ENQUEUED, "Enqueued"),
(STARTED, "Started"),
(DONE, "Done"),
(FAILED, "Failed"),
]
DEFAULT_PRIORITY = 10 # used by the PriorityQueue to sort the jobs
DEFAULT_MAX_RETRIES = 20
RETRY_INTERVAL = 1 * 60 # seconds
_logger = logging.getLogger(__name__)
class DelayableRecordset(object):
"""Allow to delay a method for a recordset
Usage::
delayable = DelayableRecordset(recordset, priority=20)
delayable.method(args, kwargs)
The method call will be processed asynchronously in the job queue, with
the passed arguments.
This class will generally not be used directly, it is used internally
by :meth:`~odoo.addons.queue_job.models.base.Base.with_delay`
"""
def __init__(
self,
recordset,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
self.recordset = recordset
self.priority = priority
self.eta = eta
self.max_retries = max_retries
self.description = description
self.channel = channel
self.identity_key = identity_key
def __getattr__(self, name):
if name in self.recordset:
raise AttributeError(
"only methods can be delayed ({} called on {})".format(
name, self.recordset
)
)
recordset_method = getattr(self.recordset, name)
def delay(*args, **kwargs):
return Job.enqueue(
recordset_method,
args=args,
kwargs=kwargs,
priority=self.priority,
max_retries=self.max_retries,
eta=self.eta,
description=self.description,
channel=self.channel,
identity_key=self.identity_key,
)
return delay
def __str__(self):
return "DelayableRecordset({}{})".format(
self.recordset._name, getattr(self.recordset, "_ids", "")
)
__repr__ = __str__
def identity_exact(job_):
"""Identity function using the model, method and all arguments as key
When used, this identity key will have the effect that when a job should be
created and a pending job with the exact same recordset and arguments, the
second will not be created.
It should be used with the ``identity_key`` argument:
.. python::
from odoo.addons.queue_job.job import identity_exact
# [...]
delayable = self.with_delay(identity_key=identity_exact)
delayable.export_record(force=True)
Alternative identity keys can be built using the various fields of the job.
For example, you could compute a hash using only some arguments of
the job.
.. python::
def identity_example(job_):
hasher = hashlib.sha1()
hasher.update(job_.model_name)
hasher.update(job_.method_name)
hasher.update(str(sorted(job_.recordset.ids)))
hasher.update(str(job_.args[1]))
hasher.update(str(job_.kwargs.get('foo', '')))
return hasher.hexdigest()
Usually you will probably always want to include at least the name of the
model and method.
"""
hasher = hashlib.sha1()
hasher.update(job_.model_name.encode("utf-8"))
hasher.update(job_.method_name.encode("utf-8"))
hasher.update(str(sorted(job_.recordset.ids)).encode("utf-8"))
hasher.update(str(job_.args).encode("utf-8"))
hasher.update(str(sorted(job_.kwargs.items())).encode("utf-8"))
return hasher.hexdigest()
class Job(object):
"""A Job is a task to execute. It is the in-memory representation of a job.
Jobs are stored in the ``queue.job`` Odoo Model, but they are handled
through this class.
.. attribute:: uuid
Id (UUID) of the job.
.. attribute:: state
State of the job, can pending, enqueued, started, done or failed.
The start state is pending and the final state is done.
.. attribute:: retry
The current try, starts at 0 and each time the job is executed,
it increases by 1.
.. attribute:: max_retries
The maximum number of retries allowed before the job is
considered as failed.
.. attribute:: args
Arguments passed to the function when executed.
.. attribute:: kwargs
Keyword arguments passed to the function when executed.
.. attribute:: description
Human description of the job.
.. attribute:: func
The python function itself.
.. attribute:: model_name
Odoo model on which the job will run.
.. attribute:: priority
Priority of the job, 0 being the higher priority.
.. attribute:: date_created
Date and time when the job was created.
.. attribute:: date_enqueued
Date and time when the job was enqueued.
.. attribute:: date_started
Date and time when the job was started.
.. attribute:: date_done
Date and time when the job was done.
.. attribute:: result
A description of the result (for humans).
.. attribute:: exc_info
Exception information (traceback) when the job failed.
.. attribute:: user_id
Odoo user id which created the job
.. attribute:: eta
Estimated Time of Arrival of the job. It will not be executed
before this date/time.
.. attribute:: recordset
Model recordset when we are on a delayed Model method
.. attribute::channel
The complete name of the channel to use to process the job. If
provided it overrides the one defined on the job's function.
.. attribute::identity_key
A key referencing the job, multiple job with the same key will not
be added to a channel if the existing job with the same key is not yet
started or executed.
"""
@classmethod
def load(cls, env, job_uuid):
"""Read a job from the Database"""
stored = cls.db_record_from_uuid(env, job_uuid)
if not stored:
raise NoSuchJobError(
"Job %s does no longer exist in the storage." % job_uuid
)
return cls._load_from_db_record(stored)
@classmethod
def _load_from_db_record(cls, job_db_record):
stored = job_db_record
args = stored.args
kwargs = stored.kwargs
method_name = stored.method_name
recordset = stored.records
method = getattr(recordset, method_name)
eta = None
if stored.eta:
eta = stored.eta
job_ = cls(
method,
args=args,
kwargs=kwargs,
priority=stored.priority,
eta=eta,
job_uuid=stored.uuid,
description=stored.name,
channel=stored.channel,
identity_key=stored.identity_key,
)
if stored.date_created:
job_.date_created = stored.date_created
if stored.date_enqueued:
job_.date_enqueued = stored.date_enqueued
if stored.date_started:
job_.date_started = stored.date_started
if stored.date_done:
job_.date_done = stored.date_done
job_.state = stored.state
job_.result = stored.result if stored.result else None
job_.exc_info = stored.exc_info if stored.exc_info else None
job_.retry = stored.retry
job_.max_retries = stored.max_retries
if stored.company_id:
job_.company_id = stored.company_id.id
job_.identity_key = stored.identity_key
job_.worker_pid = stored.worker_pid
return job_
def job_record_with_same_identity_key(self):
"""Check if a job to be executed with the same key exists."""
existing = (
self.env["queue.job"]
.sudo()
.search(
[
("identity_key", "=", self.identity_key),
("state", "in", [PENDING, ENQUEUED]),
],
limit=1,
)
)
return existing
@classmethod
def enqueue(
cls,
func,
args=None,
kwargs=None,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Create a Job and enqueue it in the queue. Return the job uuid.
This expects the arguments specific to the job to be already extracted
from the ones to pass to the job function.
If the identity key is the same than the one in a pending job,
no job is created and the existing job is returned
"""
new_job = cls(
func=func,
args=args,
kwargs=kwargs,
priority=priority,
eta=eta,
max_retries=max_retries,
description=description,
channel=channel,
identity_key=identity_key,
)
if new_job.identity_key:
existing = new_job.job_record_with_same_identity_key()
if existing:
_logger.debug(
"a job has not been enqueued due to having "
"the same identity key (%s) than job %s",
new_job.identity_key,
existing.uuid,
)
return Job._load_from_db_record(existing)
new_job.store()
_logger.debug(
"enqueued %s:%s(*%r, **%r) with uuid: %s",
new_job.recordset,
new_job.method_name,
new_job.args,
new_job.kwargs,
new_job.uuid,
)
return new_job
@staticmethod
def db_record_from_uuid(env, job_uuid):
model = env["queue.job"].sudo()
record = model.search([("uuid", "=", job_uuid)], limit=1)
return record.with_env(env).sudo()
def __init__(
self,
func,
args=None,
kwargs=None,
priority=None,
eta=None,
job_uuid=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Create a Job
:param func: function to execute
:type func: function
:param args: arguments for func
:type args: tuple
:param kwargs: keyworkd arguments for func
:type kwargs: dict
:param priority: priority of the job,
the smaller is the higher priority
:type priority: int
:param eta: the job can be executed only after this datetime
(or now + timedelta)
:type eta: datetime or timedelta
:param job_uuid: UUID of the job
:param max_retries: maximum number of retries before giving up and set
the job state to 'failed'. A value of 0 means infinite retries.
:param description: human description of the job. If None, description
is computed from the function doc or name
:param channel: The complete channel name to use to process the job.
:param identity_key: A hash to uniquely identify a job, or a function
that returns this hash (the function takes the job
as argument)
:param env: Odoo Environment
:type env: :class:`odoo.api.Environment`
"""
if args is None:
args = ()
if isinstance(args, list):
args = tuple(args)
assert isinstance(args, tuple), "%s: args are not a tuple" % args
if kwargs is None:
kwargs = {}
assert isinstance(kwargs, dict), "%s: kwargs are not a dict" % kwargs
if not _is_model_method(func):
raise TypeError("Job accepts only methods of Models")
recordset = func.__self__
env = recordset.env
self.method_name = func.__name__
self.recordset = recordset
self.env = env
self.job_model = self.env["queue.job"]
self.job_model_name = "queue.job"
self.job_config = (
self.env["queue.job.function"]
.sudo()
.job_config(
self.env["queue.job.function"].job_function_name(
self.model_name, self.method_name
)
)
)
self.state = PENDING
self.retry = 0
if max_retries is None:
self.max_retries = DEFAULT_MAX_RETRIES
else:
self.max_retries = max_retries
self._uuid = job_uuid
self.args = args
self.kwargs = kwargs
self.priority = priority
if self.priority is None:
self.priority = DEFAULT_PRIORITY
self.date_created = datetime.now()
self._description = description
if isinstance(identity_key, str):
self._identity_key = identity_key
self._identity_key_func = None
else:
# we'll compute the key on the fly when called
# from the function
self._identity_key = None
self._identity_key_func = identity_key
self.date_enqueued = None
self.date_started = None
self.date_done = None
self.result = None
self.exc_info = None
if "company_id" in env.context:
company_id = env.context["company_id"]
else:
company_id = env.company.id
self.company_id = company_id
self._eta = None
self.eta = eta
self.channel = channel
self.worker_pid = None
def perform(self):
"""Execute the job.
The job is executed with the user which has initiated it.
"""
self.retry += 1
try:
self.result = self.func(*tuple(self.args), **self.kwargs)
except RetryableJobError as err:
if err.ignore_retry:
self.retry -= 1
raise
elif not self.max_retries: # infinite retries
raise
elif self.retry >= self.max_retries:
type_, value, traceback = sys.exc_info()
# change the exception type but keep the original
# traceback and message:
# http://blog.ianbicking.org/2007/09/12/re-raising-exceptions/
new_exc = FailedJobError(
"Max. retries (%d) reached: %s" % (self.max_retries, value or type_)
)
raise new_exc from err
raise
return self.result
def store(self):
"""Store the Job"""
vals = {
"state": self.state,
"priority": self.priority,
"retry": self.retry,
"max_retries": self.max_retries,
"exc_info": self.exc_info,
"company_id": self.company_id,
"result": str(self.result) if self.result else False,
"date_enqueued": False,
"date_started": False,
"date_done": False,
"eta": False,
"identity_key": False,
"worker_pid": self.worker_pid,
}
if self.date_enqueued:
vals["date_enqueued"] = self.date_enqueued
if self.date_started:
vals["date_started"] = self.date_started
if self.date_done:
vals["date_done"] = self.date_done
if self.eta:
vals["eta"] = self.eta
if self.identity_key:
vals["identity_key"] = self.identity_key
job_model = self.env["queue.job"]
# The sentinel is used to prevent edition sensitive fields (such as
# method_name) from RPC methods.
edit_sentinel = job_model.EDIT_SENTINEL
db_record = self.db_record()
if db_record:
db_record.with_context(_job_edit_sentinel=edit_sentinel).write(vals)
else:
date_created = self.date_created
# The following values must never be modified after the
# creation of the job
vals.update(
{
"uuid": self.uuid,
"name": self.description,
"date_created": date_created,
"method_name": self.method_name,
"records": self.recordset,
"args": self.args,
"kwargs": self.kwargs,
}
)
# it the channel is not specified, lets the job_model compute
# the right one to use
if self.channel:
vals.update({"channel": self.channel})
job_model.with_context(_job_edit_sentinel=edit_sentinel).sudo().create(vals)
def db_record(self):
return self.db_record_from_uuid(self.env, self.uuid)
@property
def func(self):
recordset = self.recordset.with_context(job_uuid=self.uuid)
return getattr(recordset, self.method_name)
@property
def identity_key(self):
if self._identity_key is None:
if self._identity_key_func:
self._identity_key = self._identity_key_func(self)
return self._identity_key
@identity_key.setter
def identity_key(self, value):
if isinstance(value, str):
self._identity_key = value
self._identity_key_func = None
else:
# we'll compute the key on the fly when called
# from the function
self._identity_key = None
self._identity_key_func = value
@property
def description(self):
if self._description:
return self._description
elif self.func.__doc__:
return self.func.__doc__.splitlines()[0].strip()
else:
return "{}.{}".format(self.model_name, self.func.__name__)
@property
def uuid(self):
"""Job ID, this is an UUID """
if self._uuid is None:
self._uuid = str(uuid.uuid4())
return self._uuid
@property
def model_name(self):
return self.recordset._name
@property
def user_id(self):
return self.recordset.env.uid
@property
def eta(self):
return self._eta
@eta.setter
def eta(self, value):
if not value:
self._eta = None
elif isinstance(value, timedelta):
self._eta = datetime.now() + value
elif isinstance(value, int):
self._eta = datetime.now() + timedelta(seconds=value)
else:
self._eta = value
def set_pending(self, result=None, reset_retry=True):
self.state = PENDING
self.date_enqueued = None
self.date_started = None
self.worker_pid = None
if reset_retry:
self.retry = 0
if result is not None:
self.result = result
def set_enqueued(self):
self.state = ENQUEUED
self.date_enqueued = datetime.now()
self.date_started = None
self.worker_pid = None
def set_started(self):
self.state = STARTED
self.date_started = datetime.now()
self.worker_pid = os.getpid()
def set_done(self, result=None):
self.state = DONE
self.exc_info = None
self.date_done = datetime.now()
if result is not None:
self.result = result
def set_failed(self, exc_info=None):
self.state = FAILED
if exc_info is not None:
self.exc_info = exc_info
def __repr__(self):
return "<Job %s, priority:%d>" % (self.uuid, self.priority)
def _get_retry_seconds(self, seconds=None):
retry_pattern = self.job_config.retry_pattern
if not seconds and retry_pattern:
# ordered from higher to lower count of retries
patt = sorted(retry_pattern.items(), key=lambda t: t[0])
seconds = RETRY_INTERVAL
for retry_count, postpone_seconds in patt:
if self.retry >= retry_count:
seconds = postpone_seconds
else:
break
elif not seconds:
seconds = RETRY_INTERVAL
if isinstance(seconds, (list, tuple)):
seconds = randint(seconds[0], seconds[1])
return seconds
def postpone(self, result=None, seconds=None):
"""Postpone the job
Write an estimated time arrival to n seconds
later than now. Used when an retryable exception
want to retry a job later.
"""
eta_seconds = self._get_retry_seconds(seconds)
self.eta = timedelta(seconds=eta_seconds)
self.exc_info = None
if result is not None:
self.result = result
def related_action(self):
record = self.db_record()
if not self.job_config.related_action_enable:
return None
funcname = self.job_config.related_action_func_name
if not funcname:
funcname = record._default_related_action
if not isinstance(funcname, str):
raise ValueError(
"related_action must be the name of the "
"method on queue.job as string"
)
action = getattr(record, funcname)
action_kwargs = self.job_config.related_action_kwargs
return action(**action_kwargs)
def _is_model_method(func):
return inspect.ismethod(func) and isinstance(
func.__self__.__class__, odoo.models.MetaModel
)

149
queue_job/jobrunner/__init__.py Executable file
View File

@ -0,0 +1,149 @@
# Copyright (c) 2015-2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
from threading import Thread
import time
from odoo.service import server
from odoo.tools import config
try:
from odoo.addons.server_environment import serv_config
if serv_config.has_section("queue_job"):
queue_job_config = serv_config["queue_job"]
else:
queue_job_config = {}
except ImportError:
queue_job_config = config.misc.get("queue_job", {})
from .runner import QueueJobRunner, _channels
_logger = logging.getLogger(__name__)
START_DELAY = 5
# Here we monkey patch the Odoo server to start the job runner thread
# in the main server process (and not in forked workers). This is
# very easy to deploy as we don't need another startup script.
class QueueJobRunnerThread(Thread):
def __init__(self):
Thread.__init__(self)
self.daemon = True
self.runner = QueueJobRunner.from_environ_or_config()
def run(self):
# sleep a bit to let the workers start at ease
time.sleep(START_DELAY)
self.runner.run()
def stop(self):
self.runner.stop()
class WorkerJobRunner(server.Worker):
""" Jobrunner workers """
def __init__(self, multi):
super().__init__(multi)
self.watchdog_timeout = None
self.runner = QueueJobRunner.from_environ_or_config()
def sleep(self):
pass
def signal_handler(self, sig, frame):
_logger.debug("WorkerJobRunner (%s) received signal %s", self.pid, sig)
super().signal_handler(sig, frame)
self.runner.stop()
def process_work(self):
_logger.debug("WorkerJobRunner (%s) starting up", self.pid)
time.sleep(START_DELAY)
self.runner.run()
runner_thread = None
def _is_runner_enabled():
return not _channels().strip().startswith("root:0")
def _start_runner_thread(server_type):
global runner_thread
if not config["stop_after_init"]:
if _is_runner_enabled():
_logger.info("starting jobrunner thread (in %s)", server_type)
runner_thread = QueueJobRunnerThread()
runner_thread.start()
else:
_logger.info(
"jobrunner thread (in %s) NOT started, "
"because the root channel's capacity is set to 0",
server_type,
)
orig_prefork__init__ = server.PreforkServer.__init__
orig_prefork_process_spawn = server.PreforkServer.process_spawn
orig_prefork_worker_pop = server.PreforkServer.worker_pop
orig_threaded_start = server.ThreadedServer.start
orig_threaded_stop = server.ThreadedServer.stop
def prefork__init__(server, app):
res = orig_prefork__init__(server, app)
server.jobrunner = {}
return res
def prefork_process_spawn(server):
orig_prefork_process_spawn(server)
if not hasattr(server, "jobrunner"):
# if 'queue_job' is not in server wide modules, PreforkServer is
# not initialized with a 'jobrunner' attribute, skip this
return
if not server.jobrunner and _is_runner_enabled():
server.worker_spawn(WorkerJobRunner, server.jobrunner)
def prefork_worker_pop(server, pid):
res = orig_prefork_worker_pop(server, pid)
if not hasattr(server, "jobrunner"):
# if 'queue_job' is not in server wide modules, PreforkServer is
# not initialized with a 'jobrunner' attribute, skip this
return res
if pid in server.jobrunner:
server.jobrunner.pop(pid)
return res
def threaded_start(server, *args, **kwargs):
res = orig_threaded_start(server, *args, **kwargs)
_start_runner_thread("threaded server")
return res
def threaded_stop(server):
global runner_thread
if runner_thread:
runner_thread.stop()
res = orig_threaded_stop(server)
if runner_thread:
runner_thread.join()
runner_thread = None
return res
server.PreforkServer.__init__ = prefork__init__
server.PreforkServer.process_spawn = prefork_process_spawn
server.PreforkServer.worker_pop = prefork_worker_pop
server.ThreadedServer.start = threaded_start
server.ThreadedServer.stop = threaded_stop

1060
queue_job/jobrunner/channels.py Executable file

File diff suppressed because it is too large Load Diff

520
queue_job/jobrunner/runner.py Executable file
View File

@ -0,0 +1,520 @@
# Copyright (c) 2015-2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2015-2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
"""
What is the job runner?
-----------------------
The job runner is the main process managing the dispatch of delayed jobs to
available Odoo workers
How does it work?
-----------------
* It starts as a thread in the Odoo main process or as a new worker
* It receives postgres NOTIFY messages each time jobs are
added or updated in the queue_job table.
* It maintains an in-memory priority queue of jobs that
is populated from the queue_job tables in all databases.
* It does not run jobs itself, but asks Odoo to run them through an
anonymous ``/queue_job/runjob`` HTTP request. [1]_
How to use it?
--------------
* Optionally adjust your configuration through environment variables:
- ``ODOO_QUEUE_JOB_CHANNELS=root:4`` (or any other channels
configuration), default ``root:1``.
- ``ODOO_QUEUE_JOB_SCHEME=https``, default ``http``.
- ``ODOO_QUEUE_JOB_HOST=load-balancer``, default ``http_interface``
or ``localhost`` if unset.
- ``ODOO_QUEUE_JOB_PORT=443``, default ``http_port`` or 8069 if unset.
- ``ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner``, default empty.
- ``ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t``, default empty.
- ``ODOO_QUEUE_JOB_JOBRUNNER_DB_HOST=master-db``, default ``db_host``
or ``False`` if unset.
- ``ODOO_QUEUE_JOB_JOBRUNNER_DB_PORT=5432``, default ``db_port``
or ``False`` if unset.
* Alternatively, configure the channels through the Odoo configuration
file, like:
.. code-block:: ini
[queue_job]
channels = root:4
scheme = https
host = load-balancer
port = 443
http_auth_user = jobrunner
http_auth_password = s3cr3t
jobrunner_db_host = master-db
jobrunner_db_port = 5432
* Or, if using ``anybox.recipe.odoo``, add this to your buildout configuration:
.. code-block:: ini
[odoo]
recipe = anybox.recipe.odoo
(...)
queue_job.channels = root:4
queue_job.scheme = https
queue_job.host = load-balancer
queue_job.port = 443
queue_job.http_auth_user = jobrunner
queue_job.http_auth_password = s3cr3t
* Start Odoo with ``--load=web,web_kanban,queue_job``
and ``--workers`` greater than 1 [2]_, or set the ``server_wide_modules``
option in The Odoo configuration file:
.. code-block:: ini
[options]
(...)
workers = 4
server_wide_modules = web,web_kanban,queue_job
(...)
* Or, if using ``anybox.recipe.odoo``:
.. code-block:: ini
[odoo]
recipe = anybox.recipe.odoo
(...)
options.workers = 4
options.server_wide_modules = web,web_kanban,queue_job
* Confirm the runner is starting correctly by checking the odoo log file:
.. code-block:: none
...INFO...queue_job.jobrunner.runner: starting
...INFO...queue_job.jobrunner.runner: initializing database connections
...INFO...queue_job.jobrunner.runner: queue job runner ready for db <dbname>
...INFO...queue_job.jobrunner.runner: database connections ready
* Create jobs (eg using base_import_async) and observe they
start immediately and in parallel.
* Tip: to enable debug logging for the queue job, use
``--log-handler=odoo.addons.queue_job:DEBUG``
Caveat
------
* After creating a new database or installing queue_job on an
existing database, Odoo must be restarted for the runner to detect it.
* When Odoo shuts down normally, it waits for running jobs to finish.
However, when the Odoo server crashes or is otherwise force-stopped,
running jobs are interrupted while the runner has no chance to know
they have been aborted. In such situations, jobs may remain in
``started`` or ``enqueued`` state after the Odoo server is halted.
Since the runner has no way to know if they are actually running or
not, and does not know for sure if it is safe to restart the jobs,
it does not attempt to restart them automatically. Such stale jobs
therefore fill the running queue and prevent other jobs to start.
You must therefore requeue them manually, either from the Jobs view,
or by running the following SQL statement *before starting Odoo*:
.. code-block:: sql
update queue_job set state='pending' where state in ('started', 'enqueued')
.. rubric:: Footnotes
.. [1] From a security standpoint, it is safe to have an anonymous HTTP
request because this request only accepts to run jobs that are
enqueued.
.. [2] It works with the threaded Odoo server too, although this way
of running Odoo is obviously not for production purposes.
"""
import datetime
import logging
import os
import select
import threading
import time
from contextlib import closing, contextmanager
import psycopg2
import requests
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
import odoo
from odoo.tools import config
from . import queue_job_config
from .channels import ENQUEUED, NOT_DONE, PENDING, ChannelManager
SELECT_TIMEOUT = 60
ERROR_RECOVERY_DELAY = 5
_logger = logging.getLogger(__name__)
# Unfortunately, it is not possible to extend the Odoo
# server command line arguments, so we resort to environment variables
# to configure the runner (channels mostly).
#
# On the other hand, the odoo configuration file can be extended at will,
# so we check it in addition to the environment variables.
def _channels():
return (
os.environ.get("ODOO_QUEUE_JOB_CHANNELS")
or queue_job_config.get("channels")
or "root:1"
)
def _datetime_to_epoch(dt):
# important: this must return the same as postgresql
# EXTRACT(EPOCH FROM TIMESTAMP dt)
return (dt - datetime.datetime(1970, 1, 1)).total_seconds()
def _odoo_now():
dt = datetime.datetime.utcnow()
return _datetime_to_epoch(dt)
def _connection_info_for(db_name):
db_or_uri, connection_info = odoo.sql_db.connection_info_for(db_name)
for p in ("host", "port"):
cfg = os.environ.get(
"ODOO_QUEUE_JOB_JOBRUNNER_DB_%s" % p.upper()
) or queue_job_config.get("jobrunner_db_" + p)
if cfg:
connection_info[p] = cfg
return connection_info
def _async_http_get(scheme, host, port, user, password, db_name, job_uuid):
# Method to set failed job (due to timeout, etc) as pending,
# to avoid keeping it as enqueued.
def set_job_pending():
connection_info = _connection_info_for(db_name)
conn = psycopg2.connect(**connection_info)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
with closing(conn.cursor()) as cr:
cr.execute(
"UPDATE queue_job SET state=%s, "
"date_enqueued=NULL, date_started=NULL "
"WHERE uuid=%s and state=%s "
"RETURNING uuid",
(PENDING, job_uuid, ENQUEUED),
)
if cr.fetchone():
_logger.warning(
"state of job %s was reset from %s to %s",
job_uuid,
ENQUEUED,
PENDING,
)
# TODO: better way to HTTP GET asynchronously (grequest, ...)?
# if this was python3 I would be doing this with
# asyncio, aiohttp and aiopg
def urlopen():
url = "{}://{}:{}/queue_job/runjob?db={}&job_uuid={}".format(
scheme, host, port, db_name, job_uuid
)
try:
auth = None
if user:
auth = (user, password)
# we are not interested in the result, so we set a short timeout
# but not too short so we trap and log hard configuration errors
response = requests.get(url, timeout=1, auth=auth)
# raise_for_status will result in either nothing, a Client Error
# for HTTP Response codes between 400 and 500 or a Server Error
# for codes between 500 and 600
response.raise_for_status()
except requests.Timeout:
set_job_pending()
except Exception:
_logger.exception("exception in GET %s", url)
set_job_pending()
thread = threading.Thread(target=urlopen)
thread.daemon = True
thread.start()
class Database(object):
def __init__(self, db_name):
self.db_name = db_name
connection_info = _connection_info_for(db_name)
self.conn = psycopg2.connect(**connection_info)
self.conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
self.has_queue_job = self._has_queue_job()
if self.has_queue_job:
self._initialize()
def close(self):
# pylint: disable=except-pass
# if close fail for any reason, it's either because it's already closed
# and we don't care, or for any reason but anyway it will be closed on
# del
try:
self.conn.close()
except Exception:
pass
self.conn = None
def _has_queue_job(self):
with closing(self.conn.cursor()) as cr:
cr.execute(
"SELECT 1 FROM pg_tables WHERE tablename=%s", ("ir_module_module",)
)
if not cr.fetchone():
_logger.debug("%s doesn't seem to be an odoo db", self.db_name)
return False
cr.execute(
"SELECT 1 FROM ir_module_module WHERE name=%s AND state=%s",
("queue_job", "installed"),
)
if not cr.fetchone():
_logger.debug("queue_job is not installed for db %s", self.db_name)
return False
cr.execute(
"""SELECT COUNT(1)
FROM information_schema.triggers
WHERE event_object_table = %s
AND trigger_name = %s""",
("queue_job", "queue_job_notify"),
)
if cr.fetchone()[0] != 3: # INSERT, DELETE, UPDATE
_logger.error(
"queue_job_notify trigger is missing in db %s", self.db_name
)
return False
return True
def _initialize(self):
with closing(self.conn.cursor()) as cr:
cr.execute("LISTEN queue_job")
@contextmanager
def select_jobs(self, where, args):
# pylint: disable=sql-injection
# the checker thinks we are injecting values but we are not, we are
# adding the where conditions, values are added later properly with
# parameters
query = (
"SELECT channel, uuid, id as seq, date_created, "
"priority, EXTRACT(EPOCH FROM eta), state "
"FROM queue_job WHERE %s" % (where,)
)
with closing(self.conn.cursor("select_jobs", withhold=True)) as cr:
cr.execute(query, args)
yield cr
def keep_alive(self):
query = "SELECT 1"
with closing(self.conn.cursor()) as cr:
cr.execute(query)
def set_job_enqueued(self, uuid):
with closing(self.conn.cursor()) as cr:
cr.execute(
"UPDATE queue_job SET state=%s, "
"date_enqueued=date_trunc('seconds', "
" now() at time zone 'utc') "
"WHERE uuid=%s",
(ENQUEUED, uuid),
)
class QueueJobRunner(object):
def __init__(
self,
scheme="http",
host="localhost",
port=8069,
user=None,
password=None,
channel_config_string=None,
):
self.scheme = scheme
self.host = host
self.port = port
self.user = user
self.password = password
self.channel_manager = ChannelManager()
if channel_config_string is None:
channel_config_string = _channels()
self.channel_manager.simple_configure(channel_config_string)
self.db_by_name = {}
self._stop = False
self._stop_pipe = os.pipe()
@classmethod
def from_environ_or_config(cls):
scheme = os.environ.get("ODOO_QUEUE_JOB_SCHEME") or queue_job_config.get(
"scheme"
)
host = (
os.environ.get("ODOO_QUEUE_JOB_HOST")
or queue_job_config.get("host")
or config["http_interface"]
)
port = (
os.environ.get("ODOO_QUEUE_JOB_PORT")
or queue_job_config.get("port")
or config["http_port"]
)
user = os.environ.get("ODOO_QUEUE_JOB_HTTP_AUTH_USER") or queue_job_config.get(
"http_auth_user"
)
password = os.environ.get(
"ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD"
) or queue_job_config.get("http_auth_password")
runner = cls(
scheme=scheme or "http",
host=host or "localhost",
port=port or 8069,
user=user,
password=password,
)
return runner
def get_db_names(self):
if config["db_name"]:
db_names = config["db_name"].split(",")
else:
db_names = odoo.service.db.exp_list(True)
return db_names
def close_databases(self, remove_jobs=True):
for db_name, db in self.db_by_name.items():
try:
if remove_jobs:
self.channel_manager.remove_db(db_name)
db.close()
except Exception:
_logger.warning("error closing database %s", db_name, exc_info=True)
self.db_by_name = {}
def initialize_databases(self):
for db_name in self.get_db_names():
db = Database(db_name)
if db.has_queue_job:
self.db_by_name[db_name] = db
with db.select_jobs("state in %s", (NOT_DONE,)) as cr:
for job_data in cr:
self.channel_manager.notify(db_name, *job_data)
_logger.info("queue job runner ready for db %s", db_name)
def run_jobs(self):
now = _odoo_now()
for job in self.channel_manager.get_jobs_to_run(now):
if self._stop:
break
_logger.info("asking Odoo to run job %s on db %s", job.uuid, job.db_name)
self.db_by_name[job.db_name].set_job_enqueued(job.uuid)
_async_http_get(
self.scheme,
self.host,
self.port,
self.user,
self.password,
job.db_name,
job.uuid,
)
def process_notifications(self):
for db in self.db_by_name.values():
if not db.conn.notifies:
# If there are no activity in the queue_job table it seems that
# tcp keepalives are not sent (in that very specific scenario),
# causing some intermediaries (such as haproxy) to close the
# connection, making the jobrunner to restart on a socket error
db.keep_alive()
while db.conn.notifies:
if self._stop:
break
notification = db.conn.notifies.pop()
uuid = notification.payload
with db.select_jobs("uuid = %s", (uuid,)) as cr:
job_datas = cr.fetchone()
if job_datas:
self.channel_manager.notify(db.db_name, *job_datas)
else:
self.channel_manager.remove_job(uuid)
def wait_notification(self):
for db in self.db_by_name.values():
if db.conn.notifies:
# something is going on in the queue, no need to wait
return
# wait for something to happen in the queue_job tables
# we'll select() on database connections and the stop pipe
conns = [db.conn for db in self.db_by_name.values()]
conns.append(self._stop_pipe[0])
# look if the channels specify a wakeup time
wakeup_time = self.channel_manager.get_wakeup_time()
if not wakeup_time:
# this could very well be no timeout at all, because
# any activity in the job queue will wake us up, but
# let's have a timeout anyway, just to be safe
timeout = SELECT_TIMEOUT
else:
timeout = wakeup_time - _odoo_now()
# wait for a notification or a timeout;
# if timeout is negative (ie wakeup time in the past),
# do not wait; this should rarely happen
# because of how get_wakeup_time is designed; actually
# if timeout remains a large negative number, it is most
# probably a bug
_logger.debug("select() timeout: %.2f sec", timeout)
if timeout > 0:
conns, _, _ = select.select(conns, [], [], timeout)
if conns and not self._stop:
for conn in conns:
conn.poll()
def stop(self):
_logger.info("graceful stop requested")
self._stop = True
# wakeup the select() in wait_notification
os.write(self._stop_pipe[1], b".")
def run(self):
_logger.info("starting")
while not self._stop:
# outer loop does exception recovery
try:
_logger.info("initializing database connections")
# TODO: how to detect new databases or databases
# on which queue_job is installed after server start?
self.initialize_databases()
_logger.info("database connections ready")
# inner loop does the normal processing
while not self._stop:
self.process_notifications()
self.run_jobs()
self.wait_notification()
except KeyboardInterrupt:
self.stop()
except InterruptedError:
# Interrupted system call, i.e. KeyboardInterrupt during select
self.stop()
except Exception:
_logger.exception(
"exception: sleeping %ds and retrying", ERROR_RECOVERY_DELAY
)
self.close_databases()
time.sleep(ERROR_RECOVERY_DELAY)
self.close_databases(remove_jobs=False)
_logger.info("stopped")

5
queue_job/models/__init__.py Executable file
View File

@ -0,0 +1,5 @@
from . import base
from . import ir_model_fields
from . import queue_job
from . import queue_job_channel
from . import queue_job_function

191
queue_job/models/base.py Executable file
View File

@ -0,0 +1,191 @@
# Copyright 2016 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import functools
import logging
import os
from odoo import models
from ..job import DelayableRecordset
_logger = logging.getLogger(__name__)
class Base(models.AbstractModel):
"""The base model, which is implicitly inherited by all models.
A new :meth:`~with_delay` method is added on all Odoo Models, allowing to
postpone the execution of a job method in an asynchronous process.
"""
_inherit = "base"
def with_delay(
self,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Return a ``DelayableRecordset``
The returned instance allows to enqueue any method of the recordset's
Model.
Usage::
self.env['res.users'].with_delay().write({'name': 'test'})
``with_delay()`` accepts job properties which specify how the job will
be executed.
Usage with job properties::
delayable = env['a.model'].with_delay(priority=30, eta=60*60*5)
delayable.export_one_thing(the_thing_to_export)
# => the job will be executed with a low priority and not before a
# delay of 5 hours from now
:param priority: Priority of the job, 0 being the higher priority.
Default is 10.
:param eta: Estimated Time of Arrival of the job. It will not be
executed before this date/time.
:param max_retries: maximum number of retries before giving up and set
the job state to 'failed'. A value of 0 means
infinite retries. Default is 5.
:param description: human description of the job. If None, description
is computed from the function doc or name
:param channel: the complete name of the channel to use to process
the function. If specified it overrides the one
defined on the function
:param identity_key: key uniquely identifying the job, if specified
and a job with the same key has not yet been run,
the new job will not be added. It is either a
string, either a function that takes the job as
argument (see :py:func:`..job.identity_exact`).
:return: instance of a DelayableRecordset
:rtype: :class:`odoo.addons.queue_job.job.DelayableRecordset`
Note for developers: if you want to run tests or simply disable
jobs queueing for debugging purposes, you can:
a. set the env var `TEST_QUEUE_JOB_NO_DELAY=1`
b. pass a ctx key `test_queue_job_no_delay=1`
In tests you'll have to mute the logger like:
@mute_logger('odoo.addons.queue_job.models.base')
"""
if os.getenv("TEST_QUEUE_JOB_NO_DELAY"):
_logger.warning(
"`TEST_QUEUE_JOB_NO_DELAY` env var found. NO JOB scheduled."
)
return self
if self.env.context.get("test_queue_job_no_delay"):
_logger.warning(
"`test_queue_job_no_delay` ctx key found. NO JOB scheduled."
)
return self
return DelayableRecordset(
self,
priority=priority,
eta=eta,
max_retries=max_retries,
description=description,
channel=channel,
identity_key=identity_key,
)
def _patch_job_auto_delay(self, method_name, context_key=None):
"""Patch a method to be automatically delayed as job method when called
This patch method has to be called in ``_register_hook`` (example
below).
When a method is patched, any call to the method will not directly
execute the method's body, but will instead enqueue a job.
When a ``context_key`` is set when calling ``_patch_job_auto_delay``,
the patched method is automatically delayed only when this key is
``True`` in the caller's context. It is advised to patch the method
with a ``context_key``, because making the automatic delay *in any
case* can produce nasty and unexpected side effects (e.g. another
module calls the method and expects it to be computed before doing
something else, expecting a result, ...).
A typical use case is when a method in a module we don't control is
called synchronously in the middle of another method, and we'd like all
the calls to this method become asynchronous.
The options of the job usually passed to ``with_delay()`` (priority,
description, identity_key, ...) can be returned in a dictionary by a
method named after the name of the method suffixed by ``_job_options``
which takes the same parameters as the initial method.
It is still possible to force synchronous execution of the method by
setting a key ``_job_force_sync`` to True in the environment context.
Example patching the "foo" method to be automatically delayed as job
(the job options method is optional):
.. code-block:: python
# original method:
def foo(self, arg1):
print("hello", arg1)
def large_method(self):
# doing a lot of things
self.foo("world)
# doing a lot of other things
def button_x(self):
self.with_context(auto_delay_foo=True).large_method()
# auto delay patch:
def foo_job_options(self, arg1):
return {
"priority": 100,
"description": "Saying hello to {}".format(arg1)
}
def _register_hook(self):
self._patch_method(
"foo",
self._patch_job_auto_delay("foo", context_key="auto_delay_foo")
)
return super()._register_hook()
The result when ``button_x`` is called, is that a new job for ``foo``
is delayed.
"""
def auto_delay_wrapper(self, *args, **kwargs):
# when no context_key is set, we delay in any case (warning, can be
# dangerous)
context_delay = self.env.context.get(context_key) if context_key else True
if (
self.env.context.get("job_uuid")
or not context_delay
or self.env.context.get("_job_force_sync")
or self.env.context.get("test_queue_job_no_delay")
):
# we are in the job execution
return auto_delay_wrapper.origin(self, *args, **kwargs)
else:
# replace the synchronous call by a job on itself
method_name = auto_delay_wrapper.origin.__name__
job_options_method = getattr(
self, "{}_job_options".format(method_name), None
)
job_options = {}
if job_options_method:
job_options.update(job_options_method(*args, **kwargs))
delayed = self.with_delay(**job_options)
return getattr(delayed, method_name)(*args, **kwargs)
origin = getattr(self, method_name)
return functools.update_wrapper(auto_delay_wrapper, origin)

View File

@ -0,0 +1,13 @@
# Copyright 2020 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import fields, models
class IrModelFields(models.Model):
_inherit = "ir.model.fields"
ttype = fields.Selection(
selection_add=[("job_serialized", "Job Serialized")],
ondelete={"job_serialized": "cascade"},
)

386
queue_job/models/queue_job.py Executable file
View File

@ -0,0 +1,386 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
from datetime import datetime, timedelta
from odoo import _, api, exceptions, fields, models
from odoo.osv import expression
from ..fields import JobSerialized
from ..job import DONE, PENDING, STATES, Job
_logger = logging.getLogger(__name__)
class QueueJob(models.Model):
"""Model storing the jobs to be executed."""
_name = "queue.job"
_description = "Queue Job"
_inherit = ["mail.thread", "mail.activity.mixin"]
_log_access = False
_order = "date_created DESC, date_done DESC"
_removal_interval = 30 # days
_default_related_action = "related_action_open_record"
# This must be passed in a context key "_job_edit_sentinel" to write on
# protected fields. It protects against crafting "queue.job" records from
# RPC (e.g. on internal methods). When ``with_delay`` is used, the sentinel
# is set.
EDIT_SENTINEL = object()
_protected_fields = (
"uuid",
"name",
"date_created",
"model_name",
"method_name",
"records",
"args",
"kwargs",
)
uuid = fields.Char(string="UUID", readonly=True, index=True, required=True)
user_id = fields.Many2one(
comodel_name="res.users",
string="User ID",
compute="_compute_user_id",
inverse="_inverse_user_id",
store=True,
)
company_id = fields.Many2one(
comodel_name="res.company", string="Company", index=True
)
name = fields.Char(string="Description", readonly=True)
model_name = fields.Char(
string="Model", compute="_compute_model_name", store=True, readonly=True
)
method_name = fields.Char(readonly=True)
# record_ids field is only for backward compatibility (e.g. used in related
# actions), can be removed (replaced by "records") in 14.0
record_ids = JobSerialized(compute="_compute_record_ids", base_type=list)
records = JobSerialized(
string="Record(s)",
readonly=True,
base_type=models.BaseModel,
)
args = JobSerialized(readonly=True, base_type=tuple)
kwargs = JobSerialized(readonly=True, base_type=dict)
func_string = fields.Char(
string="Task", compute="_compute_func_string", readonly=True, store=True
)
state = fields.Selection(STATES, readonly=True, required=True, index=True)
priority = fields.Integer()
exc_info = fields.Text(string="Exception Info", readonly=True)
result = fields.Text(readonly=True)
date_created = fields.Datetime(string="Created Date", readonly=True)
date_started = fields.Datetime(string="Start Date", readonly=True)
date_enqueued = fields.Datetime(string="Enqueue Time", readonly=True)
date_done = fields.Datetime(readonly=True)
eta = fields.Datetime(string="Execute only after")
retry = fields.Integer(string="Current try")
max_retries = fields.Integer(
string="Max. retries",
help="The job will fail if the number of tries reach the "
"max. retries.\n"
"Retries are infinite when empty.",
)
channel_method_name = fields.Char(
readonly=True, compute="_compute_job_function", store=True
)
job_function_id = fields.Many2one(
comodel_name="queue.job.function",
compute="_compute_job_function",
string="Job Function",
readonly=True,
store=True,
)
override_channel = fields.Char()
channel = fields.Char(
compute="_compute_channel", inverse="_inverse_channel", store=True, index=True
)
identity_key = fields.Char()
worker_pid = fields.Integer()
def init(self):
self._cr.execute(
"SELECT indexname FROM pg_indexes WHERE indexname = %s ",
("queue_job_identity_key_state_partial_index",),
)
if not self._cr.fetchone():
self._cr.execute(
"CREATE INDEX queue_job_identity_key_state_partial_index "
"ON queue_job (identity_key) WHERE state in ('pending', "
"'enqueued') AND identity_key IS NOT NULL;"
)
@api.depends("records")
def _compute_user_id(self):
for record in self:
record.user_id = record.records.env.uid
def _inverse_user_id(self):
for record in self.with_context(_job_edit_sentinel=self.EDIT_SENTINEL):
record.records = record.records.with_user(record.user_id.id)
@api.depends("records")
def _compute_model_name(self):
for record in self:
record.model_name = record.records._name
@api.depends("records")
def _compute_record_ids(self):
for record in self:
record.record_ids = record.records.ids
def _inverse_channel(self):
for record in self:
record.override_channel = record.channel
@api.depends("job_function_id.channel_id")
def _compute_channel(self):
for record in self:
channel = (
record.override_channel or record.job_function_id.channel or "root"
)
if record.channel != channel:
record.channel = channel
@api.depends("model_name", "method_name", "job_function_id.channel_id")
def _compute_job_function(self):
for record in self:
func_model = self.env["queue.job.function"]
channel_method_name = func_model.job_function_name(
record.model_name, record.method_name
)
function = func_model.search([("name", "=", channel_method_name)], limit=1)
record.channel_method_name = channel_method_name
record.job_function_id = function
@api.depends("model_name", "method_name", "records", "args", "kwargs")
def _compute_func_string(self):
for record in self:
model = repr(record.records)
args = [repr(arg) for arg in record.args]
kwargs = ["{}={!r}".format(key, val) for key, val in record.kwargs.items()]
all_args = ", ".join(args + kwargs)
record.func_string = "{}.{}({})".format(model, record.method_name, all_args)
@api.model_create_multi
def create(self, vals_list):
if self.env.context.get("_job_edit_sentinel") is not self.EDIT_SENTINEL:
# Prevent to create a queue.job record "raw" from RPC.
# ``with_delay()`` must be used.
raise exceptions.AccessError(
_("Queue jobs must created by calling 'with_delay()'.")
)
return super().create(vals_list)
def write(self, vals):
if self.env.context.get("_job_edit_sentinel") is not self.EDIT_SENTINEL:
write_on_protected_fields = [
fieldname for fieldname in vals if fieldname in self._protected_fields
]
if write_on_protected_fields:
raise exceptions.AccessError(
_("Not allowed to change field(s): {}").format(
write_on_protected_fields
)
)
if vals.get("state") == "failed":
self._message_post_on_failure()
return super().write(vals)
def open_related_action(self):
"""Open the related action associated to the job"""
self.ensure_one()
job = Job.load(self.env, self.uuid)
action = job.related_action()
if action is None:
raise exceptions.UserError(_("No action available for this job"))
return action
def _change_job_state(self, state, result=None):
"""Change the state of the `Job` object
Changing the state of the Job will automatically change some fields
(date, result, ...).
"""
for record in self:
job_ = Job.load(record.env, record.uuid)
if state == DONE:
job_.set_done(result=result)
elif state == PENDING:
job_.set_pending(result=result)
else:
raise ValueError("State not supported: %s" % state)
job_.store()
def button_done(self):
result = _("Manually set to done by %s") % self.env.user.name
self._change_job_state(DONE, result=result)
return True
def requeue(self):
self._change_job_state(PENDING)
return True
def _message_post_on_failure(self):
# subscribe the users now to avoid to subscribe them
# at every job creation
domain = self._subscribe_users_domain()
users = self.env["res.users"].search(domain)
self.message_subscribe(partner_ids=users.mapped("partner_id").ids)
for record in self:
msg = record._message_failed_job()
if msg:
record.message_post(body=msg, subtype_xmlid="queue_job.mt_job_failed")
def _subscribe_users_domain(self):
"""Subscribe all users having the 'Queue Job Manager' group"""
group = self.env.ref("queue_job.group_queue_job_manager")
if not group:
return None
companies = self.mapped("company_id")
domain = [("groups_id", "=", group.id)]
if companies:
domain.append(("company_id", "in", companies.ids))
return domain
def _message_failed_job(self):
"""Return a message which will be posted on the job when it is failed.
It can be inherited to allow more precise messages based on the
exception informations.
If nothing is returned, no message will be posted.
"""
self.ensure_one()
return _(
"Something bad happened during the execution of the job. "
"More details in the 'Exception Information' section."
)
def _needaction_domain_get(self):
"""Returns the domain to filter records that require an action
:return: domain or False is no action
"""
return [("state", "=", "failed")]
def autovacuum(self):
"""Delete all jobs done based on the removal interval defined on the
channel
Called from a cron.
"""
for channel in self.env["queue.job.channel"].search([]):
deadline = datetime.now() - timedelta(days=int(channel.removal_interval))
while True:
jobs = self.search(
[
("date_done", "<=", deadline),
("channel", "=", channel.complete_name),
],
limit=1000,
)
if jobs:
jobs.unlink()
else:
break
return True
def requeue_stuck_jobs(self, enqueued_delta=5, started_delta=0):
"""Fix jobs that are in a bad states
:param in_queue_delta: lookup time in minutes for jobs
that are in enqueued state
:param started_delta: lookup time in minutes for jobs
that are in enqueued state,
0 means that it is not checked
"""
self._get_stuck_jobs_to_requeue(
enqueued_delta=enqueued_delta, started_delta=started_delta
).requeue()
return True
def _get_stuck_jobs_domain(self, queue_dl, started_dl):
domain = []
now = fields.datetime.now()
if queue_dl:
queue_dl = now - timedelta(minutes=queue_dl)
domain.append(
[
"&",
("date_enqueued", "<=", fields.Datetime.to_string(queue_dl)),
("state", "=", "enqueued"),
]
)
if started_dl:
started_dl = now - timedelta(minutes=started_dl)
domain.append(
[
"&",
("date_started", "<=", fields.Datetime.to_string(started_dl)),
("state", "=", "started"),
]
)
if not domain:
raise exceptions.ValidationError(
_("If both parameters are 0, ALL jobs will be requeued!")
)
return expression.OR(domain)
def _get_stuck_jobs_to_requeue(self, enqueued_delta, started_delta):
job_model = self.env["queue.job"]
stuck_jobs = job_model.search(
self._get_stuck_jobs_domain(enqueued_delta, started_delta)
)
return stuck_jobs
def related_action_open_record(self):
"""Open a form view with the record(s) of the job.
For instance, for a job on a ``product.product``, it will open a
``product.product`` form view with the product record(s) concerned by
the job. If the job concerns more than one record, it opens them in a
list.
This is the default related action.
"""
self.ensure_one()
records = self.records.exists()
if not records:
return None
action = {
"name": _("Related Record"),
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": records._name,
}
if len(records) == 1:
action["res_id"] = records.id
else:
action.update(
{
"name": _("Related Records"),
"view_mode": "tree,form",
"domain": [("id", "in", records.ids)],
}
)
return action
def _test_job(self):
_logger.info("Running test job.")

View File

@ -0,0 +1,94 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import _, api, exceptions, fields, models
class QueueJobChannel(models.Model):
_name = "queue.job.channel"
_description = "Job Channels"
name = fields.Char()
complete_name = fields.Char(
compute="_compute_complete_name", store=True, readonly=True
)
parent_id = fields.Many2one(
comodel_name="queue.job.channel", string="Parent Channel", ondelete="restrict"
)
job_function_ids = fields.One2many(
comodel_name="queue.job.function",
inverse_name="channel_id",
string="Job Functions",
)
removal_interval = fields.Integer(
default=lambda self: self.env["queue.job"]._removal_interval, required=True
)
_sql_constraints = [
("name_uniq", "unique(complete_name)", "Channel complete name must be unique")
]
@api.depends("name", "parent_id.complete_name")
def _compute_complete_name(self):
for record in self:
if not record.name:
complete_name = "" # new record
elif record.parent_id:
complete_name = ".".join([record.parent_id.complete_name, record.name])
else:
complete_name = record.name
record.complete_name = complete_name
@api.constrains("parent_id", "name")
def parent_required(self):
for record in self:
if record.name != "root" and not record.parent_id:
raise exceptions.ValidationError(_("Parent channel required."))
@api.model_create_multi
def create(self, vals_list):
records = self.browse()
if self.env.context.get("install_mode"):
# installing a module that creates a channel: rebinds the channel
# to an existing one (likely we already had the channel created by
# the @job decorator previously)
new_vals_list = []
for vals in vals_list:
name = vals.get("name")
parent_id = vals.get("parent_id")
if name and parent_id:
existing = self.search(
[("name", "=", name), ("parent_id", "=", parent_id)]
)
if existing:
if not existing.get_metadata()[0].get("noupdate"):
existing.write(vals)
records |= existing
continue
new_vals_list.append(vals)
vals_list = new_vals_list
records |= super().create(vals_list)
return records
def write(self, values):
for channel in self:
if (
not self.env.context.get("install_mode")
and channel.name == "root"
and ("name" in values or "parent_id" in values)
):
raise exceptions.UserError(_("Cannot change the root channel"))
return super().write(values)
def unlink(self):
for channel in self:
if channel.name == "root":
raise exceptions.UserError(_("Cannot remove the root channel"))
return super().unlink()
def name_get(self):
result = []
for record in self:
result.append((record.id, record.complete_name))
return result

View File

@ -0,0 +1,253 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import ast
import logging
import re
from collections import namedtuple
from odoo import _, api, exceptions, fields, models, tools
from ..fields import JobSerialized
_logger = logging.getLogger(__name__)
regex_job_function_name = re.compile(r"^<([0-9a-z_\.]+)>\.([0-9a-zA-Z_]+)$")
class QueueJobFunction(models.Model):
_name = "queue.job.function"
_description = "Job Functions"
_log_access = False
JobConfig = namedtuple(
"JobConfig",
"channel "
"retry_pattern "
"related_action_enable "
"related_action_func_name "
"related_action_kwargs ",
)
def _default_channel(self):
return self.env.ref("queue_job.channel_root")
name = fields.Char(
compute="_compute_name",
inverse="_inverse_name",
index=True,
store=True,
)
# model and method should be required, but the required flag doesn't
# let a chance to _inverse_name to be executed
model_id = fields.Many2one(
comodel_name="ir.model", string="Model", ondelete="cascade"
)
method = fields.Char()
channel_id = fields.Many2one(
comodel_name="queue.job.channel",
string="Channel",
required=True,
default=lambda r: r._default_channel(),
)
channel = fields.Char(related="channel_id.complete_name", store=True, readonly=True)
retry_pattern = JobSerialized(string="Retry Pattern (serialized)", base_type=dict)
edit_retry_pattern = fields.Text(
string="Retry Pattern",
compute="_compute_edit_retry_pattern",
inverse="_inverse_edit_retry_pattern",
help="Pattern expressing from the count of retries on retryable errors,"
" the number of of seconds to postpone the next execution. Setting the "
"number of seconds to a 2-element tuple or list will randomize the "
"retry interval between the 2 values.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"Example: {1: (1, 10), 5: (11, 20), 10: (21, 30), 15: (100, 300)}.\n"
"See the module description for details.",
)
related_action = JobSerialized(string="Related Action (serialized)", base_type=dict)
edit_related_action = fields.Text(
string="Related Action",
compute="_compute_edit_related_action",
inverse="_inverse_edit_related_action",
help="The action when the button *Related Action* is used on a job. "
"The default action is to open the view of the record related "
"to the job. Configured as a dictionary with optional keys: "
"enable, func_name, kwargs.\n"
"See the module description for details.",
)
@api.depends("model_id.model", "method")
def _compute_name(self):
for record in self:
if not (record.model_id and record.method):
record.name = ""
continue
record.name = self.job_function_name(record.model_id.model, record.method)
def _inverse_name(self):
groups = regex_job_function_name.match(self.name)
if not groups:
raise exceptions.UserError(_("Invalid job function: {}").format(self.name))
model_name = groups[1]
method = groups[2]
model = self.env["ir.model"].search([("model", "=", model_name)], limit=1)
if not model:
raise exceptions.UserError(_("Model {} not found").format(model_name))
self.model_id = model.id
self.method = method
@api.depends("retry_pattern")
def _compute_edit_retry_pattern(self):
for record in self:
retry_pattern = record._parse_retry_pattern()
record.edit_retry_pattern = str(retry_pattern)
def _inverse_edit_retry_pattern(self):
try:
edited = (self.edit_retry_pattern or "").strip()
if edited:
self.retry_pattern = ast.literal_eval(edited)
else:
self.retry_pattern = {}
except (ValueError, TypeError, SyntaxError):
raise exceptions.UserError(self._retry_pattern_format_error_message())
@api.depends("related_action")
def _compute_edit_related_action(self):
for record in self:
record.edit_related_action = str(record.related_action)
def _inverse_edit_related_action(self):
try:
edited = (self.edit_related_action or "").strip()
if edited:
self.related_action = ast.literal_eval(edited)
else:
self.related_action = {}
except (ValueError, TypeError, SyntaxError):
raise exceptions.UserError(self._related_action_format_error_message())
@staticmethod
def job_function_name(model_name, method_name):
return "<{}>.{}".format(model_name, method_name)
def job_default_config(self):
return self.JobConfig(
channel="root",
retry_pattern={},
related_action_enable=True,
related_action_func_name=None,
related_action_kwargs={},
)
def _parse_retry_pattern(self):
try:
# as json can't have integers as keys and the field is stored
# as json, convert back to int
retry_pattern = {
int(try_count): postpone_seconds
for try_count, postpone_seconds in self.retry_pattern.items()
}
except ValueError:
_logger.error(
"Invalid retry pattern for job function %s,"
" keys could not be parsed as integers, fallback"
" to the default retry pattern.",
self.name,
)
retry_pattern = {}
return retry_pattern
@tools.ormcache("name")
def job_config(self, name):
config = self.search([("name", "=", name)], limit=1)
if not config:
return self.job_default_config()
retry_pattern = config._parse_retry_pattern()
return self.JobConfig(
channel=config.channel,
retry_pattern=retry_pattern,
related_action_enable=config.related_action.get("enable", True),
related_action_func_name=config.related_action.get("func_name"),
related_action_kwargs=config.related_action.get("kwargs", {}),
)
def _retry_pattern_format_error_message(self):
return _(
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
).format(self.name)
@api.constrains("retry_pattern")
def _check_retry_pattern(self):
for record in self:
retry_pattern = record.retry_pattern
if not retry_pattern:
continue
all_values = list(retry_pattern) + list(retry_pattern.values())
for value in all_values:
try:
int(value)
except ValueError:
raise exceptions.UserError(
record._retry_pattern_format_error_message()
)
def _related_action_format_error_message(self):
return _(
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
'{{"enable": True, "func_name": "related_action_foo",'
' "kwargs" {{"limit": 10}}}}'
).format(self.name)
@api.constrains("related_action")
def _check_related_action(self):
valid_keys = ("enable", "func_name", "kwargs")
for record in self:
related_action = record.related_action
if not related_action:
continue
if any(key not in valid_keys for key in related_action):
raise exceptions.UserError(
record._related_action_format_error_message()
)
@api.model_create_multi
def create(self, vals_list):
records = self.browse()
if self.env.context.get("install_mode"):
# installing a module that creates a job function: rebinds the record
# to an existing one (likely we already had the job function created by
# the @job decorator previously)
new_vals_list = []
for vals in vals_list:
name = vals.get("name")
if name:
existing = self.search([("name", "=", name)], limit=1)
if existing:
if not existing.get_metadata()[0].get("noupdate"):
existing.write(vals)
records |= existing
continue
new_vals_list.append(vals)
vals_list = new_vals_list
records |= super().create(vals_list)
self.clear_caches()
return records
def write(self, values):
res = super().write(values)
self.clear_caches()
return res
def unlink(self):
res = super().unlink()
self.clear_caches()
return res

33
queue_job/post_init_hook.py Executable file
View File

@ -0,0 +1,33 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
logger = logging.getLogger(__name__)
def post_init_hook(cr, registry):
# this is the trigger that sends notifications when jobs change
logger.info("Create queue_job_notify trigger")
cr.execute(
"""
DROP TRIGGER IF EXISTS queue_job_notify ON queue_job;
CREATE OR REPLACE
FUNCTION queue_job_notify() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.state != 'done' THEN
PERFORM pg_notify('queue_job', OLD.uuid);
END IF;
ELSE
PERFORM pg_notify('queue_job', NEW.uuid);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER queue_job_notify
AFTER INSERT OR UPDATE OR DELETE
ON queue_job
FOR EACH ROW EXECUTE PROCEDURE queue_job_notify();
"""
)

Some files were not shown because too many files have changed in this diff Show More