new modles

This commit is contained in:
Mostafa 2025-09-28 21:35:22 -07:00
parent 2b4023d168
commit 0ece89d9fe
158 changed files with 10991 additions and 0 deletions

View File

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

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
#################################################################################
# Author : Terabits Technolab (<www.terabits.xyz>)
# Copyright(c): 2021-22
# All Rights Reserved.
#
# This module is copyright property of the author mentioned above.
# You can`t redistribute it and/or modify it.
#
#################################################################################
{
'name': 'Advanced Web Domain Widget',
'version': '18.0.3.0.0',
'summary': 'Set all relational fields domain by selecting its records unsing `in, not in` operator.',
'sequence': 1,
'author': 'Terabits Technolab',
'license': 'OPL-1',
'website': 'https://www.terabits.xyz/apps/18.0/advanced_web_domain_widget',
'description':"""
""",
"price": "29.00",
"currency": "USD",
'depends':['base','web'],
'assets': {
'web.assets_backend': [
'advanced_web_domain_widget/static/src/scss/style.scss',
'advanced_web_domain_widget/static/src/components/domain_selector/domain_selector.js',
'advanced_web_domain_widget/static/src/xml/domain_templates.xml',
],
},
'images': ['static/description/banner.png'],
'application': True,
'installable': True,
'auto_install': False,
}

View File

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

View File

@ -0,0 +1,497 @@
from odoo.http import request
from datetime import datetime,timedelta
from dateutil.relativedelta import relativedelta
def prepare_domain_v2(domain):
if isinstance(domain, tuple) or isinstance(domain, list):
field_name = domain[0]
operator = domain[1]
val = domain[2]
date_format = '%Y-%m-%d %H:%M:%S'
current_date = datetime.now()
current_date = current_date.replace(hour=0, minute=0, second=0, microsecond=0)
if operator != "date_filter":
return [tuple(domain)]
if val == "today":
start_of_today = current_date
end_of_today = current_date + timedelta(days=1)
return ["&", (field_name, ">=", start_of_today), (field_name, "<", end_of_today)]
if val == "this_week":
start_of_week = current_date - timedelta(days=current_date.weekday())
end_of_week = (current_date + timedelta(days=(7 - current_date.weekday())))
return ["&", (field_name, ">=", start_of_week), (field_name, "<", end_of_week)]
if val == "this_month":
start_of_month = current_date.replace(day=1)
end_of_month = current_date + relativedelta(day=31)
return ["&", (field_name, ">=", start_of_month), (field_name, "<=", end_of_month)]
if val == "this_quarter":
start_of_quarter = datetime(current_date.year, ((current_date.month - 1) // 3) * 3 + 1, 1)
end_of_quarter = start_of_quarter + relativedelta(months=3)
return ["&", (field_name, '>=', start_of_quarter), (field_name, '<', end_of_quarter)]
if val == "this_year":
start_of_year = current_date.replace(month=1, day=1)
end_of_year = start_of_year + relativedelta(years=1)
return ["&", (field_name, ">=", start_of_year.strftime(date_format)), (field_name, "<", end_of_year.strftime(date_format))]
if val == "last_day":
start_of_yesterday = current_date - timedelta(days=1)
return ["&", (field_name, ">=", start_of_yesterday), (field_name, "<", current_date)]
if val == "last_week":
end_of_last_week = current_date - timedelta(days=current_date.weekday())
start_of_last_week = end_of_last_week - timedelta(days=6)
return ["&", (field_name, ">=", start_of_last_week), (field_name, "<", end_of_last_week)]
if val == "last_month":
start_of_last_month = (current_date - relativedelta(months=1)).replace(day=1)
end_of_last_month = start_of_last_month + relativedelta(months=1)
return ["&", (field_name, ">=", start_of_last_month), (field_name, "<", end_of_last_month)]
if val == "last_quarter":
start_of_this_quarter = datetime(current_date.year, ((current_date.month - 1) // 3) * 3 + 1, 1)
end_of_last_quarter = start_of_this_quarter
start_of_last_quarter = (end_of_last_quarter - relativedelta(months=3)).replace(day=1)
return ["&", (field_name, ">=", start_of_last_quarter), (field_name, "<", end_of_last_quarter)]
if val == "last_year":
end_of_last_year = datetime(current_date.year-1, 1, 1)
start_of_last_year = datetime(current_date.year, 1, 1)
return ["&", (field_name, ">=", start_of_last_year), (field_name, "<", end_of_last_year)]
if val == "last_7_days":
start_of_last_7_days = current_date - timedelta(days=6)
return [(field_name, ">=", start_of_last_7_days)]
if val == "last_30_days":
start_of_last_30_days = current_date - timedelta(days=29)
return [(field_name, ">=", start_of_last_30_days)]
if val == "last_90_days":
start_of_last_90_days = current_date - timedelta(days=89)
return [(field_name, ">=", start_of_last_90_days)]
if val == "last_365_days":
start_of_last_365_days = current_date - timedelta(days=364)
return [(field_name, ">=", start_of_last_365_days)]
if val == "next_day":
start_of_next_day = current_date + timedelta(days=1)
end_of_next_day = start_of_next_day + timedelta(days=1)
return ["&", (field_name, ">=", start_of_next_day), (field_name, "<", end_of_next_day)]
if val == "next_week":
start_of_next_week = current_date + timedelta(days=(7 - current_date.weekday()))
end_of_next_week = start_of_next_week + timedelta(days=7)
return ["&", (field_name, ">=", start_of_next_week), (field_name, "<", end_of_next_week)]
if val == "next_month":
start_of_next_month = (current_date + relativedelta(months=1)).replace(day=1)
end_of_next_month = (start_of_next_month + relativedelta(months=1)).replace(day=1)
return ["&", (field_name, ">=", start_of_next_month), (field_name, "<", end_of_next_month)]
if val == "next_quarter":
end_of_quarter = datetime(current_date.year, (((current_date.month - 1) // 3) * 3 + 3)+1, 1)
start_of_next_quarter = end_of_quarter
end_of_next_quarter = (start_of_next_quarter + relativedelta(months=3)).replace(day=1)
return ["&", (field_name, ">=", start_of_next_quarter), (field_name, "<", end_of_next_quarter)]
if val == "next_year":
start_of_next_year = datetime(current_date.year+1, 1, 1)
end_of_next_year = datetime(current_date.year+2, 1, 1)
return ["&", (field_name, ">=", start_of_next_year), (field_name, "<", end_of_next_year)]
return [tuple(domain)]
# def prepare_domain(domain):
# prepared_domain =[]
# if isinstance(domain, tuple) or isinstance(domain, list):
# left_value = domain[0]
# operator_value = domain[1]
# right_value = domain[2]
# if operator_value == 'date_filter':
# current_date=datetime.now()
# dom_list=list(domain)
# if right_value == 'today':
# dom_list[1]='='
# dom_list[2]=current_date
# elif right_value == 'this_week':
# dom_list[1]='>'
# current_day_of_week = current_date.weekday()
# if current_day_of_week == 6:
# first_date_of_week = current_date
# else:
# days_until_start_of_week = current_day_of_week + 1
# first_date_of_week = current_date - timedelta(days=days_until_start_of_week)
# dom_list[2]=first_date_of_week
# elif right_value == 'this_month':
# dom_list[1]='>='
# first_date_of_month=current_date.replace(day=1)
# dom_list[2]=first_date_of_month
# elif right_value == 'this_quarter':
# dom_list[1]='>='
# current_month = current_date.month
# current_quarter = (current_month - 1) // 3 + 1
# current_year = current_date.year
# quarter_start_month = (current_quarter - 1) * 3 + 1
# quarter_start_date = datetime(current_year, quarter_start_month, 1)
# quarter_end_month = quarter_start_month + 2
# quarter_end_date = datetime(current_year, quarter_end_month, 1)
# quarter_end_date = quarter_end_date.replace(day=quarter_end_date.day, hour=23, minute=59, second=59)
# dom_list[2]=quarter_start_date
# this_qua_end_dom_list.append(dom_list[0])
# this_qua_end_dom_list.append('<=')
# this_qua_end_dom_list.append(quarter_end_date)
# elif right_value == 'this_year':
# dom_list[1]='>='
# first_this_year_date=datetime(current_date.year, 1, 1)
# last_date_of_year = datetime(current_date.year, 12, 31)
# dom_list[2]=first_this_year_date
# this_year_end_dom_list.append(dom_list[0])
# this_year_end_dom_list.append('<=')
# this_year_end_dom_list.append(last_date_of_year)
# elif right_value == 'last_day':
# dom_list[1]='>'
# last_day_date=current_date+ timedelta(days=-1)
# dom_list[2]=last_day_date
# elif right_value == 'last_week':
# dom_list[1]='>='
# last_week_start = current_date - timedelta(days=current_date.weekday() + 8)
# last_week_end = last_week_start + timedelta(days=6)
# dom_list[2]=last_week_start
# last_week_end_date_dom_list.append(dom_list[0])
# last_week_end_date_dom_list.append('<=')
# last_week_end_date_dom_list.append(last_week_end)
# elif right_value == 'last_month':
# dom_list[1]='>='
# last_month_end_date=current_date.replace(day=1)- timedelta(days=1)
# if last_month_end_date.strftime('%d')=='31':
# last_month_start_date=last_month_end_date-timedelta(days=30)
# elif last_month_end_date.strftime('%d')=='30':
# last_month_start_date=last_month_end_date-timedelta(days=29)
# elif last_month_end_date.strftime('%d')=='29':
# last_month_start_date=last_month_end_date-timedelta(days=28)
# elif last_month_end_date.strftime('%d')=='28':
# last_month_start_date=last_month_end_date-timedelta(days=27)
# dom_list[2]=last_month_start_date
# last_month_end_date_dom_list.append(dom_list[0])
# last_month_end_date_dom_list.append('<=')
# last_month_end_date_dom_list.append(last_month_end_date)
# elif right_value == 'last_quarter':
# dom_list[1]='>='
# current_month = current_date.month
# current_quarter = (current_month - 1) // 3 + 1
# current_year = current_date.year
# if current_quarter == 1:
# last_quarter_start = datetime(current_year - 1, 10, 1)
# else:
# last_quarter_start = datetime(current_year, (current_quarter - 2) * 3 + 1, 1)
# if current_quarter == 1:
# last_quarter_end = datetime(current_year - 1, 12, 31)
# else:
# last_quarter_end = datetime(current_year, (current_quarter - 1) * 3, 1) - timedelta(days=1)
# dom_list[2]=last_quarter_start
# last_qua_end_dom_list.append(dom_list[0])
# last_qua_end_dom_list.append('<=')
# last_qua_end_dom_list.append(last_quarter_end)
# elif right_value == 'last_year':
# dom_list[1]='>='
# last_year = current_date.year - 1
# first_date_of_last_year = datetime(last_year, 1, 1)
# last_date_of_last_year = datetime(last_year, 12, 31)
# dom_list[2]=first_date_of_last_year
# last_year_end_dom_list.append(dom_list[0])
# last_year_end_dom_list.append('<=')
# last_year_end_dom_list.append(last_date_of_last_year)
# elif right_value == 'last_7_days':
# dom_list[1]='>='
# last_7_days_date=current_date+ timedelta(days=-7)
# dom_list[2]=last_7_days_date
# elif right_value == 'last_30_days':
# dom_list[1]='>='
# last_30_days_date=current_date+ timedelta(days=-30)
# dom_list[2]=last_30_days_date
# elif right_value == 'last_90_days':
# dom_list[1]='>='
# last_90_days_date=current_date+ timedelta(days=-90)
# dom_list[2]=last_90_days_date
# elif right_value == 'last_365_days':
# dom_list[1]='>='
# last_365_days_date=current_date+ timedelta(days=-365)
# dom_list[2]=last_365_days_date
# elif right_value == "next_day":
# next_day_date=current_date+ timedelta(days=+1)
# dom_list[1]='>'
# last_365_days_date=current_date+ timedelta(days=-365)
# dom_list[2]=last_365_days_date
# pass
# elif right_value == "next_week":
# pass
# elif right_value == "next_month":
# pass
# elif right_value == "next_year":
# pass
# else:
# prepared_domain.append(dom_tuple)
# return prepared_domain
# def prepare_domain(domain):
# prepared_domain =[]
# if isinstance(domain, tuple) or isinstance(domain, list):
# left_value = domain[0]
# operator_value = domain[1]
# right_value = domain[2]
# if operator_value == 'date_filter':
# current_date=datetime.now()
# dom_list=list(domain)
# today_end_time=[]
# this_qua_end_dom_list=[]
# last_qua_end_dom_list=[]
# this_year_end_dom_list=[]
# last_year_end_dom_list=[]
# last_week_end_date_dom_list=[]
# last_month_end_date_dom_list=[]
# if right_value == 'today':
# dom_list[1]='='
# dom_list[2]=current_date
# elif right_value == 'this_week':
# dom_list[1]='>'
# current_day_of_week = current_date.weekday()
# if current_day_of_week == 6:
# first_date_of_week = current_date
# else:
# days_until_start_of_week = current_day_of_week + 1
# first_date_of_week = current_date - timedelta(days=days_until_start_of_week)
# dom_list[2]=first_date_of_week
# elif right_value == 'this_month':
# dom_list[1]='>='
# first_date_of_month=current_date.replace(day=1)
# dom_list[2]=first_date_of_month
# elif right_value == 'this_quarter':
# dom_list[1]='>='
# current_month = current_date.month
# current_quarter = (current_month - 1) // 3 + 1
# current_year = current_date.year
# quarter_start_month = (current_quarter - 1) * 3 + 1
# quarter_start_date = datetime(current_year, quarter_start_month, 1)
# quarter_end_month = quarter_start_month + 2
# quarter_end_date = datetime(current_year, quarter_end_month, 1)
# quarter_end_date = quarter_end_date.replace(day=quarter_end_date.day, hour=23, minute=59, second=59)
# dom_list[2]=quarter_start_date
# this_qua_end_dom_list.append(dom_list[0])
# this_qua_end_dom_list.append('<=')
# this_qua_end_dom_list.append(quarter_end_date)
# elif right_value == 'this_year':
# dom_list[1]='>='
# first_this_year_date=datetime(current_date.year, 1, 1)
# last_date_of_year = datetime(current_date.year, 12, 31)
# dom_list[2]=first_this_year_date
# this_year_end_dom_list.append(dom_list[0])
# this_year_end_dom_list.append('<=')
# this_year_end_dom_list.append(last_date_of_year)
# elif right_value == 'last_day':
# dom_list[1]='>'
# last_day_date=current_date+ timedelta(days=-1)
# dom_list[2]=last_day_date
# elif right_value == 'last_week':
# dom_list[1]='>='
# last_week_start = current_date - timedelta(days=current_date.weekday() + 8)
# last_week_end = last_week_start + timedelta(days=6)
# dom_list[2]=last_week_start
# last_week_end_date_dom_list.append(dom_list[0])
# last_week_end_date_dom_list.append('<=')
# last_week_end_date_dom_list.append(last_week_end)
# elif right_value == 'last_month':
# dom_list[1]='>='
# last_month_end_date=current_date.replace(day=1)- timedelta(days=1)
# if last_month_end_date.strftime('%d')=='31':
# last_month_start_date=last_month_end_date-timedelta(days=30)
# elif last_month_end_date.strftime('%d')=='30':
# last_month_start_date=last_month_end_date-timedelta(days=29)
# elif last_month_end_date.strftime('%d')=='29':
# last_month_start_date=last_month_end_date-timedelta(days=28)
# elif last_month_end_date.strftime('%d')=='28':
# last_month_start_date=last_month_end_date-timedelta(days=27)
# dom_list[2]=last_month_start_date
# last_month_end_date_dom_list.append(dom_list[0])
# last_month_end_date_dom_list.append('<=')
# last_month_end_date_dom_list.append(last_month_end_date)
# elif right_value == 'last_quarter':
# dom_list[1]='>='
# current_month = current_date.month
# current_quarter = (current_month - 1) // 3 + 1
# current_year = current_date.year
# if current_quarter == 1:
# last_quarter_start = datetime(current_year - 1, 10, 1)
# else:
# last_quarter_start = datetime(current_year, (current_quarter - 2) * 3 + 1, 1)
# if current_quarter == 1:
# last_quarter_end = datetime(current_year - 1, 12, 31)
# else:
# last_quarter_end = datetime(current_year, (current_quarter - 1) * 3, 1) - timedelta(days=1)
# dom_list[2]=last_quarter_start
# last_qua_end_dom_list.append(dom_list[0])
# last_qua_end_dom_list.append('<=')
# last_qua_end_dom_list.append(last_quarter_end)
# elif right_value == 'last_year':
# dom_list[1]='>='
# last_year = current_date.year - 1
# first_date_of_last_year = datetime(last_year, 1, 1)
# last_date_of_last_year = datetime(last_year, 12, 31)
# dom_list[2]=first_date_of_last_year
# last_year_end_dom_list.append(dom_list[0])
# last_year_end_dom_list.append('<=')
# last_year_end_dom_list.append(last_date_of_last_year)
# elif right_value == 'last_7_days':
# dom_list[1]='>='
# last_7_days_date=current_date+ timedelta(days=-7)
# dom_list[2]=last_7_days_date
# elif right_value == 'last_30_days':
# dom_list[1]='>='
# last_30_days_date=current_date+ timedelta(days=-30)
# dom_list[2]=last_30_days_date
# elif right_value == 'last_90_days':
# dom_list[1]='>='
# last_90_days_date=current_date+ timedelta(days=-90)
# dom_list[2]=last_90_days_date
# elif right_value == 'last_365_days':
# dom_list[1]='>='
# last_365_days_date=current_date+ timedelta(days=-365)
# dom_list[2]=last_365_days_date
# elif right_value == "next_day":
# next_day_date=current_date+ timedelta(days=+1)
# dom_list[1]='>'
# last_365_days_date=current_date+ timedelta(days=-365)
# dom_list[2]=last_365_days_date
# pass
# elif right_value == "next_week":
# pass
# elif right_value == "next_month":
# pass
# elif right_value == "next_year":
# pass
# dom_tuple = dom_list
# prepared_domain.append(tuple(dom_list))
# if today_end_time:
# prepared_domain.append(tuple(today_end_time))
# if this_qua_end_dom_list:
# prepared_domain.append(tuple(this_qua_end_dom_list))
# if last_qua_end_dom_list:
# prepared_domain.append(tuple(last_qua_end_dom_list))
# if last_year_end_dom_list:
# prepared_domain.append(tuple(last_year_end_dom_list))
# if this_year_end_dom_list:
# prepared_domain.append(tuple(this_year_end_dom_list))
# if last_week_end_date_dom_list:
# prepared_domain.append(tuple(last_week_end_date_dom_list))
# if last_month_end_date_dom_list:
# prepared_domain.append(tuple(last_month_end_date_dom_list))
# else:
# prepared_domain.append(dom_tuple)
# return prepared_domain

View File

@ -0,0 +1,15 @@
from odoo import api, models
class BaseModel(models.AbstractModel):
_inherit = 'base'
@api.model
def get_widget_name(self, domain=None, offset=0, limit=None, order=None):
"""Get records for the advanced domain widget"""
return self.sudo().search_read(domain or [], ['id', 'display_name'], offset=offset, limit=limit, order=order)
@api.model
def get_widget_count(self, domain):
"""Get record count for the advanced domain widget"""
return self.sudo().search_count(domain or [])

View File

@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

View File

@ -0,0 +1,545 @@
<!DOCTYPE html>
<html lang="en-US" data-website-id="1" data-oe-company-name="Odoo S.A.">
<head>
<link type="text/css" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css" />
<script src="https://code.jquery.com/jquery-3.6.0.js"
integrity="sha256-H+K7U5CnXl1h5ywQfKtSj8PCmoN9aaq30gDh27Xc0jk=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Laila:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
crossorigin="anonymous"></script>
<link type="text/css" rel="stylesheet" href="/assets/assets.css" />
</head>
<body>
<div id="module-description">
<!-- Keep From Here -->
<img class="img img-responsive center-block" style="border-top-left-radius:10px; border-top-right-radius:10px" src="https://www.terabits.xyz/index/img/advanced_web_domain_widget/14.0/img.png">
<div class="container">
<div class="oe_styling_v8">
<!-- elearning post starts -->
<section class="blog_post_01">
<div class="position-relative w-100" style="display: inline-grid;">
<img src="img/screens/domain_bg_img_01.png"
class="img-responsive img img-fluid w-100 h-100 img img-fluid position-absolute left_0" />
<div class="position-relative row justify-content-center align-items-center"
style="padding: 20px 10px 40px 10px;">
<div class="col-12 my-3 px-5">
<div class="header-img">
<img src="img/screens/domain_header_img.png" class="img img-fluid w-100"
style="width: 100% !important;" />
</div>
<div class="blg-01-content text-center my-5">
<h1
style="font-family: 'Inter', sans-serif; font-size: 45px; font-weight: 700;line-height: 51px;color: #151765;">
Advanced web domian widget
</h1>
<h3
style="font-family: 'Inter', sans-serif; font-size: 22px; font-weight: 600;line-height: 31px; color: #1947AE;margin-top: 10px;">
"Now use the feature of select any models record in domain while using any relational field."
</h3>
<p
style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px; color: #000000; margin-top: 20px;">
Odoo base domain widget allows you to only match value or id while user wants to create <br/>
domain using any relational fields. So, user confused when model has multiple record's id <br/>
and he/she does't remembered. So, we have simplified that by showing models record to <br/>
the user. so, he/she can select by finding record and select it. our module will autometic <br/>
adds ids of selected records in domain. To select related model's record and create <br/>
domain, we allowed additional two domain operators ('in', 'not in').
</p>
</div>
</div>
</div>
</div>
</section>
<!-- elearning post end -->
<!-- features highlight -->
<section class="domain_features_02">
<div class="position-relative w-100" style="display: inline-grid;">
<img src="img/screens/domain_bg_img_02.png" class="img-responsive w-100 h-100 img img-fluid position-absolute left_0"/>
<div class="position-relative row justify-content-center align-items-center" style="padding: 20px 10px 40px 10px;">
<div class="col-12 my-3 px-5">
<div class="title text-center">
<h2 style="font-family: 'Inter', sans-serif; font-size: 45px; font-weight: 700;line-height: 61px;color: #151765;">
Features
</h2>
</div>
<div class="d-flex justify-content-around align-items-center mt-4">
<div style="background-color: #F1F7FF; padding: 20px 40px 20px 25px; border-radius: 8px; width: 45% !important; height: 100% !important;" class="shadow-sm">
<h6 style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 36px;color: #151765;">
Select any models records
</h6>
<div class="mt-3">
<p style="font-family: 'Inter', sans-serif; font-size: 17px; font-weight: 500;line-height: 24px;color: #151765;">
Easy to create domain of relational fields by <br/>
selecting any models record in domain. We <br/>
provide additional operators ('in' and 'not in') <br/>
to create relational fields domain.
</p>
</div>
</div>
<div style="background-color: #F1F7FF; padding: 32px 40px 32px 25px; border-radius: 8px; width: 45% !important; height: 100% !important;" class="shadow-sm">
<h6 style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 36px;color: #151765;">
Autometic id add in domain
</h6>
<div class="mt-3">
<p style="font-family: 'Inter', sans-serif; font-size: 17px; font-weight: 500;line-height: 24px;color: #151765;">
When user select models records from popup, <br/>
there will generate tags of record's names and <br/>
add records id in domain.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- features highlight -->
<!-- tabs -->
<section class="info-tabs mt-5">
<div>
<div class="tabs">
<div class="justify-content-center d-flex">
<!-- Nav pills -->
<ul class="nav nav-tabs justify-content-center" style="border-radius: 6px 6px; background-color: #ededed; padding:9px 14px 8px 14px;" role="tablist" data-tabs="tabs">
<li class="nav-item">
<a class="show "
id="v-guide-tab" data-bs-toggle="pill" aria-controls="v-guide" href="#pills-guide" aria-expanded="true" style="padding: 11px 12px 8px 12px !important;">
<span class="m-0" style="font-weight: 600; font-family: 'Inter', sans-serif; color: #1c29da;font-size: 19px; line-height: 23px;">USERGUIDE</span>
</a>
</li>
<li class="nav-item mx-1">
<a class="show active"
id="v-demo-tab" data-bs-toggle="pill" aria-controls="v-ss" href="#pills-screenshot" aria-expanded="true" style="padding: 11px 12px 8px 12px !important;">
<span class="m-0" style="font-weight: 600; font-family: 'Inter', sans-serif; color: #1c29da;font-size: 19px; line-height: 23px">DEMO</span>
</a>
</li>
<li class="nav-item mx-1">
<a class="show"
id="v-faqs-tab" data-bs-toggle="pill" aria-controls="v-faqs" href="#pills-faqs" aria-expanded="true" style="padding: 11px 12px 8px 12px !important;">
<span class="m-0" style="font-weight: 600; font-family: 'Inter', sans-serif; color: #1c29da;font-size: 19px; line-height: 23px">FAQS</span>
</a>
</li>
<li class="nav-item ">
<a class="show"
id="v-release-tab" data-bs-toggle="pill" aria-controls="v-release" href="#pills-release" aria-expanded="true" style="padding: 11px 12px 8px 12px !important;">
<span class="m-0" style="font-weight: 600; font-family: 'Inter', sans-serif; color: #1c29da;font-size: 19px; line-height: 23px">RELEASES</span>
</a>
</li>
</ul>
</div>
<div class="tab-content" style="border-radius: 10px;"
id="pills-tabContent">
<!-- user-guide -->
<div class="tab-pane fade py-3 shadow" id="pills-guide" role="tabpanel"
aria-labelledby="v-guide-tab" aria-labelledby="pills-guide-tab">
<div class="screenshot-description" style="margin: 1% 3%;">
<div style="border-radius: 10px;background-color: #f9f9f9;text-align:center;">
<h4 style='font-weight: 600;padding: 20px;'>You just need to change in xml
files to use our advanced domain feature.
</h4>
</div>
</div>
<div class="screenshot-description" style="margin: 1% 3%;">
<div style="border-radius: 10px;background-color: #f9f9f9;">
<li style='font-weight: 600;padding: 20px;'>
<span>Replace name of odoo's 'domain' widget to 'terabits_domain'
widget.
</span>
</li>
</div>
</div>
<div class="screenshot" style="text-align: center;">
<img class="img img-fluid oe_screenshot" style="width: 90%;margin: 2%;"
src="img/screens/ss4.png" alt="Odoo's domain widget" />
</div>
<div class="screenshot" style="text-align: center;">
<img class="img img-fluid oe_screenshot" style="width: 90%;margin: 2%;"
src="img/screens/ss5.png" alt="Terabits's domain widget" />
</div>
</div>
<!-- screenshots tab -->
<div class="tab-pane fade text-center active show" id="pills-screenshot" role="tabpanel"
aria-labelledby="v-ss-tab" aria-labelledby="pills-screenshot-tab">
<!-- 01 -->
<section class="blog_post_01">
<div class="position-relative w-100" style="display: inline-grid;">
<img src="img/screens/domain_bg_img_03.png"
class="img-responsive w-100 h-100 img img-fluid position-absolute left_0" />
<div class="position-relative row justify-content-center align-items-center"
style="padding: 20px 20px 40px 20px;">
<div class="col-12 my-3 px-5">
<div class="mt-2">
<div class="content-note d-flex">
<div class="icon-img">
<img src="img/screens/icon_img.png"
class="img img-fluid" />
</div>
<div class="content"
style="margin-left: 15px !important;">
<p
style="font-family: 'Inter', sans-serif; font-size: 20px; font-weight: 500;line-height: 26px;color: #151765;">
Here is odoo's 'domain' widget for domain creation.
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="img/screens/ss1.png"
class="img img-fluid img-responsive" />
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="img/screens/icon_img.png"
class="img img-fluid" />
</div>
<div class="content"
style="margin-left: 15px !important;">
<p
style="font-family: 'Inter', sans-serif; font-size: 20px; font-weight: 500;line-height: 26px;color: #151765;">
Here is customized 'terabits_domain' widget for domain creation.
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="img/screens/ss2.png"
class="img img-fluid img-responsive" />
</div>
<div class="content-img text-center mt-4 mx-5">
<img src="img/screens/ss3.png"
class="img img-fluid img-responsive" />
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="img/screens/icon_img.png"
class="img img-fluid" />
</div>
<div class="content"
style="margin-left: 15px !important;">
<p
style="font-family: 'Inter', sans-serif; font-size: 20px; font-weight: 500;line-height: 26px;color: #151765;">
Here is customized 'date and filter' widget for domain creation.
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="img/screens/date_filter_ss4.png"
class="img img-fluid img-responsive" />
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="img/screens/icon_img.png"
class="img img-fluid" />
</div>
<div class="content"
style="margin-left: 15px !important;">
<p
style="font-family: 'Inter', sans-serif; font-size: 20px; font-weight: 500;line-height: 26px;color: #151765;">
Here is how you can select environment company and user.
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="img/screens/date_filter_ss5.png"
class="img img-fluid img-responsive" />
</div>
<div class="content-img text-center mt-4 mx-5">
<img src="img/screens/date_filter_ss6.png"
class="img img-fluid img-responsive" />
</div>
<div class="content-img text-center mt-4 mx-5">
<img src="img/screens/date_filter_ss7.png"
class="img img-fluid img-responsive" />
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- faq tab -->
<div class="tab-pane fade shadow py-3" id="pills-faqs" role="tabpanel" aria-labelledby="v-faqs-tab"
aria-labelledby="pills-faqs-tab">
<div class="s_faq mt32 mb32"
style="background-color: transparent !important;padding: 40px 50px;">
<div class="panel-group" id="accordion" role="tablist"
aria-multiselectable="true">
<div class="panel-group"
style="border: 1px solid #ddd;border-radius: 10px;margin-bottom: 20px;">
<div class="panel panel-default"
style="box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07);padding: 15px 15px;border-radius: 10px;">
<div class="panel-heading mt0"
style="border:1px solid transparent !important">
<h4 class="panel-title" style="margin: 0;">
<a class="collapsed"
data-bs-toggle="collapse" href="#collapse1"
aria-expanded="false">
<span class="panelcontent" style="font-family: 'Inter', sans-serif;font-size: 20px;font-weight: 500;line-height: 25px;color: #000;">
Why should I use this app?
</span>
</a>
</h4>
</div>
<div id="collapse1"
style="background-color: rgb(240, 244, 247); padding: 15px;"
class="panel-collapse collapse">
<div>
<p class="mb0"
style="font-family: 'Inter', sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;
color: #000; padding-left:2%; margin-left:2%">
This module provides domains with an additional feature
to select any models record while using any relational
field and create a domain after selecting it. for that,
we provide two additional operators ('in' and 'not in')
that allow the user to select
a record of any model. after select any record, its id
automatic adds in domain.
</p>
</div>
</div>
</div>
</div>
<div class="panel-group"
style="border: 1px solid #ddd;border-radius: 10px;margin-bottom: 20px;">
<div class="panel panel-default"
style="box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07);padding: 15px 15px;border-radius: 10px;">
<div class="panel-heading mt0"
style="border:1px solid transparent !important">
<h4 class="panel-title" style="margin: 0;">
<a class="collapsed"
data-bs-toggle="collapse" href="#collapse2"
aria-expanded="false">
<span class="panelcontent" style="font-family: 'Inter', sans-serif;font-size: 20px;font-weight: 500;line-height: 25px;color: #000;">
What is user's main benifit ?
</span>
</a>
</h4>
</div>
<div id="collapse2"
style="background-color: rgb(240, 244, 247); padding: 15px;"
class="panel-collapse collapse">
<div>
<p class="mb0"
style="font-family: 'Inter', sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;
color: #000; padding-left:2%; margin-left:2%">
User's main benifit is that, he/she does not have to
remember models record id while he/she have to create
domain based on relational fields, because we direct
show all models record so user only have to select its
record.
</p>
</div>
</div>
</div>
</div>
<div class="panel-group"
style="border: 1px solid #ddd;border-radius: 10px;margin-bottom: 20px;">
<div class="panel panel-default"
style="box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07);padding: 15px 15px;border-radius: 10px;">
<div class="panel-heading mt0"
style="border:1px solid transparent !important">
<h4 class="panel-title" style="margin: 0;">
<a class="collapsed"
data-bs-toggle="collapse" href="#collapse3"
aria-expanded="false">
<span class="panelcontent" style="font-family: 'Inter', sans-serif;font-size: 20px;font-weight: 500;line-height: 25px;color: #000;">
Need some customization in this app, whom can I
contact?
</span>
</a>
</h4>
</div>
<div id="collapse3"
style="background-color: rgb(240, 244, 247); padding: 15px;"
class="panel-collapse collapse">
<div>
<p class="mb0"
style="font-family: 'Inter', sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;
color: #000; padding-left:2%; margin-left:2%">
Please drop an email at info@terabits.xyz or raise a
ticket through the Odoo store itself.
</p>
</div>
</div>
</div>
</div>
<div class="panel-group"
style="border: 1px solid #ddd;border-radius: 10px;margin-bottom: 20px;">
<div class="panel panel-default"
style="box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07);padding: 15px 15px;border-radius: 10px;">
<div class="panel-heading mt0"
style="border:1px solid transparent !important">
<h4 class="panel-title" style="margin: 0;">
<a class="collapsed"
data-bs-toggle="collapse" href="#collapse4"
aria-expanded="false">
<span class="panelcontent" style="font-family: 'Inter', sans-serif;font-size: 20px;font-weight: 500;line-height: 25px;color: #000;">
Do you provide any free support?
</span>
</a>
</h4>
</div>
<div id="collapse4"
style="background-color: rgb(240, 244, 247); padding: 15px;"
class="panel-collapse collapse">
<div>
<p class="mb0"
style="font-family: 'Inter', sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;
color: #000; padding-left:2%; margin-left:2%">
Yes, I do provide free support for 90 days for any
queries or any bug/issue fixing.
</p>
</div>
</div>
</div>
</div>
<div class="panel-group"
style="border: 1px solid #ddd;border-radius: 10px;margin-bottom: 20px;">
<div class="panel panel-default"
style="box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07);padding: 15px 15px;border-radius: 10px;">
<div class="panel-heading mt0"
style="border:1px solid transparent !important">
<h4 class="panel-title" style="margin: 0;">
<a class="collapsed"
data-bs-toggle="collapse" href="#collapse5"
aria-expanded="false">
<span class="panelcontent" style="font-family: 'Inter', sans-serif;font-size: 20px;font-weight: 500;line-height: 25px;color: #000;">
What is Support Policy of this Module?
</span>
</a>
</h4>
</div>
<div id="collapse5"
style="background-color: rgb(240, 244, 247); padding: 15px;"
class="panel-collapse collapse">
<div>
<p class="mb0"
style="font-family: 'Inter', sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;
color: #000; padding-left:2%; margin-left:2%">
In case of if any bug raised in the listed features of
this module, I am committed to providing support free of
cost. You will need to provide me server ssh access or
database access in order to solve the issue.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- release tab -->
<div class="tab-pane px-0 px-sm-5 shadow fade" id="pills-release" aria-labelledby="v-release-tab" style="padding-top: 0.25rem !important;" role="tabpanel" aria-labelledby="pills-release-tab">
<div class="row my-2 p-4 position-relative">
<div class="col-12 px-0 bg-white">
<h2 style="font-family: 'Inter', sans-serif;font-size: 35px;color: #000;font-weight: 600;">Changelog(s)</h2>
<hr class="mb-4 mt-0">
<!-- v14.0.1.0.2 -->
<h2 class="h4 pb-2">
<span style=" font-family: 'Inter', sans-serif; color:#00B5DB;font-size: 22px;font-weight: 600;" class="pe-2">v14.0.1.0.2</span>
<span>-</span>
<span class="ps-2" style="font-family: 'Inter', sans-serif;font-size: 23px;font-weight: 500;color: #000;">December 10, 2022</span>
</h2>
<p style="font-family: 'Inter', sans-serif;color: #000;font-size: 18px; font-weight: 500; line-height: 28px;">
Minor bug fix.
</p>
<!-- v14.0.0.0.0 -->
<h2 class="h4 pb-2">
<span style=" font-family: 'Inter', sans-serif; color:#00B5DB;font-size: 22px;font-weight: 600;" class="pe-2">v14.0.0.0.0</span>
<span>-</span>
<span class="ps-2" style="font-family: 'Inter', sans-serif;font-size: 23px;font-weight: 500;color: #000;">November 23, 2022</span>
</h2>
<p style="font-family: 'Inter', sans-serif;color: #000;font-size: 18px; font-weight: 500; line-height: 28px;">
Initial release for v14
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- tabs end -->
<!-- Help START -->
<div class="row pb16 pt-5 mb-5">
<div class="alert alert-warning text-center w-100"
style="padding:21px 51px; background-color:#ffffff; border:1px solid #dee2e6; color:#414d5c; margin:auto; display:block; border-radius:1px; min-width:90%; border-top:3px solid #151765">
<div>
<div style="background-color:rgb(255 164 0 / 12%); color:#151765"
class="badge border-0 rounded-circle p-3">
<i class="fa fa-question-circle fa-2x"></i>
</div>
</div>
<h2 style="color:#3c4858" class="mt-2 mb-4">
Need a help for this module?
</h2>
<h4 style="color:#3c4858; font-weight:400" class="mt-2 mb-4">
Contact me
<b
style="color:#151765; background-color:#e0f0ff; padding:3px 10px; border-radius:3px">info@terabits.xyz</b>
for your queries
</h4>
</div>
</div>
<!-- Help END -->
</div>
</div>

View File

@ -0,0 +1,165 @@
/** @odoo-module **/
import { Component, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
/**
* Advanced Domain Selector Dialog Component
*/
export class AdvancedDomainSelectorDialog extends Component {
static template = "advanced_web_domain_widget.DomainSelectorDialog";
static components = { Dialog };
static props = {
resModel: String,
domain: String,
onSave: Function,
close: Function,
};
setup() {
this.orm = useService("orm");
this.state = useState({
domain: this.props.domain || "[]",
selectedRecords: [],
searchValue: "",
availableRecords: [],
isLoading: false
});
onWillStart(async () => {
await this.loadAvailableRecords();
this.parseExistingDomain();
});
}
parseExistingDomain() {
try {
if (this.props.domain && this.props.domain !== "[]") {
// Parse domain to extract selected IDs
const domainStr = this.props.domain;
const match = domainStr.match(/\[.*'id'.*'in'.*\[([^\]]+)\]/);
if (match) {
const idsStr = match[1];
const ids = idsStr.split(',').map(id => parseInt(id.trim()));
this.state.selectedRecords = ids.filter(id => !isNaN(id));
}
}
} catch (error) {
console.error("Error parsing domain:", error);
}
}
async loadAvailableRecords() {
this.state.isLoading = true;
try {
const records = await this.orm.searchRead(
this.props.resModel,
[],
["display_name"],
{ limit: 100 }
);
this.state.availableRecords = records;
} catch (error) {
console.error("Error loading records:", error);
this.state.availableRecords = [];
} finally {
this.state.isLoading = false;
}
}
onSearchInput(ev) {
this.state.searchValue = ev.target.value;
}
get filteredRecords() {
const search = this.state.searchValue.toLowerCase();
if (!search) return this.state.availableRecords;
return this.state.availableRecords.filter(record =>
record.display_name.toLowerCase().includes(search)
);
}
toggleRecord(recordId) {
const index = this.state.selectedRecords.indexOf(recordId);
if (index === -1) {
this.state.selectedRecords.push(recordId);
} else {
this.state.selectedRecords.splice(index, 1);
}
this.updateDomain();
}
updateDomain() {
if (this.state.selectedRecords.length > 0) {
this.state.domain = `[('id', 'in', [${this.state.selectedRecords.join(',')}])]`;
} else {
this.state.domain = "[]";
}
}
onSave() {
this.props.onSave(this.state.domain);
this.props.close();
}
onCancel() {
this.props.close();
}
}
/**
* Advanced Domain Field Widget
*/
export class AdvancedDomainField extends Component {
static template = "advanced_web_domain_widget.DomainField";
static props = {
...standardFieldProps,
resModel: { type: String, optional: true },
};
setup() {
this.dialog = useService("dialog");
this.orm = useService("orm");
this.state = useState({
domainString: this.props.value || "[]"
});
}
get displayValue() {
try {
if (!this.state.domainString || this.state.domainString === "[]") {
return "No domain set";
}
return this.state.domainString;
} catch {
return this.state.domainString;
}
}
openAdvancedSelector() {
const resModel = this.props.resModel ||
(this.props.record && this.props.record.resModel) ||
'res.partner'; // fallback
this.dialog.add(AdvancedDomainSelectorDialog, {
resModel: resModel,
domain: this.state.domainString,
onSave: (domain) => {
this.state.domainString = domain;
this.props.update(domain);
}
});
}
onDirectEdit(ev) {
this.state.domainString = ev.target.value;
this.props.update(ev.target.value);
}
}
// Register the field widget
registry.category("fields").add("advanced_domain", AdvancedDomainField);

View File

@ -0,0 +1,30 @@
.o_field_advanced_domain {
.input-group {
.btn {
border-color: var(--o-input-border-color);
&:hover {
background-color: var(--o-gray-100);
}
}
}
}
.o_advanced_domain_selector {
.list-group-item {
cursor: pointer;
&.active {
background-color: var(--o-primary);
border-color: var(--o-primary);
color: white;
}
&:hover:not(.active) {
background-color: var(--o-gray-100);
}
}
pre {
max-height: 200px;
overflow-y: auto;
font-size: 0.875rem;
}
}

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="advanced_web_domain_widget.DomainField">
<div class="o_field_advanced_domain">
<div class="input-group">
<input type="text"
class="form-control"
t-att-value="displayValue"
t-att-readonly="props.readonly"
t-on-change="onDirectEdit"
placeholder="Enter domain..."/>
<button t-if="!props.readonly"
class="btn btn-outline-secondary"
type="button"
t-on-click="openAdvancedSelector">
<i class="fa fa-search"/> Advanced
</button>
</div>
</div>
</t>
<t t-name="advanced_web_domain_widget.DomainSelectorDialog">
<Dialog title="'Advanced Domain Selector'" size="'lg'">
<div class="o_advanced_domain_selector">
<t t-if="state.isLoading">
<div class="text-center p-4">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
<div class="mt-2">Loading records...</div>
</div>
</t>
<t t-else="">
<div class="row">
<div class="col-md-6">
<h5>Available Records</h5>
<input type="text"
class="form-control mb-2"
placeholder="Search..."
t-model="state.searchValue"
t-on-input="onSearchInput"/>
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
<t t-if="filteredRecords.length == 0">
<div class="text-muted text-center p-3">
<t t-if="state.searchValue">
No records found matching your search.
</t>
<t t-else="">
No records available.
</t>
</div>
</t>
<t t-foreach="filteredRecords" t-as="record" t-key="record.id">
<a href="#"
class="list-group-item list-group-item-action"
t-att-class="{ active: state.selectedRecords.includes(record.id) }"
t-on-click.prevent="() => toggleRecord(record.id)">
<i t-if="state.selectedRecords.includes(record.id)" class="fa fa-check-square-o me-2"/>
<i t-else="" class="fa fa-square-o me-2"/>
<t t-esc="record.display_name"/>
</a>
</t>
</div>
</div>
<div class="col-md-6">
<h5>Domain Preview</h5>
<pre class="bg-light p-3 rounded border">
<t t-esc="state.domain"/>
</pre>
<div class="mt-3">
<h6 class="text-primary">
Selected Records:
<span class="badge bg-primary">
<t t-esc="state.selectedRecords.length"/>
</span>
</h6>
<t t-if="state.selectedRecords.length > 0">
<div class="mt-2">
<small class="text-muted">
Selected IDs: <t t-esc="state.selectedRecords.join(', ')"/>
</small>
</div>
</t>
</div>
</div>
</div>
</t>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="onSave">Save Domain</button>
<button class="btn btn-secondary" t-on-click="onCancel">Cancel</button>
</t>
</Dialog>
</t>
</templates>

View File

@ -0,0 +1,16 @@
Most of the files are
Copyright (c) 2019 - present Droggol.
Some javascript files might be from from third
parties libraries. In that case the original
copyright of the contributions can be traced
through the history of the source version
control system.
When that is not the case, the files contain a prominent
notice stating the original copyright and applicable
license, or come with their own dedicated COPYRIGHT
and/or LICENSE file.

View File

@ -0,0 +1,27 @@
Odoo Proprietary License v1.0
This software and associated files (the "Software") may only be used (executed,
modified, executed after modifications) if you have purchased a valid license
from the authors, typically via Odoo Apps, or if you have received a written
agreement from the authors of the Software (see the COPYRIGHT file).
You may develop Odoo modules that use the Software as a library (typically
by depending on it, importing it and using its resources), but without copying
any source code or material from the Software. You may distribute those
modules under the license of your choice, provided that this license is
compatible with the terms of the Odoo Proprietary License (For example:
LGPL, MIT, or proprietary licenses similar to this one).
It is forbidden to publish, distribute, sublicense, or sell copies of the Software
or modified copies of the Software.
The above copyright notice and this permission notice must be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
# Part of Droggol. See LICENSE file for full copyright and licensing details.

View File

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Part of Droggol. See LICENSE file for full copyright and licensing details.
{
'name': 'Form Double Click Edit',
'summary': 'Double click on form view to Edit or Save',
'description': """
This module provides the feature that allows to edit/save formview record on double click.
""",
'version': '18.0.0.1',
'license': 'OPL-1',
'category': 'Odex25-base',
'author': 'Droggol',
'company': 'Droggol',
'maintainer': 'Droggol',
'website': 'https://www.droggol.com/',
'depends': ['web'],
'assets': {
'web.assets_backend': [
'droggol_dblclick_edit/static/src/js/form_controller.js',
],
},
'images': ['static/description/images/banner.png'],
'installable': True,
'auto_install': False,
'application': False,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 40' width='80' height='40'><path fill='#ffffff' fill-opacity='0.15' d='M0 40a19.96 19.96 0 0 1 5.9-14.11 20.17 20.17 0 0 1 19.44-5.2A20 20 0 0 1 20.2 40H0zM65.32.75A20.02 20.02 0 0 1 40.8 25.26 20.02 20.02 0 0 1 65.32.76zM.07 0h20.1l-.08.07A20.02 20.02 0 0 1 .75 5.25 20.08 20.08 0 0 1 .07 0zm1.94 40h2.53l4.26-4.24v-9.78A17.96 17.96 0 0 0 2 40zm5.38 0h9.8a17.98 17.98 0 0 0 6.67-16.42L7.4 40zm3.43-15.42v9.17l11.62-11.59c-3.97-.5-8.08.3-11.62 2.42zm32.86-.78A18 18 0 0 0 63.85 3.63L43.68 23.8zm7.2-19.17v9.15L62.43 2.22c-3.96-.5-8.05.3-11.57 2.4zm-3.49 2.72c-4.1 4.1-5.81 9.69-5.13 15.03l6.61-6.6V6.02c-.51.41-1 .85-1.48 1.33zM17.18 0H7.42L3.64 3.78A18 18 0 0 0 17.18 0zM2.08 0c-.01.8.04 1.58.14 2.37L4.59 0H2.07z'></path></svg>

After

Width:  |  Height:  |  Size: 785 B

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg6"
sodipodi:docname="lnr-cloud-upload.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview8"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
fill="#000000"
d="M16.006 16h-3.506c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h3.506c1.651 0 2.994-1.343 2.994-2.994s-1.343-2.994-2.994-2.994c-0.352 0-0.696 0.060-1.023 0.179-0.218 0.079-0.462-0.002-0.589-0.196s-0.104-0.45 0.056-0.618c0.355-0.373 0.55-0.862 0.55-1.377 0-1.103-0.897-2-2-2-0.642 0-1.229 0.297-1.61 0.814-0.229 0.31-0.362 0.677-0.386 1.061-0.013 0.212-0.159 0.393-0.364 0.451s-0.423-0.021-0.545-0.195l-0.005-0.007c-0.107-0.152-0.226-0.302-0.351-0.442-0.949-1.068-2.312-1.681-3.74-1.681-2.757 0-5 2.243-5 5s2.243 5 5 5h2.5c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5h-2.5c-3.308 0-6-2.692-6-6s2.692-6 6-6c1.603 0 3.137 0.643 4.261 1.775 0.087-0.195 0.196-0.381 0.324-0.555 0.564-0.764 1.467-1.22 2.415-1.22 1.654 0 3 1.346 3 3 0 0.351-0.061 0.694-0.176 1.017 0.061-0.003 0.122-0.004 0.183-0.004 2.202 0 3.994 1.792 3.994 3.994s-1.792 3.994-3.994 3.994z"
id="path2"
style="fill:#ab47bc;fill-opacity:1" />
<path
fill="#000000"
d="M12.854 12.146l-2-2c-0.195-0.195-0.512-0.195-0.707 0l-2 2c-0.195 0.195-0.195 0.512 0 0.707s0.512 0.195 0.707 0l1.146-1.146v3.793c0 0.276 0.224 0.5 0.5 0.5s0.5-0.224 0.5-0.5v-3.793l1.146 1.146c0.098 0.098 0.226 0.146 0.354 0.146s0.256-0.049 0.354-0.146c0.195-0.195 0.195-0.512 0-0.707z"
id="path4"
style="fill:#ab47bc;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg8"
sodipodi:docname="lnr-code.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata14">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs12" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview10"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<path
fill="#000000"
d="M5 15c-0.128 0-0.256-0.049-0.354-0.146l-4-4c-0.195-0.195-0.195-0.512 0-0.707l4-4c0.195-0.195 0.512-0.195 0.707 0s0.195 0.512 0 0.707l-3.646 3.646 3.646 3.646c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z"
id="path2"
style="fill:#0080ff;fill-opacity:1" />
<path
fill="#000000"
d="M15 15c-0.128 0-0.256-0.049-0.354-0.146-0.195-0.195-0.195-0.512 0-0.707l3.646-3.646-3.646-3.646c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l4 4c0.195 0.195 0.195 0.512 0 0.707l-4 4c-0.098 0.098-0.226 0.146-0.354 0.146z"
id="path4"
style="fill:#0080ff;fill-opacity:1" />
<path
fill="#000000"
d="M7.5 15c-0.091 0-0.182-0.025-0.265-0.076-0.234-0.146-0.305-0.455-0.159-0.689l5-8c0.146-0.234 0.455-0.305 0.689-0.159s0.305 0.455 0.159 0.689l-5 8c-0.095 0.152-0.258 0.235-0.424 0.235z"
id="path6"
style="fill:#0080ff;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg6"
sodipodi:docname="lnr-coffee-cup.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview8"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
fill="#000000"
d="M10 15c-1.654 0-3-1.346-3-3s1.346-3 3-3 3 1.346 3 3-1.346 3-3 3zM10 10c-1.103 0-2 0.897-2 2s0.897 2 2 2c1.103 0 2-0.897 2-2s-0.897-2-2-2z"
id="path2"
style="fill:#141a1f;fill-opacity:1" />
<path
fill="#000000"
d="M15.904 2.056l-0.177-0.707c-0.189-0.756-0.948-1.349-1.728-1.349h-8c-0.78 0-1.538 0.593-1.728 1.349l-0.177 0.707c-0.631 0.177-1.096 0.757-1.096 1.444v1c0 0.663 0.432 1.226 1.029 1.424l0.901 12.614c0.058 0.806 0.762 1.462 1.57 1.462h7c0.808 0 1.512-0.656 1.57-1.462l0.901-12.614c0.597-0.198 1.029-0.761 1.029-1.424v-1c0-0.687-0.464-1.267-1.096-1.444zM6 1h8c0.319 0 0.68 0.282 0.757 0.591l0.102 0.409h-9.719l0.102-0.409c0.077-0.309 0.438-0.591 0.757-0.591zM14.892 7h-9.783l-0.071-1h9.926l-0.071 1zM14.249 16h-8.497l-0.571-8h9.64l-0.571 8zM13.5 19h-7c-0.29 0-0.552-0.244-0.573-0.533l-0.105-1.467h8.355l-0.105 1.467c-0.021 0.289-0.283 0.533-0.573 0.533zM16 4.5c0 0.276-0.224 0.5-0.5 0.5h-11c-0.276 0-0.5-0.224-0.5-0.5v-1c0-0.275 0.224-0.499 0.499-0.5 0.001 0 0.001 0 0.002 0s0.002-0 0.003-0h10.997c0.276 0 0.5 0.224 0.5 0.5v1z"
id="path4"
style="fill:#141a1f;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg4"
sodipodi:docname="lnr-graduation-hat.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview6"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
fill="#000000"
d="M18.658 7.026l-9-3c-0.103-0.034-0.214-0.034-0.316 0l-9 3c-0.204 0.068-0.342 0.259-0.342 0.474s0.138 0.406 0.342 0.474l2.658 0.886v2.64c0 0.133 0.053 0.26 0.146 0.354 0.088 0.088 2.194 2.146 6.354 2.146 1.513 0 2.924-0.272 4.195-0.809 0.254-0.107 0.373-0.401 0.266-0.655s-0.401-0.373-0.655-0.266c-1.147 0.485-2.427 0.73-3.805 0.73-1.945 0-3.376-0.504-4.234-0.926-0.635-0.313-1.060-0.629-1.266-0.799v-2.081l5.342 1.781c0.051 0.017 0.105 0.026 0.158 0.026s0.107-0.009 0.158-0.026l5.342-1.781v0.892c-0.582 0.206-1 0.762-1 1.414 0 0.611 0.367 1.137 0.892 1.371l-0.877 3.508c-0.037 0.149-0.004 0.308 0.091 0.429s0.24 0.192 0.394 0.192h2c0.154 0 0.299-0.071 0.394-0.192s0.128-0.28 0.091-0.429l-0.877-3.508c0.525-0.234 0.892-0.76 0.892-1.371 0-0.652-0.418-1.208-1-1.414v-1.226l2.658-0.886c0.204-0.068 0.342-0.259 0.342-0.474s-0.138-0.406-0.342-0.474zM15.5 11c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5-0.5-0.224-0.5-0.5 0.224-0.5 0.5-0.5zM15.14 16l0.36-1.438 0.36 1.438h-0.719zM15.46 7.986l-5.877-0.98c-0.273-0.045-0.53 0.139-0.575 0.411s0.139 0.53 0.411 0.575l4.014 0.669-3.932 1.311-7.419-2.473 7.419-2.473 7.419 2.473-1.459 0.486z"
id="path2"
style="fill:#28a745;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg4"
sodipodi:docname="lnr-highlight.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview6"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
fill="#000000"
d="M19.854 9.646c-0.195-0.195-0.512-0.195-0.707 0l-3.586 3.586c-0.585 0.585-1.537 0.585-2.121 0l-4.672-4.672c-0.282-0.282-0.437-0.658-0.437-1.061s0.155-0.779 0.437-1.061l3.586-3.586c0.195-0.195 0.195-0.512 0-0.707s-0.512-0.195-0.707 0l-3.586 3.586c-0.471 0.471-0.73 1.098-0.73 1.768 0 0.285 0.048 0.563 0.138 0.824l-7.322 7.322c-0.094 0.094-0.146 0.221-0.146 0.354v1.5c0 0.276 0.224 0.5 0.5 0.5h9.5c0.133 0 0.26-0.053 0.354-0.146l3.322-3.322c0.261 0.091 0.539 0.138 0.824 0.138 0.669 0 1.297-0.259 1.768-0.73l3.586-3.586c0.195-0.195 0.195-0.512 0-0.707zM9.793 17h-8.793v-0.793l7.002-7.002c0.020 0.021 0.039 0.042 0.059 0.062l4.672 4.672c0.020 0.020 0.041 0.040 0.062 0.059l-3.002 3.002z"
id="path2"
style="fill:#ffad0a;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg8"
sodipodi:docname="lnr-layers.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata14">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs12" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview10"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<path
fill="#000000"
d="M10 12c-0.066 0-0.132-0.013-0.194-0.039l-9.5-4c-0.185-0.078-0.306-0.26-0.306-0.461s0.121-0.383 0.306-0.461l9.5-4c0.124-0.052 0.264-0.052 0.388 0l9.5 4c0.185 0.078 0.306 0.26 0.306 0.461s-0.121 0.383-0.306 0.461l-9.5 4c-0.062 0.026-0.128 0.039-0.194 0.039zM1.788 7.5l8.212 3.457 8.212-3.457-8.212-3.457-8.212 3.457z"
id="path2"
style="fill:#00a98f;fill-opacity:1" />
<path
fill="#000000"
d="M10 15c-0.066 0-0.132-0.013-0.194-0.039l-9.5-4c-0.254-0.107-0.374-0.4-0.267-0.655s0.4-0.374 0.655-0.267l9.306 3.918 9.306-3.918c0.254-0.107 0.548 0.012 0.655 0.267s-0.012 0.548-0.267 0.655l-9.5 4c-0.062 0.026-0.128 0.039-0.194 0.039z"
id="path4"
style="fill:#00a98f;fill-opacity:1" />
<path
fill="#000000"
d="M10 18c-0.066 0-0.132-0.013-0.194-0.039l-9.5-4c-0.254-0.107-0.374-0.4-0.267-0.655s0.4-0.374 0.655-0.267l9.306 3.918 9.306-3.918c0.254-0.107 0.548 0.012 0.655 0.267s-0.012 0.548-0.267 0.655l-9.5 4c-0.062 0.026-0.128 0.039-0.194 0.039z"
id="path6"
style="fill:#00a98f;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg8"
sodipodi:docname="lnr-heart-pulse.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata14">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs12" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview10"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<path
fill="#000000"
d="M9.5 19c-0.084 0-0.167-0.021-0.243-0.063-0.116-0.065-2.877-1.611-5.369-4.082-0.196-0.194-0.197-0.511-0.003-0.707s0.511-0.197 0.707-0.003c1.979 1.962 4.186 3.346 4.908 3.776 0.723-0.431 2.932-1.817 4.908-3.776 0.196-0.194 0.513-0.193 0.707 0.003s0.193 0.513-0.003 0.707c-2.493 2.471-5.253 4.017-5.369 4.082-0.076 0.042-0.159 0.063-0.243 0.063z"
id="path2"
style="fill:#ff5c75;fill-opacity:1" />
<path
fill="#000000"
d="M1.279 11c-0.188 0-0.368-0.106-0.453-0.287-0.548-1.165-0.826-2.33-0.826-3.463 0-2.895 2.355-5.25 5.25-5.25 0.98 0 2.021 0.367 2.931 1.034 0.532 0.39 0.985 0.86 1.319 1.359 0.334-0.499 0.787-0.969 1.319-1.359 0.91-0.667 1.951-1.034 2.931-1.034 2.895 0 5.25 2.355 5.25 5.25 0 1.133-0.278 2.298-0.826 3.463-0.118 0.25-0.415 0.357-0.665 0.24s-0.357-0.415-0.24-0.665c0.485-1.031 0.731-2.053 0.731-3.037 0-2.343-1.907-4.25-4.25-4.25-1.703 0-3.357 1.401-3.776 2.658-0.068 0.204-0.259 0.342-0.474 0.342s-0.406-0.138-0.474-0.342c-0.419-1.257-2.073-2.658-3.776-2.658-2.343 0-4.25 1.907-4.25 4.25 0 0.984 0.246 2.006 0.731 3.037 0.118 0.25 0.010 0.548-0.24 0.665-0.069 0.032-0.141 0.048-0.212 0.048z"
id="path4"
style="fill:#ff5c75;fill-opacity:1" />
<path
fill="#000000"
d="M10.515 15c-0.005 0-0.009-0-0.013-0-0.202-0.004-0.569-0.109-0.753-0.766l-1.217-4.334-0.807 3.279c-0.158 0.643-0.525 0.778-0.73 0.8s-0.592-0.027-0.889-0.62l-0.606-1.211c-0.029-0.058-0.056-0.094-0.076-0.117-0.003 0.004-0.007 0.009-0.011 0.015-0.37 0.543-1.192 0.953-1.913 0.953h-1c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h1c0.421 0 0.921-0.272 1.087-0.516 0.223-0.327 0.547-0.501 0.891-0.478 0.374 0.025 0.708 0.279 0.917 0.696l0.445 0.89 0.936-3.803c0.158-0.64 0.482-0.779 0.726-0.783s0.572 0.125 0.751 0.76l1.284 4.576 1.178-3.608c0.205-0.628 0.582-0.736 0.788-0.745s0.59 0.068 0.847 0.677l0.724 1.719c0.136 0.322 0.578 0.616 0.927 0.616h1.5c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5h-1.5c-0.747 0-1.559-0.539-1.849-1.228l-0.592-1.406-1.274 3.9c-0.207 0.634-0.566 0.733-0.771 0.733z"
id="path6"
style="fill:#ff5c75;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg12"
sodipodi:docname="lnr-store.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata18">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs16" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview14"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg12" />
<path
fill="#000000"
d="M18 9.5v-1c0-0.078-0.018-0.154-0.053-0.224l-2-4c-0.085-0.169-0.258-0.276-0.447-0.276h-12c-0.189 0-0.363 0.107-0.447 0.276l-2 4c-0.035 0.069-0.053 0.146-0.053 0.224v1c0 0.816 0.393 1.542 1 1.999v6.501h-0.5c-0.276 0-0.5 0.224-0.5 0.5s0.224 0.5 0.5 0.5h16c0.276 0 0.5-0.224 0.5-0.5s-0.224-0.5-0.5-0.5h-0.5v-6.501c0.607-0.457 1-1.182 1-1.999zM11.5 11c-0.827 0-1.5-0.673-1.5-1.5v-0.5h3v0.5c0 0.827-0.673 1.5-1.5 1.5zM2 9.5v-0.5h3v0.5c0 0.827-0.673 1.5-1.5 1.5s-1.5-0.673-1.5-1.5zM9 5v3h-2.86l0.75-3h2.11zM12.11 5l0.75 3h-2.86v-3h2.11zM6 9h3v0.5c0 0.827-0.673 1.5-1.5 1.5s-1.5-0.673-1.5-1.5v-0.5zM14 9h3v0.5c0 0.827-0.673 1.5-1.5 1.5s-1.5-0.673-1.5-1.5v-0.5zM16.691 8h-2.801l-0.75-3h2.051l1.5 3zM3.809 5h2.051l-0.75 3h-2.801l1.5-3zM3 11.95c0.162 0.033 0.329 0.050 0.5 0.050 0.817 0 1.544-0.394 2-1.002 0.456 0.608 1.183 1.002 2 1.002s1.544-0.394 2-1.002c0.361 0.48 0.89 0.827 1.5 0.951v6.050h-8v-6.050zM16 18h-4v-6.050c0.61-0.124 1.139-0.471 1.5-0.951 0.456 0.608 1.183 1.002 2 1.002 0.171 0 0.338-0.017 0.5-0.050v6.050z"
id="path2"
style="fill:#17a2b8;fill-opacity:1" />
<path
fill="#000000"
d="M14 14.5c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5z"
id="path4"
style="fill:#17a2b8;fill-opacity:1" />
<path
fill="#000000"
d="M4.5 15c-0.128 0-0.256-0.049-0.354-0.146-0.195-0.195-0.195-0.512 0-0.707l1-1c0.195-0.195 0.512-0.195 0.707 0s0.195 0.512 0 0.707l-1 1c-0.098 0.098-0.226 0.146-0.354 0.146z"
id="path6"
style="fill:#17a2b8;fill-opacity:1" />
<path
fill="#000000"
d="M5.5 17c-0.128 0-0.256-0.049-0.354-0.146-0.195-0.195-0.195-0.512 0-0.707l3-3c0.195-0.195 0.512-0.195 0.707 0s0.195 0.512 0 0.707l-3 3c-0.098 0.098-0.226 0.146-0.354 0.146z"
id="path8"
style="fill:#17a2b8;fill-opacity:1" />
<path
fill="#000000"
d="M8.5 17c-0.128 0-0.256-0.049-0.354-0.146-0.195-0.195-0.195-0.512 0-0.707l1-1c0.195-0.195 0.512-0.195 0.707 0s0.195 0.512 0 0.707l-1 1c-0.098 0.098-0.226 0.146-0.354 0.146z"
id="path10"
style="fill:#17a2b8;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg8"
sodipodi:docname="lnr-tablet.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata14">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs12" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview10"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<path
fill="#000000"
d="M16.5 20h-13c-0.827 0-1.5-0.673-1.5-1.5v-17c0-0.827 0.673-1.5 1.5-1.5h13c0.827 0 1.5 0.673 1.5 1.5v17c0 0.827-0.673 1.5-1.5 1.5zM3.5 1c-0.276 0-0.5 0.224-0.5 0.5v17c0 0.276 0.224 0.5 0.5 0.5h13c0.276 0 0.5-0.224 0.5-0.5v-17c0-0.276-0.224-0.5-0.5-0.5h-13z"
id="path2"
style="fill:#ab47bc;fill-opacity:1" />
<path
fill="#000000"
d="M10.5 18h-1c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h1c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"
id="path4"
style="fill:#ab47bc;fill-opacity:1" />
<path
fill="#000000"
d="M15.5 16h-11c-0.276 0-0.5-0.224-0.5-0.5v-13c0-0.276 0.224-0.5 0.5-0.5h11c0.276 0 0.5 0.224 0.5 0.5v13c0 0.276-0.224 0.5-0.5 0.5zM5 15h10v-12h-10v12z"
id="path6"
style="fill:#ab47bc;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg4"
sodipodi:docname="lnr-lock.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview6"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
fill="#000000"
d="M14.5 8h-0.5v-1.5c0-2.481-2.019-4.5-4.5-4.5s-4.5 2.019-4.5 4.5v1.5h-0.5c-0.827 0-1.5 0.673-1.5 1.5v8c0 0.827 0.673 1.5 1.5 1.5h10c0.827 0 1.5-0.673 1.5-1.5v-8c0-0.827-0.673-1.5-1.5-1.5zM6 6.5c0-1.93 1.57-3.5 3.5-3.5s3.5 1.57 3.5 3.5v1.5h-7v-1.5zM15 17.5c0 0.276-0.224 0.5-0.5 0.5h-10c-0.276 0-0.5-0.224-0.5-0.5v-8c0-0.276 0.224-0.5 0.5-0.5h10c0.276 0 0.5 0.224 0.5 0.5v8z"
id="path2"
style="fill:#0080ff;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="20"
height="20"
viewBox="0 0 20 20"
id="svg6"
sodipodi:docname="lnr-select.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1004"
id="namedview8"
showgrid="false"
inkscape:zoom="11.8"
inkscape:cx="10"
inkscape:cy="10"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
fill="#000000"
d="M5.5 15h-4c-0.827 0-1.5-0.673-1.5-1.5v-12c0-0.827 0.673-1.5 1.5-1.5h14c0.827 0 1.5 0.673 1.5 1.5v7c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5v-7c0-0.276-0.224-0.5-0.5-0.5h-14c-0.276 0-0.5 0.224-0.5 0.5v12c0 0.276 0.224 0.5 0.5 0.5h4c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"
id="path2"
style="fill:#0080df;fill-opacity:0.94117647" />
<path
fill="#000000"
d="M13 20c-0.198 0-0.386-0.119-0.464-0.314l-1.697-4.242-2.963 3.386c-0.137 0.157-0.357 0.212-0.552 0.139s-0.324-0.26-0.324-0.468v-15c0-0.198 0.117-0.377 0.298-0.457s0.392-0.046 0.539 0.087l11 10c0.153 0.139 0.205 0.358 0.13 0.55s-0.26 0.32-0.466 0.32h-4.261l1.726 4.314c0.103 0.256-0.022 0.547-0.279 0.65l-2.5 1c-0.061 0.024-0.124 0.036-0.186 0.036zM11 14c0.028 0 0.056 0.002 0.084 0.007 0.172 0.029 0.315 0.146 0.38 0.307l1.814 4.536 1.572-0.629-1.814-4.536c-0.062-0.154-0.043-0.329 0.050-0.466s0.248-0.22 0.414-0.22h3.707l-9.207-8.37v12.539l2.624-2.999c0.096-0.109 0.233-0.171 0.376-0.171z"
id="path4"
style="fill:#0080df;fill-opacity:0.94117647" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,313 @@
<div class="container">
<!-- Navbar START-->
<nav class="navbar navbar-expand-lg navbar-light bg-light" style="background: #fff !important;margin: 30px -10px;border-radius: 4px;overflow: hidden;box-shadow: 0 1px 2px 0px rgba(0, 0, 0, 0.16), 0 0px 2px rgba(27, 26, 26, 0.34);padding: 0px;">
<a class="navbar-brand" href="https://www.droggol.com" target="_blank" style="background: #374e65;position: absolute;padding: 13px 20px 11px 20px;left: -1px">
<img style="max-width: 90px;margin-bottom: 6px;" src="images/logo.svg"/>
</a>
<div class="ml-auto text-right" id="navbarNav">
<ul class="nav navbar-nav ml-auto text-right">
<li class="nav-item" style="padding: 10px;border-left: 1px solid #d5d3d3;">
<span class="badge badge-pill badge-primary" style="background: #7c7bad33;border: 1px solid #7c7bbb4f;font-size: 14px;color: #7c7bad;margin: 3px;padding-left: 23px;position: relative;">
<i class="fa fa-check-circle" style="font-size: 14px;position: absolute;left: 5px;top: 2px;font-size: 16px;"></i>
Community
</span>
<span class="badge badge-pill badge-primary" style="background: #eadae5;border: 1px solid #905a7b4a;font-size: 14px;color: #875a7b;margin: 3px;padding-left: 23px;position: relative;">
<i class="fa fa-check-circle" style="font-size: 14px;position: absolute;left: 5px;top: 2px;font-size: 16px;"></i>
Enterprise
</span>
</li>
<li class="nav-item d-none d-md-block">
<a href="https://www.droggol.com/contactus" style="padding: 10px;border-left: 1px solid #d5d3d3;color: #0080Ff;padding: 14px 25px;font-weight: bold;font-size: 14px;display: inline-block;" target="_blank">
Need Help?
</a>
</li>
</ul>
</div>
</nav>
<!-- Navbar END-->
<!-- Hero START-->
<div class="row pb64 pt32 align-items-center">
<div class="col-12 col-md-7 col-lg-6">
<h2 style="color: #3C4858;">
<span style="color: #0080FF;"> Double click </span>
<span style="font-weight: 300;"> to edit/save form view. </span>
</h2>
<p style="color: #8492A6;" class="lead mb-4">
This module provides the feature to switch between edit/save mode form view just with double click.
</p>
<div class="media">
<div class="d-flex mr-4">
<div style="background-color: rgba(0, 128, 255, 0.1);" class="badge border-0 rounded-circle p-3">
<img src="images/select.svg" style="height:2em; height: 2em;" />
</div>
</div>
<div class="media-body">
<h5 style="color: #3C4858;">Simple to use</h5>
<p style="color: #8492A6;" class="mb-4">Just double click on empty area of from view to edit/save the
form view. </p>
</div>
</div>
<div class="media">
<div class="d-flex mr-4">
<div style="background-color: rgba(0, 128, 255, 0.1);" class="badge border-0 rounded-circle p-3">
<img src="images/lock.svg" style="height:2em; width: 2em;" />
</div>
</div>
<div class="media-body">
<h5 style="color: #3C4858;">Based On Access Rules</h5>
<p style="color: #8492A6;" class="mb-5 mb-md-0">
This module does not allow to switch into editable mode if user don't have access rights.
</p>
</div>
</div>
</div>
<div class="col-12 col-md-5 col-lg-6">
<div style="perspective-origin: left center; perspective: 1500px; transform-style: preserve-3d;">
<img src="images/screen_shot.png" class="img-fluid"
style="border: 1px solid rgba(143, 155, 174, 0.31);transform: rotateY(-35deg) rotateX(15deg) !important; border-radius: .625rem; box-shadow: 25px 60px 125px -25px rgba(80,102,144,.1), 16px 40px 75px -40px rgba(0,0,0,.2);">
</div>
</div>
</div>
<!-- Hero END-->
<!-- Info START-->
<div class="row text-center pb64 pt64 bg-100">
<div class="col-12">
<h2 style="color: #3C4858;" class="mt-2 mb-3">How to use it?</h2>
<p style="color: #6b7789;font-weight: 300;" class="lead">
After installing this module, whenever you open any
form view. You can switch between editable and read-only mode just by double-clicking on the empty area. For
better usability, we have ignored double clicks on fields so you can select the text by double or triple
clicks without any issue. The user can not use this feature if he/she doesn't have access rights to edit
that record.
</p>
</div>
<div class="col-12">
<a href="https://www.youtube.com/watch?v=UZ5CwVPe6d8" style="background-color: #0080FF;border-color: #0080FF;border-radius: 0.375rem;line-height: 1.65;font-size: 1rem;font-weight: 500;color: #fff;" class="btn btn-lg" target="_blank">
<i class="fa fa-youtube-play fa-2x mr-2" style="vertical-align: middle;"></i>
<span>See It In Action </span>
</a>
</div>
</div>
<!-- Info END-->
<!-- Help START-->
<div class="row pt64 pb64 text-center">
<div class="col-12">
<h2 style="color: #3C4858;" class="mt-2 mb-3">Need a help for this module?</h2>
<a href="https://www.droggol.com/contactus" target="_blank" style="background-color: #0080FF;border-color: #0080FF;border-radius: 0.375rem;line-height: 1.65;font-size: 1rem;color: #fff;" class="btn btn-lg mr-4">
Get Support
</a>
<a href="https://www.droggol.com/contactus" target="_blank" style="background-color: #0080FF;border-color: #0080FF;border-radius: 0.375rem;line-height: 1.65;font-size: 1rem;color: #fff;" class="btn btn-lg mr-4">
Request Customization
</a>
<a href="https://www.droggol.com/contactus" target="_blank" style="background-color: #0080FF;border-color: #0080FF;border-radius: 0.375rem;line-height: 1.65;font-size: 1rem;color: #fff;" class="btn btn-lg">
Contact Us
</a>
</div>
</div>
<!-- Help END-->
<!-- Cards START-->
<div class="row pt16 pb16 bg-100">
<div class="col-12 col-md-12 text-center mt-4">
<span style="background-color: rgba(0, 128, 255, 0.1); color: #0080FF; padding: 0.6em 0.8em; font-size: 75%; font-weight: 700;" class="badge badge-pill border-0">
Our Services
</span>
<h2 style="color: #3C4858;" class="mt-2">Checkout Our Odoo Services</h2>
<p style="color: #737d8b;" class="lead">Great variety of Odoo services that help you to uplift your awesome business.</p>
</div>
<!-- Cards Grid-->
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(0, 128, 255, 0.1);" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/code.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;"> Development </h4>
<p style="color: #8492A6;" class="mb-4">
Customize Odoo modules, integrate with external services, migration from the old version and more.
</p>
<a href="https://www.droggol.com/odoo-development" target="_blank" style="background-color: rgba(0, 128, 255, 0.1); border-color: transparent; color: #0080FF; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(0, 169, 143, 0.1);" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/layers.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Implementation
</h4>
<p style="color: #8492A6;" class="mb-4">
We understand your business needs and setup Odoo in the right way to fulfil your business needs.
</p>
<a href="https://www.droggol.com/odoo-implementation"target="_blank"style="background-color: rgba(0, 169, 143, 0.1); border-color: transparent; color: #00A98F; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;"class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(171, 71, 188, 0.1); color: #AB47BC;" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/tablet.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Mobile Apps
</h4>
<p style="color: #8492A6;" class="mb-4">
Android, iOS or Hybrid mobile applications which are fully integrated with Odoo instances.
</p>
<a href="https://www.droggol.com/odoo-mobile-apps" target="_blank" style="background-color: rgba(171, 71, 188, 0.1); border-color: transparent; color: #AB47BC; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(255, 173, 10, 0.1);" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/highlight.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Website & Themes
</h4>
<p style="color: #8492A6;" class="mb-4">
Build eye-catching, blazing fast, configurable Odoo website with beautiful custom themes.
</p>
<a href="https://www.droggol.com/odoo-website-and-themes" target="_blank" style="background-color: rgba(255, 173, 10, 0.1); border-color: transparent; color: #FFAD0A; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(255, 92, 117, 0.1);" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/pulse.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Support
</h4>
<p style="color: #8492A6;" class="mb-4">
Odoo functional or technical support, bug-fixes, debugging, performance optimization, and more.
</p>
<a href="https://www.droggol.com/odoo-support" target="_blank" style="background-color: rgba(255, 92, 117, 0.1); border-color: transparent; color: #FF5C75; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(23, 162, 184, 0.1);" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/store.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
POS & IoT
</h4>
<p style="color: #8492A6;" class="mb-4">
Customize Odoo POS for your retail shops and restaurants. Integrate it with hardware devices to
boost your productivity.
</p>
<a href="https://www.droggol.com/odoo-pos-and-iot" target="_blank" style="background-color: rgba(23, 162, 184, 0.1); border-color: transparent; color: #17a2b8; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(171, 71, 188, 0.1); color: #AB47BC;" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/cloud.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Deployment
</h4>
<p style="color: #8492A6;" class="mb-4">
Deploy or optimize your Odoo instances on the cloud, Odoo.sh or on any other platform with top-notch
security.
</p>
<a href="https://www.droggol.com/odoo-deployment" target="_blank" style="background-color: rgba(171, 71, 188, 0.1); border-color: transparent; color: #AB47BC; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(40, 167, 69, 0.1);" class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/hat.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Training
</h4>
<p style="color: #8492A6;" class="mb-4">
Learn to develop or customize Odoo backend, website, JavaScript framework, sysadmin, themes and
more.
</p>
<a href="https://www.droggol.com/odoo-trainings" target="_blank" style="background-color: rgba(40, 167, 69, 0.1); border-color: transparent; color: #28a745; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 my-3">
<div style="background: #fff;" class="card deep-1 deep_hover py-4 h-100">
<div class="card-body text-center">
<div style="background-color: rgba(20, 26, 31, 0.1); color: #141a1f;"
class="badge border-0 rounded-circle mb-4 p-3">
<img src="images/cards/coffee.svg" style="width:3em; height: 3em;" />
</div>
<h4 style="color: #3C4858;">
Hire Developers
</h4>
<p style="color: #8492A6;" class="mb-4">
Hire full-stack Odoo developer to develop, maintain or configure Odoo applications on a regular
basis.
</p>
<a href="https://www.droggol.com/hire-odoo-developers" target="_blank" style="background-color: rgba(20, 26, 31, 0.1); border-color: transparent; color: #141a1f; border-radius: 0.375rem; line-height: 1.65; font-size: 0.75rem; font-weight: 400;" class="btn btn-sm badge border-0 rounded-circle">
Read more
</a>
</div>
</div>
</div>
<div class="col-12 text-center mt16 mb16">
<a href="https://www.droggol.com/services" target="_blank" style="background-color: #0080FF;border-color: #0080FF;border-radius: 0.375rem;line-height: 1.65;font-size: 1rem;font-weight: 500;color: #fff;" class="btn btn-lg" target="_blank">
<span> View All Sevices </span>
<i class="fa fa-arrow-right mr-2" style="vertical-align: middle;"></i>
</a>
</div>
</div>
<!-- Cards END-->
<!-- CTA END-->
<div class="row align-items-center pt32 pb32" style="background-image: url(images/bg.svg);background-color: #0080FF;border-radius: 8px;margin: 20px 0px;padding: 20px;">
<div class="col-12 col-md" style="">
<h3 style="color: #fff;">Let's have a talk.</h3>
<p style="color: #fff;" class="mb-md-0">Don't be a stranger. Our team is happy to answer all your questions.</p>
</div>
<div class="col-12 col-md-auto">
<a href="https://www.droggol.com/contactus" target="_blank" style="padding: 10px 25px;border-radius: 6px;color: #0080ff;font-weight: 500;background:#fff;" class="btn btn-default">
CONTACT US
<i class="fa fa-arrow-circle-right fa-lg" style="margin-left:4px;"></i>
</a>
</div>
</div>
<!-- CTA END-->
</div>

View File

@ -0,0 +1,69 @@
/** @odoo-module **/
import { FormController } from "@web/views/form/form_controller";
import { patch } from "@web/core/utils/patch";
import { onMounted, onWillUnmount } from "@odoo/owl";
patch(FormController.prototype, {
setup() {
super.setup(...arguments);
this._dblClickHandler = this.onDblClick.bind(this);
onMounted(() => {
// Add double click event listener to the form
if (this.rootRef && this.rootRef.el) {
this.rootRef.el.addEventListener('dblclick', this._dblClickHandler);
}
});
onWillUnmount(() => {
// Clean up event listener
if (this.rootRef && this.rootRef.el) {
this.rootRef.el.removeEventListener('dblclick', this._dblClickHandler);
}
});
},
onDblClick(ev) {
const target = ev.target;
// Avoid triggering on chatter, modal, or control panel elements
if (target.closest('.o-mail-Chatter') ||
target.closest('.o_chatter') ||
target.closest('.modal') ||
target.closest('.o_control_panel') ||
target.closest('.o-mail-ChatWindow')) {
return;
}
// Check if click is on a field or input element
const isInteractiveElement =
target.closest('.o_field_widget') ||
target.classList.contains('o_field_widget') ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.tagName === 'BUTTON' ||
target.closest('button') ||
target.closest('.dropdown') ||
target.closest('.o_dropdown') ||
target.closest('.btn');
const record = this.model.root;
const mode = record.mode;
if (mode === 'readonly' && !isInteractiveElement) {
// Switch to edit mode on double click in readonly mode
if (!record.isReadonly && this.archInfo.activeActions.edit) {
this.discard();
this.edit();
}
} else if (mode === 'edit' && !isInteractiveElement) {
// Save the record on double click in edit mode
if (record.isDirty) {
this.save();
}
}
}
});

View File

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

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': "Dynamic Reject Workflow",
'version': '18.0.1.0.0',
'description': """
Dynamic Reject Workflow
""",
'category': 'Odex25-base',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'depends': ['mail'],
'data': [
'security/groups.xml',
'security/ir.model.access.csv',
'views/reject_buttons.xml',
'views/reject_workflow.xml',
'wizard/reject_wizard.xml',
],
'assets': {
'web.assets_backend': [
'dynamic_reject_workflow/static/src/js/form.js',
],
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,332 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * dynamic_reject_workflow
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-09-13 07:10+0000\n"
"PO-Revision-Date: 2022-09-13 07:10+0000\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: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__button_name
msgid "Button Name"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__name
msgid "Button String"
msgstr ""
#. module: dynamic_reject_workflow
#: code:addons/dynamic_reject_workflow/models/reject_workflow.py:0
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.view_form_reject_button_wizard
#, python-format
msgid "Buttons"
msgstr ""
#. module: dynamic_reject_workflow
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.reject_wizard_view
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.view_form_reject_button_wizard
msgid "Cancel"
msgstr "إلغـــاء"
#. module: dynamic_reject_workflow
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.reject_workflow_form
msgid "Choose Buttons"
msgstr ""
#. module: dynamic_reject_workflow
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.reject_wizard_view
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.view_form_reject_button_wizard
msgid "Send"
msgstr "إرســال"
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__create_uid
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__create_uid
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__create_uid
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__create_uid
msgid "Created by"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__create_date
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__create_date
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__create_date
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__create_date
msgid "Created on"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread__display_name
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__display_name
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__display_name
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__display_name
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__display_name
msgid "Display Name"
msgstr "الاسم المعروض"
#. module: dynamic_reject_workflow
#: model:ir.model,name:dynamic_reject_workflow.model_mail_thread
msgid "Email Thread"
msgstr "المحادثة البريدية"
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread__id
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__id
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__id
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__id
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__id
msgid "ID"
msgstr "المُعرف"
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__invisible
msgid "Invisible"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread____last_update
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button____last_update
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard____last_update
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard____last_update
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow____last_update
msgid "Last Modified on"
msgstr "آخر تعديل في"
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__write_uid
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__write_uid
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__write_uid
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__write_uid
msgid "Last Updated by"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__write_date
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__write_date
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__write_date
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__write_date
msgid "Last Updated on"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__model_id
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__model_id
msgid "Model Ref."
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__name
msgid "Name"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_internal_transaction__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_outgoing_transaction__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_salary_advance__reason
msgid "Reason"
msgstr "السبب"
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_analytic_account__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_asset__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_asset_modify__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_asset_multi_operation__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_asset_operation__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_bank_statement__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_bank_statement_import_journal_creation__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_bank_statement_line__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_fiscal_year__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_journal__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_loan__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_move__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_online_link__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_online_provider__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_payment__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_payment_method__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_payment_term__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_account_tax__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_appraisal_degree__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_appraisal_plan__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_asset_pause__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_asset_sell__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_budget_confirmation__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_calendar_event__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_company_document__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_consolidation_period__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_contract_advantage__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_crm_lead__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_crm_team__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_crossovered_budget__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_customize_appraisal__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_checklist__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_course_name__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_department_jobs__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_effective_form__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_iqama_renewal__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_leave__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_other_request__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_overtime_request__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_employee_promotions__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_event_event__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_event_registration__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_fiscalyears_periods__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_fleet_vehicle__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_fleet_vehicle_log_contract__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_fleet_vehicle_log_services__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_form_renew__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_forum_forum__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_forum_post__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_forum_tag__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_gamification_badge__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_gamification_challenge__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_administrative_circular__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_applicant__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_attendance_report__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_clearance_form__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_contract__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_contract_extension__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_deduction__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_department__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_employee_appraisal__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_employee_reward__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_exit_return__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_expense__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_expense_sheet__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_group_employee_appraisal__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_holiday_officials__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_holidays__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_holidays_restriction__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_job__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_loan_payment_suspension__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_loan_salary_advance__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_official_mission__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_official_mission_employee__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_payroll_nomination__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_payroll_promotion_setting__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_payroll_raise__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_penalty_register__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_penalty_ss__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_personal_permission__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_punishment__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_re_contract__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_renew_official_paper__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_request_visa__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_termination__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_termination_patch__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_hr_ticket_request__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_leave_cancellation__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_blacklist__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_channel__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread_blacklist__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread_cc__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mail_thread_phone__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mailing_contact__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mailing_mailing__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_maintenance_equipment__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_maintenance_equipment_category__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_maintenance_request__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_manager_appraisal_line__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_mission_destination__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_note_note__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_petty_cash__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_phone_blacklist__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_product_product__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_product_template__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_project_project__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_project_task__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_purchase_order__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_purchase_requisition__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reconcile_leaves__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_wizard__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_res_currency__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_res_partner__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_res_users__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_return_from_leave__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_sale_order__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_stock_inventory__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_stock_picking__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_stock_production_lot__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_stock_scrap__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_survey_survey__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_ticket_class__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_all_reject_wizard__reason
msgid "Reason/Justification"
msgstr "السبب/التوضيح"
#. module: dynamic_reject_workflow
#: code:addons/dynamic_reject_workflow/models/reject_workflow.py:0
#, python-format
msgid "Reject"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model,name:dynamic_reject_workflow.model_reject_button
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button_wizard__button_ids
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_workflow__button_ids
msgid "Reject Button"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model,name:dynamic_reject_workflow.model_reject_button_wizard
msgid "Reject Button Wizard"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_fleet_maintenance__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_incoming_transaction__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_transaction_transaction__reason
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_vehicle_delegation__reason
msgid "Reject Reason"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model,name:dynamic_reject_workflow.model_reject_wizard
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.reject_wizard_view
msgid "Reason Wizard"
msgstr "السبب"
#. module: dynamic_reject_workflow
#: model:ir.model,name:dynamic_reject_workflow.model_all_reject_wizard
msgid "All Reject Wizard"
msgstr "الأسبــاب"
#. module: dynamic_reject_workflow
#: model:ir.actions.act_window,name:dynamic_reject_workflow.reject_workflow_action
#: model:ir.model,name:dynamic_reject_workflow.model_reject_workflow
#: model:ir.ui.menu,name:dynamic_reject_workflow.menu_reject_workflow
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.reject_workflow_form
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.workflow_stage_tree
msgid "Reject Workflow"
msgstr ""
#. module: dynamic_reject_workflow
#: model:res.groups,name:dynamic_reject_workflow.reject_group_manager
msgid "Reject Workflow Manager"
msgstr ""
#. module: dynamic_reject_workflow
#: model:ir.model.fields,field_description:dynamic_reject_workflow.field_reject_button__states
msgid "States"
msgstr ""
#. module: dynamic_reject_workflow
#: code:addons/dynamic_reject_workflow/models/reject_workflow.py:0
#, python-format
msgid "mail.threadreasonstring"
msgstr ""
#. module: dynamic_reject_workflow
#: model_terms:ir.ui.view,arch_db:dynamic_reject_workflow.view_form_reject_button_wizard
msgid "or"
msgstr ""

View File

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

View File

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
from lxml import etree
import json
from odoo import models, fields, api, _
class RejectWorkflow(models.Model):
_name = 'reject.workflow'
_description = 'Reject Workflow'
name = fields.Char(string='Name')
model_id = fields.Many2one('ir.model', string='Model Ref.', domain="[('is_mail_thread','=',True)]")
button_ids = fields.Many2many('reject.button', string="Reject Button")
@api.onchange('model_id')
def onchange_model_id(self):
self.env['reject.button'].search([]).unlink()
def reject_workflow_buttons(self):
for rec in self:
if rec.model_id:
self.env['reject.button'].search([]).unlink()
# In Odoo 18, use get_view instead of fields_view_get
view_info = self.env[rec.model_id.model].get_view(view_type='form')
arch = etree.XML(view_info['arch'])
for btn in arch.xpath("//form/header/button"):
attr = btn.attrib
# Handle modifiers if present
modifiers = {}
if 'modifiers' in attr:
modifiers = json.loads(attr['modifiers'])
btn_dict = {
'name': attr.get('string'),
'button_name': attr.get('name'),
'model_id': rec.model_id.id,
'invisible': 'invisible' in modifiers and
modifiers['invisible'] or False,
'states': 'states' in attr and attr['states'] or False
}
self.env['reject.button'].create(btn_dict)
return {
'name': _('Buttons'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'reject.button.wizard',
'target': 'new',
}
def check_reject_workflow(self, model=None):
button_ids = []
if model:
reject_button = self.search([('model_id.model', '=', model)])
button_ids = [{'name': item.button_name, 'string': item.name,
'invisible': item.invisible, 'states': item.states}
for item in reject_button.button_ids]
return button_ids
class RejectButtonWizard(models.TransientModel):
_name = 'reject.button.wizard'
_description = 'Reject Button Wizard'
button_ids = fields.Many2many('reject.button', string="Reject Button")
def confirm(self):
active_id = self.env['reject.workflow'].browse(self.env.context.get('active_id'))
active_id.write({
"button_ids": [(6, 0, self.button_ids.ids)]
})
class RejectButton(models.Model):
_name = 'reject.button'
_description = 'Reject Button'
name = fields.Char(string='Button String')
button_name = fields.Char(string='Button Name')
invisible = fields.Char(string='Invisible')
states = fields.Char(string='States')
model_id = fields.Many2one('ir.model', string='Model Ref.')
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
reason = fields.Text(string='Reason/Justification', tracking=True )
def action_reject_workflow(self):
return {
'name': _('Reject'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'all.reject.wizard',
'target': 'new',
}
@api.returns('mail.message', lambda value: value.id)
def message_post(self, *, body='', subject=None, message_type='notification',
parent_id=False, subtype_xmlid=None, attachments=None, **kwargs):
context = self.env.context
if 'merge_reason' in context:
tracking_value_ids = kwargs['tracking_value_ids']
tracking_value_ids.append([0, 0, {
'field': 'reason',
'field_desc': _(self.env['mail.thread'].fields_get('reason')['reason']['string']),
'field_type': 'Text',
'old_value_char': '',
'new_value_char': self.reason
}])
res = super(MailThread, self).message_post(body=body, subject=subject, message_type=message_type,
subtype_xmlid=subtype_xmlid,
parent_id=parent_id, attachments=attachments, **kwargs)
return res

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="reject_group_manager" model="res.groups">
<field name="category_id" ref="base.module_category_administration"/>
<field name="name">Reject Workflow Manager</field>
<field name="users" eval="[(4, ref('base.user_root'))]"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_reject_workflow_manager,reject_workflow_manager,model_reject_workflow,reject_group_manager,1,1,1,1
access_all_reject_wizard_manager,all_reject_wizard_manager,model_all_reject_wizard,reject_group_manager,1,1,1,1
access_reject_button,reject_button,model_reject_button,base.group_user,1,1,1,1
access_reject_workflow,reject_workflow,model_reject_workflow,base.group_user,1,0,0,0
access_reject_button_wizard_manager,reject_button_wizard_manager,model_reject_button_wizard,reject_group_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_reject_workflow_manager reject_workflow_manager model_reject_workflow reject_group_manager 1 1 1 1
3 access_all_reject_wizard_manager all_reject_wizard_manager model_all_reject_wizard reject_group_manager 1 1 1 1
4 access_reject_button reject_button model_reject_button base.group_user 1 1 1 1
5 access_reject_workflow reject_workflow model_reject_workflow base.group_user 1 0 0 0
6 access_reject_button_wizard_manager reject_button_wizard_manager model_reject_button_wizard reject_group_manager 1 1 1 1

View File

@ -0,0 +1,95 @@
/** @odoo-module **/
import { FormRenderer } from "@web/views/form/form_renderer";
import { patch } from "@web/core/utils/patch";
import { onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
patch(FormRenderer.prototype, {
setup() {
super.setup(...arguments);
this.orm = useService("orm");
this.state = useState({
rejectButtons: []
});
onWillStart(async () => {
await this.loadRejectButtons();
});
},
async loadRejectButtons() {
if (!this.props.record || !this.props.record.resModel) {
return;
}
try {
const rejectButtons = await this.orm.call(
'reject.workflow',
'check_reject_workflow',
[this.props.record.resModel],
{ model: this.props.record.resModel }
);
this.state.rejectButtons = rejectButtons || [];
} catch (error) {
console.error('Error loading reject workflow buttons:', error);
this.state.rejectButtons = [];
}
},
compileButton(buttonNode) {
// Call parent method first
const result = super.compileButton(buttonNode);
// Process reject workflow buttons
if (this.state.rejectButtons && this.state.rejectButtons.length > 0) {
this._processRejectButton(buttonNode);
}
return result;
},
_processRejectButton(buttonNode) {
if (!buttonNode || !buttonNode.getAttribute) {
return;
}
const attrs = {
name: buttonNode.getAttribute('name'),
string: buttonNode.getAttribute('string'),
states: buttonNode.getAttribute('states'),
invisible: buttonNode.getAttribute('invisible')
};
for (const btn of this.state.rejectButtons) {
const nodeInvisible = JSON.stringify(attrs.invisible || '').replace(/[^A-Z0-9]+/ig, "");
const btnInvisible = (btn.invisible || '').replace(/[^A-Z0-9]+/ig, "");
if (btn.name === attrs.name &&
btn.string === attrs.string &&
btn.states === attrs.states &&
nodeInvisible === btnInvisible) {
// Get existing context
let context = {};
const contextAttr = buttonNode.getAttribute('context');
if (contextAttr) {
try {
context = JSON.parse(contextAttr);
} catch (e) {
context = {};
}
}
// Add record reject name to context
context.record_reject_name = attrs.name;
// Update button attributes
buttonNode.setAttribute('context', JSON.stringify(context));
buttonNode.setAttribute('name', 'action_reject_workflow');
break;
}
}
}
});

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="view_form_reject_button_wizard" model="ir.ui.view">
<field name="name">reject.button.wizard.form</field>
<field name="model">reject.button.wizard</field>
<field name="arch" type="xml">
<form string="Buttons">
<field name="button_ids" nolable="1" widget="many2many_checkboxes"/>
<footer>
<button name="confirm" string="Confirm" type="object" class="oe_highlight"/>
or
<button string="Cancel" class="oe_link" special="cancel"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="reject_workflow_form" model="ir.ui.view">
<field name="name">reject.workflow.form</field>
<field name="model">reject.workflow</field>
<field name="arch" type="xml">
<form string="Reject Workflow">
<sheet>
<header>
<button name="reject_workflow_buttons" string="Choose Buttons"
class="btn-primary" type="object"/>
</header>
<group>
<field name="name" required="1" />
<field name="model_id" required="1" />
<field name="button_ids" force_save="1" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="workflow_stage_tree" model="ir.ui.view">
<field name="name">reject.workflow.tree</field>
<field name="model">reject.workflow</field>
<field name="arch" type="xml">
<tree string="Reject Workflow">
<field name="name" />
<field name="model_id" />
</tree>
</field>
</record>
<record id="reject_workflow_action" model="ir.actions.act_window">
<field name="name">Reject Workflow</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">reject.workflow</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
id="menu_reject_workflow"
name="Reject Workflow"
parent="base.next_id_2"
sequence="100"
groups="dynamic_reject_workflow.reject_group_manager"
action="reject_workflow_action"/>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import reject_wizard

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class RejectWizard(models.TransientModel):
_name = 'all.reject.wizard'
_description = 'All Reject Wizard'
reason = fields.Text(string='Reason/Justification')
def button_confirm(self):
context = dict(self._context)
active_model = context.get('active_model')
active_id = context.get('active_id')
if 'record_reject_name' in context:
rec_id = self.env[active_model].browse(active_id)
rec_id.write({
'reason': self.reason
})
reject_func = getattr(rec_id.with_context({'merge_reason': True}), context.get('record_reject_name'))
reject_func()
return True

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="reject_wizard_view" model="ir.ui.view">
<field name="name">Reason Wizard</field>
<field name="model">all.reject.wizard</field>
<field name="arch" type="xml">
<form string="Reason Wizard">
<group>
<field name="reason" required="1"/>
</group>
<footer>
<button name="button_confirm" string="Send" type="object" class="btn-primary"/>
<button special="cancel" string="Cancel" class="btn-default" />
</footer>
</form>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,27 @@
Odoo Proprietary License v1.0
This software and associated files (the "Software") may only be used (executed,
modified, executed after modifications) if you have purchased a valid license
from the authors, typically via Odoo Apps, or if you have received a written
agreement from the authors of the Software (see the COPYRIGHT file).
You may develop Odoo modules that use the Software as a library (typically
by depending on it, importing it and using its resources), but without copying
any source code or material from the Software. You may distribute those
modules under the license of your choice, provided that this license is
compatible with the terms of the Odoo Proprietary License (For example:
LGPL, MIT, or proprietary licenses similar to this one).
It is forbidden to publish, distribute, sublicense, or sell copies of the Software
or modified copies of the Software.
The above copyright notice and this permission notice must be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
from . import reports
from . import wizard
from .hooks import pre_init_hook, post_load, post_init_hook, uninstall_hook

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
{
"name": "KPI Balanced Scorecard",
"version": "18.0.1.0.4",
"category": "Extra Tools",
"author": "faOtools",
"website": "https://faotools.com/apps/18.0/kpi-balanced-scorecard-492",
"license": "Other proprietary",
"application": True,
"installable": True,
"auto_install": False,
"depends": [
"mail"
],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"data/data.xml",
"data/cron.xml",
"views/views.xml",
"wizard/kpi_copy_template.xml",
"views/kpi_measure_item.xml",
"views/kpi_measure.xml",
"views/kpi_constant.xml",
"views/kpi_item.xml",
"views/kpi_period.xml",
"views/kpi_category.xml",
"views/kpi_scorecard_line.xml",
"views/res_config_settings.xml",
"views/menu.xml",
"data/crm_measures.xml",
"data/sale_measures.xml",
"data/invoice_measures.xml",
"data/project_measures.xml"
],
"assets": {
"web.assets_backend": [
"kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js",
"kpi_scorecard/static/src/js/kpi_formula.js",
"kpi_scorecard/static/src/xml/kpi_formula_template.xml",
"kpi_scorecard/static/src/css/*.css",
],
},
"demo": [
],
"external_dependencies": {},
"summary": "The tool to set up KPI targets and control their fulfillment by periods",
"description": """For the full details look at static/description/index.html
* Features *
- Real-time control and historical trends
- Drag-and-drop formulas for KPIs
- Shared KPIs and self-control
- KPI settings to process Odoo data
#odootools_proprietary""",
"images": [
"static/description/main.png"
],
"price": "198.0",
"currency": "EUR",
"live_test_url": "https://faotools.com/my/tickets/newticket?&url_app_id=138&ticket_version=14.0&url_type_id=3",
}

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="crm_lead_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Leads and Opportunities: Count</field>
<field name="measure_type">count</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_only"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Count</field>
<field name="measure_type">count</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="domain">[("type", "=", "opportunity")]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_leads_only"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Leads: Count</field>
<field name="measure_type">count</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="domain">[("type", "=", "lead")]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_won_opprotunities"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Won Opportunities: Count</field>
<field name="measure_type">count</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">date_closed</field>
<field name="domain">[("stage_id.is_won", "=", "True")]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_days_to_assign"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Average Days to Assign</field>
<field name="measure_type">average</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">day_open</field>
<field name="domain">[]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_days_to_close"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Average Days to Close</field>
<field name="measure_type">average</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">day_close</field>
<field name="domain">[]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_expected_revenue"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Average Expected Revenue</field>
<field name="measure_type">average</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">expected_revenue</field>
<field name="domain">[]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_expected_revenue_total"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Total Expected Revenue</field>
<field name="measure_type">sum</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">expected_revenue</field>
<field name="domain">[]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_sale_total"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Total Sale Orders</field>
<field name="measure_type">sum</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">sale_amount_total</field>
<field name="domain">[]</field>
<field name="company_field_name">company_id</field>
</record>
<record id="crm_lead_opportunities_sale_average"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[crm] Opportunities: Average of Sale Orders</field>
<field name="measure_type">average</field>
<field name="model_name">crm.lead</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">sale_amount_total</field>
<field name="domain">[]</field>
<field name="company_field_name">company_id</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="cron_recalculate_kpi_periods" model="ir.cron">
<field name="name">Calculate KPIs</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_period"/>
<field name="state">code</field>
<field name="code">model.action_cron_calculate_kpi()</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')" />
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">8</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- <record id="mrp_workoorders_opened"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[TEST] TASKS</field>
<field name="measure_type">sum</field>
<field name="model_name">project.task</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">planned_hours</field>
<field name="company_field_name">company_id</field>
</record> -->
</data>
</odoo>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="customer_invoice_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[account] Posted Customer Invoices: Count</field>
<field name="measure_type">count</field>
<field name="model_name">account.move</field>
<field name="date_field_name">date</field>
<field name="company_field_name">company_id</field>
<field name="domain">[("move_type", "=", "out_invoice"), ("state", "=", "posted")]</field>
</record>
<record id="vendor_invoice_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[account] Posted Vendor Bills: Count</field>
<field name="measure_type">count</field>
<field name="model_name">account.move</field>
<field name="date_field_name">date</field>
<field name="company_field_name">company_id</field>
<field name="domain">[("move_type", "=", "in_invoice"), ("state", "=", "posted")]</field>
</record>
<record id="customer_invoice_amount_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[account] Posted Customer Invoices: Total</field>
<field name="measure_type">sum</field>
<field name="model_name">account.invoice.report</field>
<field name="date_field_name">invoice_date</field>
<field name="measure_field_name">price_subtotal</field>
<field name="company_field_name">company_id</field>
<field name="domain">[("move_type", "=", "out_invoice"), ("state", "=", "posted")]</field>
</record>
<record id="vendor_bills_amount_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[account] Posted Customer Invoices: Total</field>
<field name="measure_type">sum</field>
<field name="model_name">account.invoice.report</field>
<field name="date_field_name">invoice_date</field>
<field name="measure_field_name">price_subtotal</field>
<field name="company_field_name">company_id</field>
<field name="domain">[("move_type", "=", "in_invoice"), ("state", "=", "posted")]</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="project_task_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[project] Tasks: Count</field>
<field name="measure_type">count</field>
<field name="model_name">project.task</field>
<field name="date_field_name">create_date</field>
<field name="company_field_name">company_id</field>
</record>
<record id="project_task_planned_hours"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[project] Tasks: Total Planned Hours</field>
<field name="measure_type">sum</field>
<field name="model_name">project.task</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">planned_hours</field>
<field name="company_field_name">company_id</field>
</record>
<record id="project_task_planned_hours_average"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[project] Tasks: Average Planned Hours</field>
<field name="measure_type">average</field>
<field name="model_name">project.task</field>
<field name="date_field_name">create_date</field>
<field name="measure_field_name">planned_hours</field>
<field name="company_field_name">company_id</field>
</record>
<record id="project_task_working_days_to_close"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[project] Tasks: Working Days to Close</field>
<field name="measure_type">sum</field>
<field name="model_name">report.project.task.user</field>
<field name="date_field_name">date_assign</field>
<field name="measure_field_name">working_days_close</field>
<field name="company_field_name">company_id</field>
</record>
<record id="project_task_working_days_to_assign"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[project] Tasks: Working Days to Assign</field>
<field name="measure_type">sum</field>
<field name="model_name">report.project.task.user</field>
<field name="date_field_name">date_assign</field>
<field name="measure_field_name">working_days_open</field>
<field name="company_field_name">company_id</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="sale_order_all_abs"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales] All Sale Orders and Quotations: Count</field>
<field name="measure_type">count</field>
<field name="model_name">sale.order</field>
<field name="date_field_name">date_order</field>
<field name="company_field_name">company_id</field>
<field name="domain">[]</field>
</record>
<record id="sale_order_all"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales] Сonfirmed/Done Sale Orders: Count</field>
<field name="measure_type">count</field>
<field name="model_name">sale.order</field>
<field name="date_field_name">date_order</field>
<field name="company_field_name">company_id</field>
<field name="domain">["|", ("state","=","sale"), ("state","=","done")]</field>
</record>
<record id="sale_order_total"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales] Сonfirmed/Done Sale Orders: Total</field>
<field name="measure_type">sum</field>
<field name="model_name">sale.report</field>
<field name="date_field_name">date</field>
<field name="measure_field_name">price_total</field>
<field name="company_field_name">company_id</field>
<field name="domain">["|", ("state","=","sale"), ("state","=","done")]</field>
</record>
<record id="sale_order_total_qty"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales]Сonfirmed/Done Sale Orders: Product Units</field>
<field name="measure_type">sum</field>
<field name="model_name">sale.report</field>
<field name="date_field_name">date</field>
<field name="measure_field_name">product_uom_qty</field>
<field name="company_field_name">company_id</field>
<field name="domain">["|", ("state","=","sale"), ("state","=","done")]</field>
</record>
<record id="sale_order_invoiced_qty"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales] Сonfirmed/Done Sale Orders: Invoiced Units</field>
<field name="measure_type">sum</field>
<field name="model_name">sale.report</field>
<field name="date_field_name">date</field>
<field name="measure_field_name">qty_invoiced</field>
<field name="company_field_name">company_id</field>
<field name="domain">["|", ("state","=","sale"), ("state","=","done")]</field>
</record>
<record id="sale_order_delivered_qty"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales] Сonfirmed/Done Sale Orders: Delivered Units</field>
<field name="measure_type">sum</field>
<field name="model_name">sale.report</field>
<field name="date_field_name">date</field>
<field name="measure_field_name">qty_delivered</field>
<field name="company_field_name">company_id</field>
<field name="domain">["|", ("state","=","sale"), ("state","=","done")]</field>
</record>
<record id="sale_order_number_of_lines"
forcecreate="True"
model="kpi.measure"
>
<field name="name">[sales] Сonfirmed/Done Sale Orders: Number of Lines</field>
<field name="measure_type">sum</field>
<field name="model_name">sale.report</field>
<field name="date_field_name">date</field>
<field name="measure_field_name">nbr</field>
<field name="company_field_name">company_id</field>
<field name="domain">["|", ("state","=","sale"), ("state","=","done")]</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
def pre_init_hook(cr):
"""Loaded before installing the module.
None of this module's DB modifications will be available yet.
If you plan to raise an exception to abort install, put all code inside a
``with cr.savepoint():`` block to avoid broken databases.
:param openerp.sql_db.Cursor cr:
Database cursor.
"""
pass
def post_init_hook(cr, registry):
"""Loaded after installing the module.
This module's DB modifications will be available.
:param openerp.sql_db.Cursor cr:
Database cursor.
:param openerp.modules.registry.RegistryManager registry:
Database registry, using v7 api.
"""
pass
def uninstall_hook(cr, registry):
"""Loaded before uninstalling the module.
This module's DB modifications will still be available. Raise an exception
to abort uninstallation.
:param openerp.sql_db.Cursor cr:
Database cursor.
:param openerp.modules.registry.RegistryManager registry:
Database registry, using v7 api.
"""
pass
def post_load():
"""Loaded before any model or data has been initialized.
This is ok as the post-load hook is for server-wide
(instead of registry-specific) functionalities.
"""
pass

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from . import kpi_help
from . import kpi_period
from . import kpi_constant
from . import kpi_period_value
from . import kpi_measure
from . import kpi_measure_item
from . import kpi_item
from . import kpi_scorecard_line
from . import kpi_category
from . import res_company
from . import res_config_settings

View File

@ -0,0 +1,184 @@
#coding: utf-8
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class kpi_category(models.Model):
"""
The model to structure KPIs and targets
"""
_name = "kpi.category"
_inherit = "kpi.help"
_description = "KPI Category"
@api.depends("user_ids", "user_group_ids", "user_group_ids.users")
def _compute_access_user_ids(self):
"""
Compute method for access_user_ids
"""
for category in self:
users = category.user_group_ids.mapped("users") + category.user_ids
category.access_user_ids = [(6, 0, users.ids)]
@api.depends("edit_user_ids", "edit_user_group_ids", "edit_user_group_ids.users")
def _compute_edit_access_user_ids(self):
"""
Compute method for edit_access_user_ids
"""
for category in self:
users = category.edit_user_group_ids.mapped("users") + category.edit_user_ids
category.edit_access_user_ids = [(6, 0, users.ids)]
@api.constrains('parent_id')
def _check_node_recursion(self):
"""
Constraint for recursion
"""
if not self._check_recursion():
raise ValidationError(_('It is not allowed to make recursions!'))
return True
def _inverse_active(self):
"""
Inverse method for active to deactivate all child categories
"""
for category in self:
if not category.active:
for categ in category.child_ids:
categ.active = False
name = fields.Char(
string="Name",
required=True,
translate=True,
)
active = fields.Boolean(
string="Active",
default=True,
inverse=_inverse_active,
)
parent_id = fields.Many2one(
"kpi.category",
string="Parent Category",
)
child_ids = fields.One2many(
"kpi.category",
"parent_id",
string="Child Categories",
)
company_id = fields.Many2one(
"res.company",
string="Company",
)
description = fields.Text(
string="Notes",
translate=True,
)
sequence = fields.Integer(string="Sequence")
user_ids = fields.Many2many(
"res.users",
"res_users_kpi_category_rel_table",
"res_user_rel_id",
"kpi_category_id",
string="Read Rights Users",
)
user_group_ids = fields.Many2many(
"res.groups",
"res_groups_kkpi_category_rel_table",
"res_groups_id",
"kpi_category_id",
string="Read Rights User Groups",
)
access_user_ids = fields.Many2many(
"res.users",
"res_users_kpi_category_all_rel_table",
"res_user_all_rel_id",
"kpi_category_all_id",
string="Read Rights Access Users",
compute=_compute_access_user_ids,
compute_sudo=True,
store=True,
)
edit_user_ids = fields.Many2many(
"res.users",
"res_users_kpi_category_edit_rel_table",
"res_user_rel_id",
"kpi_category_id",
string="Edit Rights Allowed Users",
)
edit_user_group_ids = fields.Many2many(
"res.groups",
"res_groups_kpi_category_edit_rel_table",
"res_groups_id",
"kpi_category_id",
string="Edit Rights User Groups",
)
edit_access_user_ids = fields.Many2many(
"res.users",
"res_users_kpi_category_edit_all_rel_table",
"res_user_all_rel_id",
"kpi_category_all_id",
string="Edit Rights Access Users",
compute=_compute_edit_access_user_ids,
compute_sudo=True,
store=True,
)
_order = "sequence, id"
def name_get(self):
"""
Overloading the method, to reflect parent's name recursively
"""
result = []
for node in self:
name = u"{}{}".format(
node.parent_id and node.parent_id.name_get()[0][1] + '/' or '',
node.name,
)
result.append((node.id, name))
return result
@api.model
def action_return_nodes(self):
"""
The method to return nodes in jstree format
Methods:
* _return_nodes_recursive
Returns:
* list of folders dict with keys:
** id
** text - folder_name
** icon
** children - array with the same keys
"""
self = self.with_context(lang=self.env.user.lang)
nodes = self.search([("parent_id", "=", False)])
res = []
for node in nodes:
res.append(node._return_nodes_recursive())
return res
def _return_nodes_recursive(self):
"""
The method to go by all nodes recursively to prepare their list in js_tree format
Extra info:
* sorted needed to fix unclear bug of zero-sequence element placed to the end
* Expected singleton
"""
self.ensure_one()
res = {
"text": self.name,
"id": self.id,
}
if self._context.get("show_tooltip") and hasattr(self, "description") and self.description not in EMPTYHTML:
res.update({"a_attr": {"kn_tip": self.description},})
child_res = []
for child in self.child_ids.sorted(lambda ch: ch.sequence):
child_res.append(child._return_nodes_recursive())
res.update({"children": child_res})
return res

View File

@ -0,0 +1,91 @@
#coding: utf-8
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class kpi_constant(models.Model):
"""
The model to use static figures for calculations
"""
_name = "kpi.constant"
_inherit = "kpi.help"
_description = "KPI Constant"
name = fields.Char(
string="Name",
required=True,
translate=True,
)
target_value = fields.Float(
"Global Value",
help="Value used in case this constant is not applied for a target period",
)
periods_ids = fields.One2many(
"kpi.period.value",
"constant_id",
string="Set for periods",
)
company_id = fields.Many2one(
"res.company",
string="Company",
)
active = fields.Boolean(
string="Active",
default=True,
)
description = fields.Text(
string="Notes",
translate=True,
)
sequence = fields.Integer(string="Sequence")
_order = "sequence, id"
def unlink(self):
"""
The method to block unlink if used in any KPI
"""
for constant in self:
const_key = "CONST({})".format(constant.id)
domain = [
("formula", "like", const_key),
"|",
("active", "=", False),
("active", "=", True),
]
kpi_id = self.env["kpi.item"].sudo().search(domain, limit=1)
if kpi_id:
raise ValidationError(
_("There are KPIs which depend on this constant: {}. Delete them before".format(kpi_id))
)
super(kpi_constant, self).unlink()
def _get_value_by_period(self, period_id):
"""
The method to find target value by date
We search the most preceis constant definition: if it monthly period > but there is no constant for this month,
we also check quarterly and yearly intervals
Args:
* period_id - kpi.period
Returns:
* float
Extra info
* the method is hierarchicallyr recursive to check parents
* Expected singleton
"""
self.ensure_one()
target_value = self.target_value
for constant_period in self.periods_ids:
if period_id == constant_period.period_id:
target_value = constant_period.target_value
break
else:
if period_id.parent_id:
target_value = self._get_value_by_period(period_id.parent_id)
else:
target_value = self.target_value
return target_value

View File

@ -0,0 +1,257 @@
#coding: utf-8
from odoo import _, api, fields, models
help_dict = {
"kpi.period": _("""
<div style="width:80%;font-size:14px;">
<p style="padding-bottom:10px;">A KPI period is a time frame for which companies set their targets to control
performance (in a similar way as financial performance is controlled within accounting/fiscal periods).</p>
<p style="padding-bottom:10px;">KPI scorecards in comparison to simple dashboard assume that you might not only have
overview of actual figures, but to compare those to real targets. However, goals should be time-constrained ('Sell as
much as possible' is not a goal, while 'Generate 100,000 Euro Revenue in the year 2021' is a good target for a sales
person, for example). That is why KPI periods are introduced.</p>
<p style="padding-bottom:10px;">The app allows to set periods of any length including intervals which cross each other
(and even the Past periods!).
To open a new KPI period you should just set start and end dates. Those dates will be used to calculate KPI actual value.
For example, KPI formula might include a basic KPI measurement for total amount of sale orders, while date field of
this measurement is defined as 'Order date'. Then, only sale orders which order date is within the period would be
taken into account. Thus, KPI scorecard would show total revenue for a given period.</p>
<p style="padding-bottom:10px;">It is recommended to have more or less strict logic of periods for comparability
purposes. Usually, KPI targets are set for a whole year and quarterly/monthly intervals. It let not only have KPI
overview, but also check historical trends. The app automatically considers various periods in order to define which
might be compared to this one, and will show users a chart of actual values by a specific KPI. It is possible to define
tolerance on the configuration page. For example, 2-days tolerance is needed to compare quarterly periods (since quarter
might take from 90 to 92 days), and 3-days tolerance for months (unluckily, February may last 28 days).</p>
<p style="padding-bottom:10px;">When end date is already in the Past, it is preferable to close the period to avoid
further updates of KPI targets actual values due to further corrections (it is not a good idea to update December KPIs
in the next August even though a sale total needs to be corrected, for example). This action is pretty much the same
as when accountant finishes a fiscal year. To close a period use the button 'Close period' (this action might be rolled
back by re-opening the period).</p>
<p style="padding-bottom:10px;">KPI periods let you also copy targets from existing periods (so, use them as a
template). To that end just push the button 'Substitute targets' and select a period with proper KPI targets.
It significantly saves time, since KPI targets usually remain similar for periods of the same length. Do not worry,
after that action you would be still able to change actual scorecard for this period (for sure, until a period is
closed).</p>
<p style="padding-bottom:10px;">Based on KPI periods you may also configure KPI formulas to rely upon period length in
days to calculate figures per days, weeks, etc. To that end use the special Measurements 'Periods Days' or 'Days Passed'
(how many days already passed in comparison to today) when you construct a formula. For example, you might have average
sales amount per week within the given period, and compare how much it changes from the previous one. That length is
calculated automatically, while you just put that to a proper formula place.</p>
</div>
"""),
"kpi.item": _("""
<div style="width:80%;font-size:14px;">
<p style="padding-bottom:10px;">KPIs (key performance indicators) are measurements which aim to evaluate organizational
or personal success of a definite activity. Different companies have different KPIs depending on their strategy and
business area. However, all KPIs have the common core attribute: they must be measurable within a target period.</p>
<p style="padding-bottom:10px;">To that end KPIs allow to construct formulas to retrieve Odoo data sets and process
those to real figures. Formula preparation is as simple as it is to write down a mathematical expression: just drag and
drop formula parts in a right order with correct operators.</p>
<h3>KPIs, KPI periods, and KPI targets</h3>
<p style="padding-bottom:10px;">KPIs represent the list of success figures you may use to plan your company activities.
Simultaneously, measurements are almost senseless unless you have target values for those KPIs. That is possible only
within a time-constrained period. 'Sell as much as you can' is not a goal, while 'Generate 100,000 Euro Revenue in the
year 2021' is a good aim for a sales person, for example. To that end KPI periods are introduced.</p>
<p style="padding-bottom:10px;">A Combination of a KPI and a KPI period results in a KPI target. Exactly with those
targets you work on the score card interface. For each KPI you would like to manage in this period, you should define
a planned value to compare those to actual at the end of the period.</p>
<p style="padding-bottom:10px;">So, a KPI itself defines how to compute actual value for period and how to estimate the
result ('the more the better' or 'the less the better'), but does not assume setting targets. The latter should be done
for each period.</p>
<h3>Formula parts</h3>
<p style="padding-bottom:10px;">As variables for formula you may use:</p>
<p style="padding-bottom:10px;"><i>Measurements (KPI measurements, KPI variables)</i> are figures calculated from actual
Odoo data for a checked period. For example, 'number of quotations of the sales team Europe'.</p>
<p style="padding-bottom:10px;">Among measurements you may also find 2 very specific variables: 'Period Days' and 'Days
Passed'. Those figures are calculated not from Odoo data, but from the KPI period settings under consideration. 'Period
Days' is an interval length in days. 'Days Passed' is a length between period start and today (if today is before period
end; otherwise period end). Those parameters let calculate per-time KPIs, such as, for example, 'Average sales per
week'.</p>
<p style="padding-bottom:10px;"><i>Constants (KPI constants)</i> are fixed numbers applied globally or for a period.
Such numbers do not depend on Odoo data. So, they let define strict non-changeable figures which you can't otherwise get
from Odoo. For example, 'Total investments'.</p>
<p style="padding-bottom:10px;"><i>Other KPIs</i> are results of other KPI formula calculations. That variables allow to
construct derivative complex calculations and make up hierarchy. For example, you may have KPIs 'Sales count' and
'Opportunities count', and a derivative KPI 'Opportunity to sales success ratio'.</p>
<p style="padding-bottom:10px;">Variables of any types above might be added to a formula. You may drag and drop as many
variables as you like (and even use the same variable twice). Just do not forget to add operators in between to make
correct mathematical expressions. The following operators are available:</p>
<ul>
<li>"-" - subtraction;</li>
<li>"+" - addition;</li>
<li>"*" - multiplication;</li>
<li>"/" - division;</li>
<li>"(", ")" - to make proper calculation order as it is in Math;</li>
<li>"**" - exponentiation (**2 squaring; **0.5 square root extraction);</li>
<li>Float number.</li>
</ul>
<h3>Result appearance</h3>
<p style="padding-bottom:10px;">Depending of business logic of KPI formula, the final calculation result might have
different form:</p>
<ul>
<li>Simple number: for example, 'Average sales count per week';</li>
<li>Percentage: for example, 'Sales to opportunities success ratio';</li>
<li>Monetary: for example, 'Total Sales per period'. <strong>Make sure the measurement field you used is in the same
currency (usually company default currency)!</strong></li>
</ul>
<p style="padding-bottom:10px;">For simple numbers and percentage you may also define result suffix and prefix to make a
figure nice looking (e.g. add "%" as suffix to have "88<strong>%</strong>"). For monetary result type it is recommended
to define a currency, which symbol would be added to result.</p>
<p style="padding-bottom:10px;">Finally, you may decide how calculation result should be rounded. Available options are
from 0 to 4 decimal points (1 > 1.2 > 1.23 > 1.235 > 1.2346).</p>
<h3>Categories and hierarchy</h3>
<p style="padding-bottom:10px;">To make navigation by KPI targets more comfortable, KPIs are combined into KPI
categories. It let not only quickly search targets inside a scorecard, but it let grant additive access rights. In such
a way, users would overview only their KPIs structured in sections.</p>
<p style="padding-bottom:10px;">Each KPI might also have a parent. Such hierarchy let organize KPI scorecard with
indicative padding. For example, 'Total company sales' might have children 'Sales Europe' and 'Sales America'. The
latter 2 might be further specified by sales persons.</p>
<h3>Additive access to KPIs and targets</h3>
<p style="padding-bottom:10px;">By default KPIs and there linked targets are available only for users with the right
'KPI Manager'. Simultaneously, you may grant extra rights for other user groups or/and definite users. To that end it is
possible to define 'Read Rights' and 'Edit Rights' on KPI category or KPI form views (take into account that KPI
category rights and KPI own rights are combined!).</p>
<p style="padding-bottom:10px;">Read rights define which user and user groups would be able to observe KPI targets on
the scorecard interface. For example, you might want to share tasks' targets by persons with related project users in
order their control themselves.</p>
<p style="padding-bottom:10px;">Edit rights assume sharing an access to set specific targets up. For instance, you may
find it a good idea to involve sales manager to set sub targets for their sales team.</p>
<p style="padding-bottom:10px;">Take into account: all security settings are additive and they are not restrictive. KPI
managers would have full rights for all KPIs disregarding those settings, while other users would have rights only to
KPIs which settings (or category settings) allow them so.</p>
</div>
"""),
"kpi.measure.item": _("""
<div style="width:80%;">
<p style="padding-bottom:10px;">A KPI measurement is a final variable used for KPI(s) calculations. It represents
specification of basic measurement. For example, 'number of quotations of the sales team Europe' might be a precision of
a basic measurement 'total number of sales orders'.</p>
<p style="padding-bottom:10px;">Such approach significantly simplifies variables' preparation, since each basic
measurement might have an unlimited number of linked KPI measurements. So, the only thing you would need to do is to
apply extra filters (in the example 'Sales Team Name is Europe').</p>
<p style="padding-bottom:10px;">Take into account that basic measurements of the type 'Execute Python code' can't be
any more specified, since they do not relate to any records. In such a case, there is no sense to have a few KPI
measurements.</p>
<p style="padding-bottom:10px;">In a multi company environment KPI Measurements are applied globally or for each company
individually. In the former case that variable is available for any company KPI formulas, while in the latter only for
specific one (it let make quicker overview while constructing formulas).</p>
</div>
"""),
"kpi.constant": _("""
<div style="width:80%;">
<p style="padding-bottom:10px;">
A KPI constant is a variable type used for formula construction. In comparison to measurements, KPI constants are fixed
and they do not depend on actual Odoo data. This allows to introduce figures which can not be retrieved from modules
and/or which should remain the same during the whole period.</p>
<p style="padding-bottom:10px;">For example, you might set the 'total size of investments' to calculate return on
investments, or the 'number of salesmen' to get sales revenue per person.</p>
<p style="padding-bottom:10px;">KPI constant value might be defined for each individual period. If the value does not
exist for a calculated period, the app would try to check the parent time frame. For instance, if this constant value
does is not set up January 2021, the app would take all the intervals which include January (e.g. Quarter 1 2021 and the
year 2021 consequentially). In case the values is not defined for those periods as well, then, the global value would be
applied.</p>
</div>
"""),
"kpi.measure": _("""
<div style="width:80%;font-size:14px;">
<p style="padding-bottom:10px;">A basic measurement is the core object used for retrieving actual KPI value from Odoo
data. Although basic measurements are not used themselves for formula constructions, they are required to prepare any
sort of formula variables (KPI measurements).</p>
<p style="padding-bottom:10px;">A basic measurement represents the most general calculation, while KPI measurements
specify those. For example, 'total number of sales orders' should be a basic measurement, while narrower 'number of
quotations of the sales team Europe' is recommended to be a precision of that basic measurement (so, KPI measurement).
Each basic measurement might have an unlimited number of linked KPI measurements.</p>
<h3>Calculation Types</h3>
<p style="padding-bottom:10px;">Basic measurements assume a few types of low-level calculations:</p>
<ul>
<li>Counting records, e.g. number of registered leads or a number of posted customer invoices. Such measurements should
have the KPI type 'Count of records'.</li>
<li>Summing up certain number field of records, e.g. sum of all orders amount total or sum of paid taxes by invoices.
Such measurements should have the KPI type 'Sum of records field'.</li>
<li>Getting of average for records' number fields, e.g. average planned hours per task or average days to assign a lead.
Such measurements should have the KPI type 'Average of records field'.</li>
<li>Executing Python code. This type requires technical knowledge, but let you compute any sort of figures based on any
Odoo data. Merely introduce your Python code and save the value into the special variable 'result'. Here you might also
use the special KPI period related variables: 'period_start' (the first date of the period), 'period_end' (the last date
of the period), 'period_company_id' - res.company object for which Odoo makes calculations at the moment (according to
KPI period).</li>
</ul>
<h3>Basic Measurement Settings</h3>
<p style="padding-bottom:10px;">The first 3 calculation types assume that you define how records should be searched and
which records fields should be used for computations.</p>
<ul>
<li><i>Model</i> is an Odoo document type you have in your database, so with which data set you work. For, examples,
'Sales Order' or 'Task'. Here you can rely not only upon standard objects, but also on Odoo reports. The latter is quite
useful if indicators are already calculated for existing dashboard (for example, total sales amount in default currency
from the 'Sales Analysis Report').</li>
<li><i>Date fields</i> are required to understand whether a specific document type relates to a considered period, so,
how to distribute objects by time intervals. For example, for tasks you might use 'create date' to analyze jobs
registered within this KPI period (e.g. 'Total number of tasks created in January 2021'). It is possible to apply a few
date fields (e.g. 'Opportunities opened and won in January 2021'). If date fields are not specified, KPI period would
not influence this basic measurement.</li>
<li><i>Filters</i> allow you to restrict records set by any stored field. For example, you may calculate count of only
won opportunities based on stage settings or only posted customer invoices based on journal entry type and state.</li>
<li><i>Measure field</i> is available and required only for calculation types 'Average' and 'Sum'. It defines which
figure you use for calculations. For example, total amount of Sales Analysis Report to get accumulated sales revenue or
work hours of tasks to get average spent time per each task.</li>
<li><i>Company field</i> would be needed for multi companies environment. According to that field, KPIs are considered
only withing a KPI period target company. Take into account that records without company stated would be used for
all companies' KPI calculations.</li>
</ul>
<p style="padding-bottom:10px;">All settings might relate to your custom objects or custom fields, including ones
created through the interface or the Odoo studio.</p>
</div>
"""),
"kpi.category": _("""
<div style="width:60%;">
<p style="padding-bottom:10px;">KPI categories serve to structure KPIs and KPI targets for comfortable navigation. Each
KPI should be assigned for a single category, what allows users to find required targets quickly just by checking the
boxes on the scorecard interface.</p>
<p style="padding-bottom:10px;">Hierarchy of categories let users also combine targets in sections to control KPIs
related to specific areas. For example, to check targets only in sales (e.g. category 'sales') or targets of a
specific sales team (e.g. category 'sales > sales team Europe').</p>
<p style="padding-bottom:10px;">Moreover, KPI categories let you administrate user accesses in a batch. Thus, you may
grant users and/or user groups an access for this category KPI ('Read Rights') or a right to update those
targets ('Edit rights)'. Thus, there would be no need to manage each KPI separately. Take into account that those
settings are additive and are not restrictive, meaning that KPI managers would any way have full rights for all
KPIs.</p>
</div>
"""),
"kpi.scorecard.line": _("""
<p style="padding-bottom:10px;"></p>A KPI target is your plan for this KPI for a given period. By setting up a target
value, you indicate which result you would like to achieve by the end of the period.</p>
<p style="padding-bottom:10px;"></p>KPI targets actual values are re-calculated regularly (not in real time) and
automatically by the Odoo cron job. Alternatively, you may press the button 'Calculate' on the left navigation bar.</p>
"""),
"kpi.copy.template": _("""
<p style="padding-bottom:10px;">The action removes all current targets and copies targets from a chosen period. After
that action you would be still able to modify scorecard: change targets' values, delete certain KPI targets, or add new
ones.</p>
"""),
}
class kpi_help(models.AbstractModel):
"""
The model to store help settings
"""
_name = "kpi.help"
_description = "KPI Help"
@api.depends("kpi_help_dummy")
def _compute_show_kpi_help(self):
"""
Compute method for show_kpi_help
"""
help_setting = self.env.user.company_id.show_kpi_help
help_notes = help_setting and help_dict.get(self._name) or False
for record in self:
record.help_notes = help_notes
help_notes = fields.Html(
string="Help",
compute=_compute_show_kpi_help,
)
kpi_help_dummy = fields.Boolean("Dummy Help")

View File

@ -0,0 +1,670 @@
#coding: utf-8
import random
import re
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import safe_eval
OPERANDS = ["-", "+", "*", "/", "(", ")", "**"]
ERRORFORM = _("<ERROR!!!FORMULA PART DOES NOT EXIST>")
def cut_name_part(part):
"""
The method to cut part name
"""
if part and len(part) > 40:
part = part[:38] + ".."
return part
class kpi_item(models.Model):
"""
The key model for KPI calulation
"""
_name = "kpi.item"
_inherit = "kpi.help"
_description = "KPI"
@api.depends("formula")
def _compute_measures_ids(self):
"""
Compute method for measures_ids, constant_ids, kpi_ids, formula_warning
Methods:
* action_render_formula
* _check_formula_with_testing_data
"""
self = self.sudo()
for kpi in self:
formula_parts = kpi.action_render_formula(kpi.formula)
measures_ids = []
constant_ids = []
kpi_ids = []
for part in formula_parts:
if part.get("type") == "MEASURE":
measures_ids.append(part.get("res_id"))
elif part.get("type") == "CONST":
constant_ids.append(part.get("res_id"))
elif part.get("type") == "KPI":
kpi_ids.append(part.get("res_id"))
measures_ids = self.env["kpi.measure.item"].browse(measures_ids).exists().ids
kpi.measures_ids = [(6, 0, measures_ids)]
constant_ids = self.env["kpi.constant"].browse(constant_ids).exists().ids
kpi.constant_ids = [(6, 0, constant_ids)]
kpi_ids = self.env["kpi.item"].browse(kpi_ids).exists().ids
kpi.kpi_ids = [(6, 0, kpi_ids)]
kpi.formula_warning = kpi._check_formula_with_testing_data()
@api.depends("parent_id", "parent_id.all_parent_ids")
def _compute_all_parent_ids(self):
"""
Compute method for all_parent_ids. The idea is have full hierarchy for dependance
"""
for kpi in self:
kpi.all_parent_ids = [(6, 0, (kpi.parent_id + kpi.parent_id.all_parent_ids).ids)]
@api.depends("user_ids", "user_group_ids", "user_group_ids.users", "category_id", "category_id.access_user_ids")
def _compute_access_user_ids(self):
"""
Compute method for access_user_ids
"""
for line in self:
own_users = line.user_group_ids.mapped("users") + line.user_ids
category_users = line.category_id.access_user_ids
users = own_users + category_users
line.access_user_ids = [(6, 0, users.ids)]
@api.depends("edit_user_ids", "edit_user_group_ids", "edit_user_group_ids.users", "category_id",
"category_id.edit_access_user_ids")
def _compute_edit_access_user_ids(self):
"""
Compute method for edit_access_user_ids
"""
for line in self:
own_users = line.edit_user_group_ids.mapped("users") + line.edit_user_ids
category_users = line.category_id.edit_access_user_ids
users = own_users + category_users
line.edit_access_user_ids = [(6, 0, users.ids)]
@api.constrains('parent_id')
def _check_hierarhcical_recursion(self):
"""
Check hierarchical recursion
Methods:
* _check_recursion of models.py
"""
if not self._check_recursion():
raise ValidationError(_('You cannot create recursive KPIs.'))
return True
@api.constrains('formula')
def _check_formula_recursion(self):
"""
Check formula recursion not to have other KPIs calculation leads to this calculation
Methods:
* _check_recursion of models.py
"""
for kpi in self:
parent_kpis = kpi.kpi_ids
while parent_kpis:
if kpi.id in parent_kpis.ids:
raise ValidationError(_(
'Other KPIs used in formula depends on this KPI. Calculation is impossible'
))
parent_kpis = parent_kpis.mapped("kpi_ids")
return True
@api.onchange("result_appearance")
def _onchange_result_appearance(self):
"""
Onchange method for result_appearance
"""
for kpi in self:
if kpi.result_appearance == "percentage":
kpi.result_suffix = "%"
name = fields.Char(
string="Name",
required=True,
translate=True,
)
category_id = fields.Many2one(
"kpi.category",
string="Category",
required=True,
)
formula = fields.Char(
string="Formula",
)
formula_warning = fields.Char(
string="Alert",
compute=_compute_measures_ids,
store=False,
)
line_ids = fields.One2many(
"kpi.scorecard.line",
"period_id",
string="Targets"
)
result_type = fields.Selection(
[
("more", "The more the better"),
("less", "The less the better"),
],
string="Success Criteria",
default="more",
required=True,
)
result_appearance = fields.Selection(
[
("number", "Number"),
("percentage", "Percentage"),
("monetory", "Monetary"),
],
string="Result Type",
default="number",
required=True,
)
result_suffix = fields.Char(
string="Result Suffix",
help="would be shown after the result value",
)
result_preffix = fields.Char(
string="Result Prefix",
help="would be shown before the result value",
)
currency_id = fields.Many2one(
"res.currency",
string="Currency",
default=lambda self: self.env.user.company_id.currency_id,
)
result_rounding = fields.Selection(
[
("1", "1"),
("2", "2"),
("3", "3"),
("4", "4"),
],
string="Rounding Decimals",
default="2",
)
parent_id = fields.Many2one(
"kpi.item",
string="Parent KPI",
)
all_parent_ids = fields.Many2many(
"kpi.item",
"kpi_item_kpi_item_all_parents_rel_table",
"kpi_item_all_id",
"kpi_item_all_back_id",
string="All Parents",
compute=_compute_all_parent_ids,
compute_sudo=True,
store=True,
)
child_ids = fields.One2many(
"kpi.item",
"parent_id",
string="Child KPIs",
)
measures_ids = fields.Many2many(
"kpi.measure.item",
"kpi_item_kpi_measure_rel_table"
"kpi_item_measure_rel_id",
"kpi_measure_item_rel_id",
string="Measurements",
compute=_compute_measures_ids,
context={'active_test':False},
)
constant_ids = fields.Many2many(
"kpi.constant",
"kpi_item_kpi_constant_rel_table",
"kpi_item_id",
"kpi_constant_id",
string="Constants",
compute=_compute_measures_ids,
context={'active_test':False},
)
kpi_ids = fields.Many2many(
"kpi.item",
"kpi_item_kpi_item_rel_table",
"kpi_item_this_id",
"kpi_item_back_id",
string="Other KPIs",
compute=_compute_measures_ids,
context={'active_test': False},
)
company_id = fields.Many2one(
"res.company",
string="Company",
)
active = fields.Boolean(
string="Active",
default=True,
)
description = fields.Text(
string="Notes",
translate=True,
)
sequence = fields.Integer(string="Sequence")
user_ids = fields.Many2many(
"res.users",
"res_users_kpi_item_rel_table",
"res_user_rel_id",
"kpi_item_id",
string="Allowed Users",
)
user_group_ids = fields.Many2many(
"res.groups",
"res_groups_kpi_item_rel_table",
"res_groups_id",
"kpi_item_id",
string="Allowed User Groups",
)
access_user_ids = fields.Many2many(
"res.users",
"res_users_kpi_item_all_rel_table",
"res_user_all_rel_id",
"kpi_item_all_id",
string="Access Users",
compute=_compute_access_user_ids,
compute_sudo=True,
store=True,
)
edit_user_ids = fields.Many2many(
"res.users",
"res_users_kpi_item_edit_rel_table",
"res_user_rel_id",
"kpi_item_id",
string="Edit Rights Allowed Users",
)
edit_user_group_ids = fields.Many2many(
"res.groups",
"res_groups_kpi_item_edit_rel_table",
"res_groups_id",
"kpi_item_id",
string="Edit Rights User Groups",
)
edit_access_user_ids = fields.Many2many(
"res.users",
"res_users_kpi_item_edit_all_rel_table",
"res_user_all_rel_id",
"kpi_item_all_id",
string="Edit Rights Access Users",
compute=_compute_edit_access_user_ids,
compute_sudo=True,
store=True,
)
_order = "sequence, id"
def unlink(self):
"""
The method to block unlink if used in any KPI
"""
for kpi in self:
kpi_key = "KPI({})".format(kpi.id)
domain = [
("formula", "like", kpi_key),
"|",
("active", "=", False),
("active", "=", True),
]
kpi_id = self.env["kpi.item"].sudo().search(domain, limit=1)
if kpi_id:
raise ValidationError(
_("There are KPIs which depend on this KPI: {}. Delete them before".format(kpi_id))
)
super(kpi_item, self).unlink()
def action_return_measures(self, formula):
"""
The method to return available measurements, constants
Methods:
* action_render_formula
Extra info:
* Expected singleton
"""
self.ensure_one()
measure_ids = self.env["kpi.measure.item"].search([('existing_kpi', '!=', False)]).mapped(lambda mes: {
"id": mes.id,
"name": mes.name,
"description": mes.description or mes.name,
})
constant_ids = self.env["kpi.constant"].search([]).mapped(lambda const: {
"id": const.id,
"name": const.name,
"description": const.description or const.name,
})
kpi_ids = self.env["kpi.item"].search([("id", "!=", self.id)]).mapped(lambda kpi: {
"id": kpi.id,
"name": kpi.name,
"description": kpi.description or kpi.name,
})
formulaparts = self.action_render_formula(formula)
return {
"measures": measure_ids,
"constants": constant_ids,
"kpis": kpi_ids,
"operands": OPERANDS,
"formulaparts": formulaparts,
}
@api.model
def action_render_formula(self, formula):
"""
The method to split formula on parts
Methods:
* _get_id_from_string
Returns:
* list of dicts
** id
** name
Extra info:
* expected singleton
"""
formula_parts = []
if formula:
formula_parts_str = formula.split(";")
for part in formula_parts_str:
if part.startswith("MEASURE"):
part_id = self._get_id_from_string(part)
if part_id:
measure_id = self.env["kpi.measure.item"].browse(part_id)
formula_parts.append({
"type": "MEASURE",
"res_id": part_id,
"id": part,
"name": measure_id.exists() and cut_name_part(measure_id.name) or ERRORFORM,
"extra_class": "kpi-measure-highlight",
})
elif part.startswith("CONST"):
part_id = self._get_id_from_string(part)
if part_id:
const_id = self.env["kpi.constant"].browse(part_id)
formula_parts.append({
"type": "CONST",
"res_id": part_id,
"id": part,
"name": const_id.exists() and cut_name_part(const_id.name) or ERRORFORM,
"extra_class": "kpi-const-highlight",
})
elif part.startswith("KPI"):
part_id = self._get_id_from_string(part)
if part_id:
kpi_id = self.env["kpi.item"].browse(part_id)
formula_parts.append({
"type": "KPI",
"res_id": part_id,
"id": part,
"name": kpi_id.exists() and cut_name_part(kpi_id.name) or ERRORFORM,
"extra_class": "kpi-kpi-highlight",
})
elif part.startswith("PERIOD"):
if part == "PERIOD_len":
formula_parts.append({
"type": "PERIOD",
"res_id": False,
"id": part,
"name": _("Period Days"),
"extra_class": "kpi-measure-highlight",
})
elif part == "PERIOD_passed":
formula_parts.append({
"type": "PERIOD_passed",
"res_id": False,
"id": part,
"name": _("Days Passed"),
"extra_class": "kpi-measure-highlight",
})
else:
formula_parts.append({
"type": "OPERATOR",
"res_id": False,
"id": part,
"name": part,
"extra_class": "kpi-element-operator kpi-operator-highlight",
})
return formula_parts
@api.model
def action_open_formula_part(self, formula_part_id):
"""
The method to open formula part (measure, cosntant, another kpi)
Args:
* formula_part_id - char
Methods:
* _get_id_from_string
Returns:
* action dict (or False if not connected)
"""
res_id = False
if formula_part_id:
action = {
"name": _("Details"),
"view_mode": "form",
"type": "ir.actions.act_window",
"views": [(False, 'form')],
"target": "new",
}
if formula_part_id.startswith("MEASURE"):
res_id = self._get_id_from_string(formula_part_id)
action.update({
"res_id": res_id,
"res_model": "kpi.measure.item",
})
elif formula_part_id.startswith("CONST"):
res_id = self._get_id_from_string(formula_part_id)
action.update({
"res_id": res_id,
"res_model": "kpi.constant",
})
elif formula_part_id.startswith("KPI"):
res_id = self._get_id_from_string(formula_part_id)
action.update({
"res_id": res_id,
"res_model": "kpi.item",
})
return res_id and action or False
def _calculate_by_measure(self, all_variables_dict):
"""
The method to consturct formula with numeric values and calculate the result
Returns:
* float
Extra info:
* Expected singleton
"""
self.ensure_one()
try:
if self.formula:
numeric_formula = self.formula.replace(";", " ")
for key, value in all_variables_dict.items():
if numeric_formula.find(key) != -1:
if isinstance(value, (int, float)):
numeric_formula = numeric_formula.replace(key, str(value))
else:
res = value
break
else:
res = safe_eval(numeric_formula)
else:
res = _("Computation Error: the formula is empty")
except Exception as e:
res = _("Computation Error: {}".format(e))
return res
def _get_measures(self):
"""
The method to recursively return included measures and constants
Returns:
* kpi.measure.item recordset
* kpi.constant recordset
"""
all_measures = self.mapped("measures_ids")
all_constants = self.mapped("constant_ids")
all_other_kpis = self.mapped("kpi_ids")
if all_other_kpis:
child_measures, child_constants, child_other_kpis = all_other_kpis._get_measures()
all_measures += child_measures
all_constants += child_constants
all_other_kpis += child_other_kpis
return all_measures, all_constants, all_other_kpis
def _check_formula_with_testing_data(self):
"""
The method to check the formula with random tesing data
Methods:
* _calculate_by_measure
Returns:
* False if everything is fine
* char - error messages otherwise
Extra info:
* Expected singleton
"""
self.ensure_one()
res = False
if self.formula:
all_variables_dict = {}
for measure in self.measures_ids:
if measure.existing_kpi != False:
res = _("The formula uses obsolete measurement: {}".format(measure.name))
break
all_variables_dict.update({"MEASURE({})".format(measure.id): random.randint(5, 10000)})
else:
for constant in self.constant_ids:
all_variables_dict.update({"CONST({})".format(constant.id): random.randint(5, 10000)})
else:
all_variables_dict.update({
"PERIOD_len": 90,
"PERIOD_passed": 80,
})
for kpi in self.kpi_ids:
child_check = kpi._check_formula_with_testing_data()
if child_check:
res = _("The formula might rely upon incorrect KPI: {}.>> {}".format(kpi.name, child_check))
break
all_variables_dict.update({"KPI({})".format(kpi.id): random.randint(5, 10000)})
else:
value = self._calculate_by_measure(all_variables_dict)
if not isinstance(value, (int, float)):
res = _("The formula might be incorrect: {}".format(value))
else:
res = _("The formula is empty")
return res
@api.model
def _get_id_from_string(self, str_id):
"""
The method to retrieve int from string
Args:
* str_id - char
Returns:
* int
"""
all_numbers = re.findall('\d+', str_id)
return all_numbers and int(all_numbers[0]) or False
def _return_formated_appearance(self, value, novalue_change=False, currency=False):
"""
The method to format value accroding to KPI setting
Args:
* value - float
* novalue_change - whether value should not be recalculated (e.g for % of target value)
* currency - res.currency object
Methods:
* _return_rounded_value
Returns:
* str
Extra info:
* Expected singleton
"""
self.ensure_one()
formatted_value = self._return_rounded_value(value, novalue_change)
formatted_value = isinstance(formatted_value, (int, float)) and "{:,}".format(formatted_value) \
or formatted_value
currency = self.currency_id or currency
if self.result_appearance in ["monetory"] and currency:
currency_symbol = currency.symbol
formatted_value = currency.position == "before" and "{}{}".format(currency_symbol, formatted_value) \
or "{}{}".format(formatted_value, currency_symbol)
else:
formatted_value = "{}{}{}".format(
self.result_preffix or "", formatted_value, self.result_suffix or "",
)
return formatted_value
def _return_rounded_value(self, value, novalue_change):
"""
The method to return rounded value
Args:
* value - float
* novalue_change - whether value should not be recalculated (e.g for % of target value)
Returns:
* int. float, or str
"""
self.ensure_one()
result_appearance = self.result_appearance
formatted_value = value
if result_appearance in ["number", "percentage", "monetory"]:
rounding = self.result_rounding and int(self.result_rounding) or 0
formatted_value = result_appearance in ["number", "monetory"] and formatted_value \
or result_appearance in ["percentage"] and novalue_change and formatted_value \
or formatted_value * 100 \
or formatted_value
formatted_value = round(formatted_value, rounding)
if rounding == 0:
formatted_value = int(formatted_value)
return formatted_value
def _return_xls_formatting(self, value, novalue_change=False):
"""
The method to format value accroding to KPI setting for table cell
Args:
* value - float
* novalue_change - whether value should not be recalculated (e.g for % of target value)
Returns:
* int or float
"""
self.ensure_one()
result_appearance = self.result_appearance
rounding = self.result_rounding and int(self.result_rounding) or 0
formatted_value = value
if result_appearance in ["number", "percentage", "monetory"]:
formatted_value = result_appearance in ["number", "monetory"] and formatted_value \
or result_appearance in ["percentage"] and novalue_change and formatted_value \
or formatted_value / 100 \
or formatted_value
formatted_value = round(formatted_value, rounding)
if rounding == 0:
formatted_value = int(formatted_value)
return formatted_value

View File

@ -0,0 +1,322 @@
#coding: utf-8
import numbers
from datetime import date, datetime, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import safe_eval
DEFAULT_PYTHON_CODE = """# Make sure the 'result' is defined and it contains the new current value.
# Result should be of type integer or float
# For example, result = env["crm.lead"].search_count([('create_date', '>=', period_start+timedelta(days=1))])
# Available variables:
# - env: Odoo Environment on which the action is triggered
# - period_start, period_end - period on which the KPI is calculated
# - period_company_id - res.company object for which KPI target is calculated
# - date, datetime, timedelta: useful Python libraries
result = 10\n\n\n\n"""
EXISTING_INSTALLED = _("In order to activate the KPI, please install the module for {}")
class kpi_measure(models.Model):
"""
The core model of the module to calculate KPI figure
* The idea of having computed and inversed model, date_field_id is to have prepared classifier even the linked
apps are not installed
"""
_name = "kpi.measure"
_inherit = "kpi.help"
_description = "KPI Measurement"
@api.model
def _return_model(self):
"""
The method to return available models
"""
self._cr.execute("SELECT model, name FROM ir_model ORDER BY name")
return self._cr.fetchall()
@api.depends("model_name")
def _compute_model_id(self):
"""
Compute method for model_id
Methods:
* _return_model
* _get_id of ir.model
"""
all_models = [model_n[0] for model_n in self._return_model()]
for measure in self:
model_id = False
if measure.model_name and measure.model_name in all_models:
model_id = self.env["ir.model"].sudo().search([("model", "=", measure.model_name)], limit=1)
measure.model_id = model_id
@api.depends("date_field_name")
def _compute_date_field_ids(self):
"""
Compute method for date_field_name
Methods:
* _get of ir.model.fields
"""
for measure in self:
res_ids = []
all_field_names = measure.date_field_name
existing_model = not measure.existing_kpi
if all_field_names:
all_fields = all_field_names.split(",")
for field_date in all_fields:
field_id = existing_model and self.env["ir.model.fields"]._get(measure.model_name, field_date) \
or False
if field_id:
res_ids.append(field_id.id)
measure.date_field_ids = [(6, 0, res_ids)]
@api.depends("measure_field_name")
def _compute_measure_field_id(self):
"""
Compute method for measure_field_id
Methods:
* _get of ir.model.fields
"""
for measure in self:
field_id = not measure.existing_kpi \
and self.env["ir.model.fields"]._get(measure.model_name, measure.measure_field_name) or False
measure.measure_field_id = field_id or False
@api.depends("company_field_name")
def _compute_company_field_id(self):
"""
Compute method for company_field_id
Methods:
* _get of ir.model.fields
"""
for measure in self:
field_id = not measure.existing_kpi \
and self.env["ir.model.fields"]._get(measure.model_name, measure.company_field_name) or False
measure.company_field_id = field_id or False
@api.depends("model_name")
def _compute_existing_kpi(self):
"""
Compute method for existing_kpi
"""
for measure in self:
res = False
if measure.measure_type in ["sum", "average", "count"] and measure.model_name and not measure.model_id:
res = EXISTING_INSTALLED.format(measure.model_name)
measure.existing_kpi = res
@api.depends("item_ids")
def _compute_measures_len(self):
"""
Compute method for measures_len
"""
for measure in self:
measure.measures_len = len(measure.item_ids)
def _inverse_model_id(self):
"""
Inverse method for model_id
"""
for measure in self:
measure.model_name = measure.model_id and measure.model_id.model or False
def _inverse_date_field_ids(self):
"""
Inverse method for date_field_ids
"""
for measure in self:
measure.date_field_name = measure.date_field_ids and ",".join(measure.date_field_ids.mapped("name")) or \
False
def _inverse_measure_field_id(self):
"""
Inverse method for measure_field_id
"""
for measure in self:
measure.measure_field_name = measure.measure_field_id and measure.measure_field_id.name or False
def _inverse_company_field_id(self):
"""
Inverse method for company_field_id
"""
for measure in self:
measure.company_field_name = measure.company_field_id and measure.company_field_id.name or False
@api.onchange("model_id")
def _onchange_model_id(self):
"""
Onchange method for model_id - to update fields and
"""
for measure in self:
company_field_id = False
if measure.model_id:
measure.model_name = measure.model_id.model
company_field = self.env["ir.model.fields"]._get(measure.model_id.model, "company_id")
if company_field:
company_field_id = company_field
measure.date_field_ids = False
measure.measure_field_id = False
measure.company_field_id = company_field_id
@api.model
def search_existing_kpi(self, operator, value):
"""
Search method for existing_kpi
"""
all_measures = self.search([])
measure_ids = []
for measure in all_measures:
res = False
if measure.measure_type in ["sum", "average", "count"] and measure.model_name and not measure.model_id:
res = EXISTING_INSTALLED.format(measure.model_name)
if value == False:
if operator == "!=" and not res:
measure_ids.append(measure.id)
elif operator == "=" and res:
measure_ids.append(measure.id)
elif res and res.find(value) != -1:
measure_ids.append(measure.id)
return [('id', 'in', measure_ids)]
name = fields.Char(
string="Name",
required=True,
translate=True,
)
measure_type = fields.Selection(
[
("sum", "Sum of records field"),
("average", "Average of records field"),
("count", "Count of records"),
("py_code", "Execute Python code"),
],
string="KPI Type",
default="sum",
required=True,
)
py_code = fields.Text(
string="Python Code",
default=DEFAULT_PYTHON_CODE,
help="Make sure the 'result' is defined and it contains the new current value."
)
model_name = fields.Char("Model Name")
model_id = fields.Many2one(
'ir.model',
string='Model',
compute=_compute_model_id,
inverse=_inverse_model_id,
readonly=False,
)
domain = fields.Text(
string="Filters",
default="[]",
required=True,
)
date_field_name = fields.Char(string="Date Field Name",)
date_field_ids = fields.Many2many(
"ir.model.fields",
"ir_model_fields_kpi_measure_rel_table",
"ir_model_field_date_id",
"kpi_measure_rel_id",
string='Date Fields',
compute=_compute_date_field_ids,
inverse=_inverse_date_field_ids,
readonly=False,
help="""
According to those dates Odoo will calculate whether a record is within a specified period.
In case there are a few of date fields, all of related dates should be within a period.
""",
)
measure_field_name = fields.Char(
string="Measure Field Name",
)
measure_field_id = fields.Many2one(
'ir.model.fields',
string='Measure Field',
compute=_compute_measure_field_id,
inverse=_inverse_measure_field_id,
readonly=False,
)
company_field_name = fields.Char(
string="Company Field Name",
)
company_field_id = fields.Many2one(
'ir.model.fields',
string='Company Field',
compute=_compute_company_field_id,
inverse=_inverse_company_field_id,
readonly=False,
)
existing_kpi = fields.Char(
string="Installed",
compute=_compute_existing_kpi,
search="search_existing_kpi",
)
item_ids = fields.One2many(
"kpi.measure.item",
"measure_id",
string="Measurements"
)
measures_len = fields.Integer(
string="Measurements Count",
compute=_compute_measures_len,
store=True,
)
active = fields.Boolean(
string="Active",
default=True,
)
description = fields.Text(
string="Notes",
translate=True,
)
sequence = fields.Integer(string="Sequence")
_order = "sequence, id"
def _execute_py_code(self, date_start, date_end, period_company_id):
"""
The method to executr Python code
Args:
* date_start - date
* date_end - date
* period_company_id - res.company object
Returns:
* float or integer - if success
* char - if error
Extra info:
* Expected singleton
"""
self.ensure_one()
cxt = {
'env': self.env,
'period_start': date_start,
'period_end': date_end,
"period_company_id": period_company_id,
'date': date,
'datetime': datetime,
'timedelta': timedelta,
}
code = self.py_code.strip()
try:
safe_eval(code, cxt, mode="exec", nocopy=True)
result = cxt.get('result')
if result is None:
result = _("Computation Error: Python code is incorrect: it doesn't contain 'result' key word")
elif not isinstance(result, (int, float)):
result = _(
"Computation Error: Python code is incorrect: it returns not number but {}".format(type(result))
)
except Exception as e:
result = _("Computation Error: {}".format(e))
return result

View File

@ -0,0 +1,132 @@
#coding: utf-8
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import safe_eval
class kpi_measure_item(models.Model):
"""
The key model for KPI calulation
"""
_name = "kpi.measure.item"
_inherit = "kpi.help"
_description = "KPI Measurement (Variable)"
name = fields.Char(
string="Name",
required=True,
translate=True,
)
measure_id = fields.Many2one(
"kpi.measure",
string="Measurement",
required=True,
domain=[("existing_kpi", "!=", False)],
)
domain = fields.Text(
string="Extra Filters",
default="[]",
required=True,
)
model_id = fields.Many2one(
related="measure_id.model_id",
store=True,
compute_sudo=True,
related_sudo=True,
)
model_name = fields.Char(
related="measure_id.model_name",
store=True,
compute_sudo=True,
related_sudo=True,
)
measure_type = fields.Selection(
related="measure_id.measure_type",
store=True,
compute_sudo=True,
related_sudo=True,
)
company_id = fields.Many2one(
"res.company",
string="Company"
)
active = fields.Boolean(
string="Active",
default=True,
)
description = fields.Text(
string="Notes",
translate=True,
)
sequence = fields.Integer(string="Sequence")
existing_kpi = fields.Char(related="measure_id.existing_kpi",)
_order = "sequence, id"
def unlink(self):
"""
The method to block unlink if used in any KPI
"""
for kpi in self:
measure_item_key = "MEASURE({})".format(kpi.id)
domain = [
("formula", "like", measure_item_key),
"|",
("active", "=", False),
("active", "=", True),
]
kpi_id = self.env["kpi.item"].sudo().search(domain, limit=1)
if kpi_id:
raise ValidationError(
_("There are KPIs which depend on this MEASUREMENT: {}. Delete them before".format(kpi_id))
)
super(kpi_measure_item, self).unlink()
def _calculate_for_period(self, period_id):
"""
The method to calculate KPI for period
Args:
* period_id - kpi.period
Returns:
* float
Extra info:
* Expected singleton
"""
self.ensure_one()
self = self.sudo()
measure = self.measure_id
res = _("""Computation Error: KPI is not correctly defined. Perhaps, the related module was uninstalled or
field was removed. Please check basic measurements involved in formula""")
date_start = period_id.date_start
date_end = period_id.date_end
company_id = period_id.company_id
if measure.measure_type == "py_code":
res = measure._execute_py_code(date_start, date_end, company_id)
elif measure.model_id:
considered_model = self.env[measure.model_name]
try:
domain = safe_eval(self.domain)+safe_eval(measure.domain)
for date_field in measure.date_field_ids:
domain += [
(date_field.name, ">=", date_start),
(date_field.name, "<=", date_end),
]
if measure.company_field_id:
company_f = measure.company_field_name
domain += ["|", (company_f, "=", False), (company_f, "=", company_id.id)]
if measure.measure_type == "count":
res = considered_model.search_count(domain)
elif measure.measure_field_name:
if measure.measure_type == "sum":
res = sum(considered_model.search(domain).mapped(measure.measure_field_name))
elif measure.measure_type == "average":
all_records = considered_model.search(domain)
res = sum(all_records.mapped(measure.measure_field_name)) / len(all_records)
except Exception as e:
res = """{}
{}""".format(res, e)
return res

View File

@ -0,0 +1,391 @@
#coding: utf-8
import base64
import logging
import tempfile
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
import xlsxwriter
except ImportError:
_logger.warning("Cannot import xlsxwriter")
xlsxwriter = False
class kpi_period(models.Model):
"""
The model to distinguish KPIs objects by periods
"""
_name = "kpi.period"
_inherit = "kpi.help"
_description = "KPI Period"
@api.depends("date_start", "date_end")
def _compute_period_length(self):
"""
Compute method for period_length
"""
for period in self:
period_length = 0
if period.date_end and period.date_start:
period_length = (period.date_end - period.date_start).days + 1
period.period_length = period_length
@api.depends("date_start", "date_end")
def _compute_period_passed(self):
"""
Compute method for period_passed
"""
today = fields.Date.today()
for period in self:
period_passed = 1
if period.date_end and period.date_start:
if period.date_end <= today:
period_passed = period.period_length
elif period.date_start < today:
period_passed = (today - period.date_start).days + 1
period.period_passed = period_passed
def _compute_parent_id(self):
"""
Compute method for parent_id (e.g. for montly it would be quartely if exists or annually)
"""
for period in self:
parent_period = False
if period.date_end and period.date_start:
parent_period = self.search([
("date_start", "!=", False),
("date_end", "!=", False),
("id", "!=", period.id),
("date_start", "<=", period.date_start),
("date_end", ">=", period.date_end),
], limit=1, order="period_length ASC, id")
period.parent_id = parent_period
def _inverse_template_id(self):
"""
Inverse method for template_id
"""
for period in self:
if period.template_id:
line_vals = []
for line in period.template_id.line_ids:
if line.kpi_id.active:
line_vals.append((0, 0, {
"kpi_id": line.kpi_id.id,
"target_value": line.target_value,
}))
if line_vals:
period.line_ids = line_vals
period.template_id = False
name = fields.Char(
string="Reference",
required=True,
translate=True,
)
date_start = fields.Date(
"Period Start",
required=True,
default=lambda self: fields.Date.today().strftime('%Y-01-01'),
)
date_end = fields.Date(
"Period End",
required=True,
default=lambda self: fields.Date.today().strftime('%Y-12-31'),
)
line_ids = fields.One2many(
"kpi.scorecard.line",
"period_id",
string="KPI Targets",
copy=True,
)
period_length = fields.Float(
string="Period Days",
compute=_compute_period_length,
store=True,
)
period_passed = fields.Float(
string="Days Passed",
compute=_compute_period_passed,
store=False,
)
parent_id = fields.Many2one(
"kpi.period",
string="Parent Period",
compute=_compute_parent_id,
)
state = fields.Selection(
[
("open", "Opened"),
("closed", "Closed"),
],
string="State",
default="open",
)
template_id = fields.Many2one(
"kpi.period",
string="Copy targets from",
inverse=_inverse_template_id,
)
company_id = fields.Many2one(
"res.company",
string="Company",
required=True,
default=lambda self: self.env.user.company_id,
)
last_recalculation_date = fields.Datetime(
string="Last KPI Calculation",
default=lambda self: fields.Datetime.now(),
)
_order = "date_start DESC, period_length, id"
_sql_constraints = [
(
'dates_check',
'check (date_end>=date_start)',
_('Period end should be after period start')
),
]
def name_get(self):
"""
Overloading the method to include period start and end ino the names
"""
result = []
lang = self._context.get("lang")
lang_date_format = "%m/%d/%Y"
if lang:
record_lang = self.env['res.lang'].search([("code", "=", lang)], limit=1)
lang_date_format = record_lang.date_format
for period in self:
date_start = period.date_start.strftime(lang_date_format)
date_end = period.date_end.strftime(lang_date_format)
name = "{} ({} - {})".format(period.name, date_start, date_end)
result.append((period.id, name))
return result
@api.model
def action_return_periods(self):
"""
The method to return available periods
Returns:
* dict
** all_periods - list of tuples
** this_period - int - id
"""
all_periods = self.search([])
today = fields.Date.today()
this_period = False
if all_periods:
this_periods = all_periods.filtered(lambda period: period.date_start <= today and period.date_end >= today)
this_periods = this_periods.sorted("period_length", reverse=False)
this_period = this_periods and this_periods[0].id or all_periods[0].id
return {
"all_periods": all_periods and all_periods.mapped(lambda per: [per.id, per.name_get()[0][1], per.state]) \
or False,
"this_period": this_period,
"kpi_adming_rights": self.env.user.has_group("kpi_scorecard.group_kpi_admin"),
}
def action_calculate_kpis(self):
"""
The method to update KPI values
Methods:
* _calculate_kpis
"""
for period in self:
period._calculate_kpis()
@api.model
def action_cron_calculate_kpi(self):
"""
The method to find all periods for re-calculations
"""
open_periods = self.search([("state", "=", "open")], order="last_recalculation_date ASC, id")
open_periods.action_calculate_kpis()
def action_export_scorecard(self):
"""
The method to prepare the xls table
Methods:
* _get_xls_table of kpi.scorecard.line
Returns:
* action of downloading the xlsx table
Extra info:
* Expected singleton
"""
self.ensure_one()
if not xlsxwriter:
raise UserError(_("The Python library xlsxwriter is installed. Contact your system administrator"))
file_name = u"{}.xlsx".format(self.name_get()[0][1])
file_path = tempfile.mktemp(suffix='.xlsx')
workbook = xlsxwriter.Workbook(file_path)
main_header_style = workbook.add_format({
'bold': True,
'font_size': 11,
'border': 1,
'align': 'center',
'valign': 'vcenter',
'bg_color': 'silver',
'border_color': 'gray',
})
main_cell_style_dict = {
'font_size': 11,
'border': 1,
'border_color': 'gray',
}
worksheet = workbook.add_worksheet(file_name)
column_keys = [
{"key": "A", "label": _("KPI"), "width": 60},
{"key": "B", "label": _("Target"), "width": 14},
{"key": "C", "label": _("Actual"), "width": 14},
{"key": "D", "label": _("Notes"), "width": 80},
]
total_row_number = len(self.line_ids)
cell_values = self.line_ids._get_xls_table()
for ccolumn in column_keys:
ckey = ccolumn.get("key")
# set columns
worksheet.set_column('{c}:{c}'.format(c=ckey), ccolumn.get("width"))
# set header row
worksheet.write("{}1".format(ckey), ccolumn.get("label"), main_header_style)
# set column values
for row_number in range(2, total_row_number+2):
cell_number = "{}{}".format(ckey, row_number)
cell_value_dict = cell_values.get(cell_number)
cell_value = ""
cell_level = 0
cell_style = main_cell_style_dict.copy()
if cell_value_dict:
cell_value = cell_value_dict.get("value")
cell_style.update(cell_value_dict.get("style"))
cell_level = cell_value_dict.get("level") or 0
cell_style = workbook.add_format(cell_style)
if ckey == "A":
cell_style.set_indent(cell_level)
worksheet.write(
cell_number,
cell_value,
cell_style,
)
worksheet.set_row(0, 24)
workbook.close()
with open(file_path, 'rb') as r:
xls_file = base64.b64encode(r.read())
att_vals = {
'name': file_name,
'type': 'binary',
'datas': xls_file,
}
attachment_id = self.env['ir.attachment'].create(att_vals)
self.env.cr.commit()
action = {
'type': 'ir.actions.act_url',
'url': '/web/content/{}?download=true'.format(attachment_id.id,),
'target': 'self',
}
return action
def action_close(self):
"""
The method to close periods
"""
for period in self:
if period.state != "closed":
period.state = "closed"
def action_reopen(self):
"""
The method to close periods
"""
for period in self:
if period.state != "open":
period.state = "open"
def _calculate_kpis(self):
"""
The method to calculate KPIs for this period
1. Firstly calculate all variables to avoid duplicated calculations
2. Calculate all involved kpi.items
3. Get actual value from calculated dict and save it to line
Methods:
* _get_measures of kpi.item
* _calculate_for_period of kpi.measure.item
* _get_value_by_period of kpi.constant
* _calculate_by_measure of kpi.item
Extra info:
* Expected singleton
"""
self.ensure_one()
self = self.sudo()
if self.state == "open":
if self.line_ids:
# 1
all_variables_dict = {}
all_kpis = self.line_ids.mapped("kpi_id")
all_measures, all_constants, all_other_kpis = all_kpis._get_measures()
all_kpis += all_other_kpis
for measure in all_measures:
value = measure._calculate_for_period(self)
all_variables_dict.update({"MEASURE({})".format(measure.id): value})
for cosntant in all_constants:
value = cosntant._get_value_by_period(self)
all_variables_dict.update({"CONST({})".format(cosntant.id): value})
all_variables_dict.update({
"PERIOD_len": self.period_length,
"PERIOD_passed": self.period_passed,
})
# 2
lines_iterator = 0
while all_kpis:
this_kpi = all_kpis[lines_iterator]
kpi_id_reference = "KPI({})".format(this_kpi.id)
if not all_variables_dict.get(kpi_id_reference):
other_kpis = this_kpi.kpi_ids
for other_kpi in other_kpis:
other_value = all_variables_dict.get("KPI({})".format(other_kpi.id))
if other_value is None:
break
else:
# we have all data to process formula
all_kpis -= this_kpi
actual_value = this_kpi._calculate_by_measure(all_variables_dict)
all_variables_dict.update({kpi_id_reference: actual_value})
else:
# seems excess to be here; but left for sudden cases
all_kpis -= this_kpi
all_variables_dict.update({kpi_id_reference: all_variables_dict.get(kpi_id_reference)})
lines_iterator += 1
if lines_iterator >= len(all_kpis):
# start from missed factors
lines_iterator = 0
# 3
for line in self.line_ids:
kpi_id_reference = "KPI({})".format(line.kpi_id.id)
actual_value = all_variables_dict.get(kpi_id_reference)
if isinstance(actual_value, str):
# it is the error
line.write({
"computation_error": actual_value,
"actual_value": 0,
})
else:
line.write({
"computation_error": False,
"actual_value": actual_value,
})
self.last_recalculation_date = fields.Datetime.now()
self._cr.commit()

View File

@ -0,0 +1,34 @@
#coding: utf-8
from odoo import _, fields, models
class kpi_period_value(models.Model):
"""
The model to distinguish KPIs constant values by periods
"""
_name = "kpi.period.value"
_description = "KPI Period Value"
_rec_name = "period_id"
period_id = fields.Many2one(
"kpi.period",
string="Period",
required=True,
ondelete="cascade",
)
constant_id = fields.Many2one(
"kpi.constant",
string="KPI Constant",
ondelete="cascade",
)
target_value = fields.Float(
string="Target Value",
)
_sql_constraints = [
(
'period_constant_id_uniq',
'unique(period_id,constant_id)',
_('Period should be unique per each constant!'),
)
]

View File

@ -0,0 +1,341 @@
#coding: utf-8
from odoo import _, api, fields, models
class kpi_scorecard_line(models.Model):
"""
KPI target
"""
_name = "kpi.scorecard.line"
_inherit = "kpi.help"
_description = "KPI Target"
_rec_name = "kpi_id"
@api.depends("target_value", "actual_value", "computation_error", "kpi_id", "kpi_id.result_appearance",
"kpi_id.result_preffix", "kpi_id.result_suffix", "kpi_id.result_type")
def _compute_formatted_actual_value(self):
"""
Compute method for formatted_actual_value, result, formatted_target_value
Methods:
* _return_formated_appearance of kpi.item
"""
for line in self:
kpi_id = line.kpi_id
company_currency = line.period_id.company_id.currency_id
if line.computation_error:
line.formatted_actual_value = _("N/A")
line.result = "error"
line.formatted_target_value = kpi_id._return_formated_appearance(
line.target_value, novalue_change=True, currency=company_currency,
)
else:
actual_value = line.actual_value
line.formatted_actual_value = kpi_id._return_formated_appearance(
line.actual_value, novalue_change=False, currency=company_currency,
)
line.formatted_target_value = kpi_id._return_formated_appearance(
line.target_value, novalue_change=True, currency=company_currency,
)
result_type = kpi_id.result_type
actual_value = line.kpi_id.result_appearance == "percentage" and (actual_value * 100) or actual_value
bigger_result = actual_value >= line.target_value
if result_type == "more":
line.result = bigger_result and "success" or "failure"
elif result_type == "less":
line.result = bigger_result and "failure" or "success"
@api.depends("period_id", "period_id.line_ids", "period_id.line_ids.kpi_id", "period_id.line_ids.kpi_id.sequence",
"period_id.line_ids.kpi_id.all_parent_ids")
def _compute_sequence(self):
"""
The method to re-compute sequence, parent_id for lines
"""
periods = self.mapped("period_id")
for period in periods:
res_hierarchy = period.line_ids.action_get_hierarchy()
cur_sequence = 0
for line_dict in res_hierarchy:
line = line_dict.get("line")
line.sequence = cur_sequence
parent = line_dict.get("parent")
line.all_parents = parent \
and "{}{}".format(
parent.id,
parent.all_parents and "{}".format("," + parent.all_parents) or ""
) \
or False
cur_sequence += 1
@api.depends("kpi_id", "kpi_id.edit_access_user_ids")
def _compute_edit_rights(self):
"""
Compute method for edit_rights
"""
active_user = self.env.user
admin_rights = active_user.has_group("kpi_scorecard.group_kpi_admin")
for line in self:
line.edit_rights = admin_rights or active_user in line.kpi_id.edit_access_user_ids or False
kpi_id = fields.Many2one(
"kpi.item",
string="KPI",
required=True,
)
category_id = fields.Many2one(
"kpi.category",
related="kpi_id.category_id",
store=True,
)
description = fields.Text(
related="kpi_id.description",
store=True,
)
period_id = fields.Many2one(
"kpi.period",
string="Period",
ondelete="cascade",
)
target_value = fields.Float(string="Target Value")
formatted_target_value = fields.Char(
string="Target",
compute=_compute_formatted_actual_value,
compute_sudo=True,
store=True,
)
actual_value = fields.Float(string="Actual Value")
computation_error = fields.Char(string="Logs")
formatted_actual_value = fields.Char(
string="Actual",
compute=_compute_formatted_actual_value,
compute_sudo=True,
store=True,
)
result = fields.Selection(
[
("success", "Success"),
("failure", "Failure"),
("error", "Error"),
],
compute=_compute_formatted_actual_value,
compute_sudo=True,
store=True,
)
sequence = fields.Integer(
string="Sequence",
compute=_compute_sequence,
compute_sudo=True,
store=True,
)
all_parents = fields.Char(
string="All Parents",
compute=_compute_sequence,
compute_sudo=True,
store=True,
)
edit_rights = fields.Boolean(
"Active User Editor",
compute=_compute_edit_rights,
)
company_id = fields.Many2one(
related="period_id.company_id",
store=True,
compute_sudo=True,
related_sudo=True,
)
period_length = fields.Float(
related="period_id.period_length",
store=True,
compute_sudo=True,
related_sudo=True,
)
_order = "sequence, id"
_sql_constraints = [
(
'period_kpi_uniq',
'unique(period_id, kpi_id)',
_('Target for this KPI is already set for this period'),
)
]
def name_get(self):
"""
Overloading the method to make a name, since it doesn't have own
"""
result = []
for line in self:
name = "{} ({})".format(line.kpi_id.name, line.period_id.name_get()[0][1])
result.append((line.id, name))
return result
def action_get_hierarchy(self):
"""
The method to get hirarchy of KPI targets
Methods:
* _get_levels_recursively
Returns:
* list of dict
"""
kpi_ids = self.mapped("kpi_id")
result = []
if kpi_ids:
kpi_ids = kpi_ids.sorted("sequence")
kpi_lines_dict = {line.kpi_id.id: line for line in self}
parent_kpis = kpi_ids.filtered(lambda kpi: not kpi.parent_id or kpi.parent_id.id not in kpi_ids.ids)
for parent_kpi in parent_kpis:
line_id = kpi_lines_dict.get(parent_kpi.id)
result += line_id._get_levels_recursively(parent_kpi, kpi_ids.ids, kpi_lines_dict, False)
return result
def action_get_history(self):
"""
The method to find targets for the same KPI, same company and similar periods
Returns:
* dict :
** all_similar_targets - list of dicts to show in the table with formatted values
** similar_targets - list of dicts for graph
Extra info:
* Expected singleton
"""
self.ensure_one()
company_id = self.company_id
all_similar_targets = self.search([
("kpi_id", "=", self.kpi_id.id),
("company_id", "=", company_id.id),
], order="period_id")
all_similar_targets = all_similar_targets.mapped(lambda target: {
"period_id": target.period_id.name_get()[0][1],
"formatted_target_value": target.formatted_target_value,
"formatted_actual_value": target.formatted_actual_value,
"result": target.result,
})
lang = self._context.get("lang")
lang_date_format = "%m/%d/%Y"
if lang:
record_lang = self.env['res.lang'].search([("code", "=", lang)], limit=1)
lang_date_format = record_lang.date_format
similar_targets = self.search([
("kpi_id", "=", self.kpi_id.id),
("company_id", "=", company_id.id),
("period_length", ">=", self.period_length - company_id.kpi_history_tolerance),
("period_length", "<=", self.period_length + company_id.kpi_history_tolerance),
], order="period_id DESC")
similar_targets = similar_targets.mapped(lambda target: {
"date": target.period_id.date_end.strftime(lang_date_format),
"value": not target.computation_error and target.kpi_id._return_rounded_value(target.actual_value, False) \
or 0,
})
return {
"all_similar_targets": all_similar_targets,
"similar_targets": similar_targets,
"kpi_name": self.kpi_id.name,
}
def _get_levels_recursively(self, parent_kpi, all_kpis, kpi_lines_dict, parent_line=False,):
"""
The recursion method to get child kpi one by one
Args:
* parent_kpi - kpi.item record
* all_kpis - list of ints
* kpi_lines_dict - dict of relations kpi.scorecard.line (id - int) - kpi.item (id - int)
* parent_line - id of parent
Methods:
* _get_value_dict
Returns:
* list of dicts
Extra info:
* Expected singleton
"""
self.ensure_one()
result = [self._get_value_dict(all_kpis, parent_line)]
child_ids = self.kpi_id.child_ids
for child_kpi in child_ids:
child_kpi_id = child_kpi.id
if child_kpi_id in all_kpis:
line_id = kpi_lines_dict.get(child_kpi_id)
result += line_id._get_levels_recursively(child_kpi, all_kpis, kpi_lines_dict, self)
return result
def _get_value_dict(self, all_kpis, parent_line=False):
"""
The method to prepare values dict of js
Args:
* all_kpis - list of ints
* parent_line - kpi.scorecard.line -parent
Returns:
* list of dict
** line - kpi.scorecard.line
** parent - int of parent if exist, False otherwise
Extra info:
* Expected singleton
"""
self.ensure_one()
return {
"line": self,
"parent": parent_line,
}
def _get_xls_table(self):
"""
The method to prepare dict of values for xls row
Args:
* spaces - str - to add at the beginning of the name
Methods:
* _return_xls_formatting - of kpi.item
Returns:
* dict
"""
result = {}
row = 2
previous_kpis = {}
for line in self:
parent_id = line.kpi_id.parent_id.id
level = previous_kpis.get(parent_id) is not None and previous_kpis.get(parent_id) + 1 or 0
previous_kpis.update({line.kpi_id.id: level})
description = line.description or ""
target_value = line.target_value
actual_value = line.actual_value
overall_style = {
"color": line.result == "success" and "black" or line.result == "failure" and "red" or "orange"
}
if line.computation_error:
target_value = 0
actual_value = 0
description = "{} {}".format(line.computation_error, description)
overall_style.update({"color": "orange"})
target_value = line.kpi_id._return_xls_formatting(line.target_value, False)
actual_value = line.kpi_id._return_xls_formatting(line.actual_value, True)
num_style = overall_style.copy()
num_style.update({
"align": "center",
})
if line.kpi_id.result_appearance == "percentage":
num_style.update({"num_format": 10}),
overall_style.update({
"valign": "vjustify",
})
result.update({
"A{}".format(row): {"value": line.kpi_id.name, "style": overall_style, "level": level},
"B{}".format(row): {"value": target_value, "style": num_style},
"C{}".format(row): {"value": actual_value, "style": num_style},
"D{}".format(row): {"value": description, "style": overall_style},
})
row += 1
return result

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class res_company(models.Model):
"""
Overwrite to keep settings in company
"""
_inherit = "res.company"
kpi_history_tolerance = fields.Integer(
string="History Tolerance",
default=3,
)
show_kpi_help = fields.Boolean(
string="Show Help Tabs",
default=True,
)

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from odoo import _, fields, models
class res_config_settings(models.TransientModel):
"""
The model to keep settings of business appointments on website
"""
_inherit = "res.config.settings"
kpi_company_id = fields.Many2one(
"res.company",
string="Company for KPI settings",
default=lambda self: self.env.user.company_id,
)
kpi_history_tolerance = fields.Integer(
related="kpi_company_id.kpi_history_tolerance",
readonly=False,
)
show_kpi_help = fields.Boolean(
related="kpi_company_id.show_kpi_help",
readonly=False,
)
def action_open_kpi_cron(self):
"""
The method to open ir.cron of kpi update
Returns:
* action dict
Extra info:
* Expected singleton
"""
self.ensure_one()
cron_id = self.sudo().env.ref("kpi_scorecard.cron_recalculate_kpi_periods", False)
if cron_id:
return {
"res_id": cron_id.id,
"name": _("Job: Calculate KPIs"),
"type": "ir.actions.act_window",
"res_model": "ir.cron",
"view_mode": "form",
"target": "new",
}

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,17 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_kpi_measure,access_kpi_measure,model_kpi_measure,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_measure_admin,access_kpi_measure_admin,model_kpi_measure,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_constant,access_kpi_constant,model_kpi_constant,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_constant_admin,access_kpi_constant_admin,model_kpi_constant,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_period_value,access_kpi_period_value,model_kpi_period_value,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_period_value_admin,access_kpi_period_value_admin,model_kpi_period_value,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_measure_item,access_kpi_measure_item,model_kpi_measure_item,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_measure_item_admin,access_kpi_measure_item_admin,model_kpi_measure_item,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_category,access_kpi_category,model_kpi_category,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_category_admin,access_kpi_category_admin,model_kpi_category,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_item,access_kpi_item,model_kpi_item,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_item_admin,access_kpi_item_admin,model_kpi_item,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_period,access_kpi_period,model_kpi_period,kpi_scorecard.group_kpi_user,1,0,0,0
access_kpi_period_admin,access_kpi_period_admin,model_kpi_period,kpi_scorecard.group_kpi_admin,1,1,1,1
access_kpi_scorecard_line,access_kpi_scorecard_line,model_kpi_scorecard_line,kpi_scorecard.group_kpi_user,1,1,1,1
access_kpi_copy_template,access_kpi_copy_template,model_kpi_copy_template,kpi_scorecard.group_kpi_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_kpi_measure access_kpi_measure model_kpi_measure kpi_scorecard.group_kpi_user 1 0 0 0
3 access_kpi_measure_admin access_kpi_measure_admin model_kpi_measure kpi_scorecard.group_kpi_admin 1 1 1 1
4 access_kpi_constant access_kpi_constant model_kpi_constant kpi_scorecard.group_kpi_user 1 0 0 0
5 access_kpi_constant_admin access_kpi_constant_admin model_kpi_constant kpi_scorecard.group_kpi_admin 1 1 1 1
6 access_kpi_period_value access_kpi_period_value model_kpi_period_value kpi_scorecard.group_kpi_user 1 0 0 0
7 access_kpi_period_value_admin access_kpi_period_value_admin model_kpi_period_value kpi_scorecard.group_kpi_admin 1 1 1 1
8 access_kpi_measure_item access_kpi_measure_item model_kpi_measure_item kpi_scorecard.group_kpi_user 1 0 0 0
9 access_kpi_measure_item_admin access_kpi_measure_item_admin model_kpi_measure_item kpi_scorecard.group_kpi_admin 1 1 1 1
10 access_kpi_category access_kpi_category model_kpi_category kpi_scorecard.group_kpi_user 1 0 0 0
11 access_kpi_category_admin access_kpi_category_admin model_kpi_category kpi_scorecard.group_kpi_admin 1 1 1 1
12 access_kpi_item access_kpi_item model_kpi_item kpi_scorecard.group_kpi_user 1 0 0 0
13 access_kpi_item_admin access_kpi_item_admin model_kpi_item kpi_scorecard.group_kpi_admin 1 1 1 1
14 access_kpi_period access_kpi_period model_kpi_period kpi_scorecard.group_kpi_user 1 0 0 0
15 access_kpi_period_admin access_kpi_period_admin model_kpi_period kpi_scorecard.group_kpi_admin 1 1 1 1
16 access_kpi_scorecard_line access_kpi_scorecard_line model_kpi_scorecard_line kpi_scorecard.group_kpi_user 1 1 1 1
17 access_kpi_copy_template access_kpi_copy_template model_kpi_copy_template kpi_scorecard.group_kpi_admin 1 1 1 1

View File

@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_kpi" model="ir.module.category">
<field name="name">KPI Management</field>
<field name="sequence">30</field>
</record>
<record id="group_kpi_user" model="res.groups">
<field name="name">KPI User</field>
<field name="category_id" ref="kpi_scorecard.module_category_kpi"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<record id="group_kpi_admin" model="res.groups">
<field name="name">KPI Manager</field>
<field name="category_id" ref="kpi_scorecard.module_category_kpi"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
<data noupdate="0">
<!-- KPI Constants -->
<record id="kpi_constant_multi_company_rule" model="ir.rule">
<field name="name">KPI Constants: Multi Company</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_constant"/>
<field name="domain_force">[
'|',
('company_id','=', False),
('company_id', 'in', company_ids),
]</field>
</record>
<!-- KPI Measurements -->
<record id="kpi_measure_item_multi_company_rule" model="ir.rule">
<field name="name">KPI Measurements (Variables): Multi Company</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_measure_item"/>
<field name="domain_force">[
'|',
('company_id','=', False),
('company_id', 'in', company_ids),
]</field>
</record>
<!-- KPI Periods -->
<record id="kpi_period_multi_company_rule" model="ir.rule">
<field name="name">KPI Periods: Multi Company</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_period"/>
<field name="domain_force">[
'|',
('company_id','=', False),
('company_id', 'in', company_ids),
]</field>
</record>
<!-- KPI Categories -->
<record id="kpi_category_multi_company_rule" model="ir.rule">
<field name="name">All users - all KPI Categories of own company</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_category"/>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="domain_force">[
'|',
("access_user_ids", "in", [user.id]),
("edit_access_user_ids", "in", [user.id]),
'|',
('company_id','=', False),
('company_id', 'in', company_ids),
]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="kpi_category_admin_multi_company_rule" model="ir.rule">
<field name="name">KPI Admin - all KPI Categories of own company</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_category"/>
<field name="domain_force">[
'|',
('company_id','=', False),
('company_id', 'in', company_ids),
]</field>
<field name="groups" eval="[(4, ref('kpi_scorecard.group_kpi_admin'))]"/>
</record>
<!-- KPIs -->
<record id="kpi_item_multi_company_rule" model="ir.rule">
<field name="name">All users: KPIs ow own companies</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_item"/>
<field name="domain_force">[
'|',
("access_user_ids", "in", [user.id]),
("edit_access_user_ids", "in", [user.id]),
'|',
('company_id','=', user.company_id.id),
('company_id','=', False),
]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="kpi_item__admin_multi_company_rule" model="ir.rule">
<field name="name">KPIs: Multi Company</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_item"/>
<field name="domain_force">[
'|',
('company_id','=', False),
('company_id', 'in', company_ids),
]</field>
<field name="groups" eval="[(4, ref('kpi_scorecard.group_kpi_admin'))]"/>
</record>
<!-- KPI Targets -->
<record id="kpi_scorecard_line_multi_company_rule" model="ir.rule">
<field name="name">All users - Read targets of own companies related to them / their groups</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_scorecard_line"/>
<field name="domain_force">[
'|',
("kpi_id.access_user_ids", "in", [user.id]),
("kpi_id.edit_access_user_ids", "in", [user.id]),
'|',
('period_id.company_id', '=', False),
('period_id.company_id', '=', user.company_id.id),
]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="kpi_scorecard_line_edit_multi_company_rule" model="ir.rule">
<field name="name">All users - Edit targets of own companies related to them / their groups</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_scorecard_line"/>
<field name="domain_force">[
("kpi_id.edit_access_user_ids", "in", [user.id]),
'|',
('period_id.company_id', '=', False),
('period_id.company_id', 'in', company_ids),
]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="kpi_scorecard_line_admin_multi_company_rule" model="ir.rule">
<field name="name">KPI Admin - any KPI Targets of own companies</field>
<field name="model_id" ref="kpi_scorecard.model_kpi_scorecard_line"/>
<field name="domain_force">[
'|',
('period_id.company_id', '=', False),
('period_id.company_id', 'in', company_ids),
]</field>
<field name="groups" eval="[(4, ref('kpi_scorecard.group_kpi_admin'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</data>
</odoo>

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