[ADD] base modules
|
|
@ -0,0 +1,6 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import controllers
|
||||||
|
from . import models
|
||||||
|
from . import utils
|
||||||
|
from . import wizards
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import logs_viewer
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}" <${(object.admin_user.company_id.email ) | safe}></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>
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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...!')
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 116 KiB |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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 = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
'/': '/',
|
||||||
|
'`': '`',
|
||||||
|
'=': '='
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import k8s_deployment
|
||||||
|
|
@ -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"))
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import saas_app_delete
|
||||||
|
from . import update_docker_image
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import stripe_payment
|
||||||
|
|
@ -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'})
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import models
|
||||||
|
from . import subscription
|
||||||
|
from . import account_move
|
||||||
|
from . import res_partner
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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
|
||||||
|
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import main
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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 "今天"
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"},
|
||||||
|
)
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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();
|
||||||
|
"""
|
||||||
|
)
|
||||||