diff --git a/odex30_base/advanced_web_domain_widget/__init__.py b/odex30_base/advanced_web_domain_widget/__init__.py new file mode 100755 index 0000000..9a7e03e --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/odex30_base/advanced_web_domain_widget/__manifest__.py b/odex30_base/advanced_web_domain_widget/__manifest__.py new file mode 100755 index 0000000..d9018ed --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/__manifest__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +################################################################################# +# Author : Terabits Technolab () +# 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, +} diff --git a/odex30_base/advanced_web_domain_widget/controllers/__init__.py b/odex30_base/advanced_web_domain_widget/controllers/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/odex30_base/advanced_web_domain_widget/models/__init__.py b/odex30_base/advanced_web_domain_widget/models/__init__.py new file mode 100755 index 0000000..aac9e9b --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/models/__init__.py @@ -0,0 +1,2 @@ +from . import domain_prepare +from . import models \ No newline at end of file diff --git a/odex30_base/advanced_web_domain_widget/models/domain_prepare.py b/odex30_base/advanced_web_domain_widget/models/domain_prepare.py new file mode 100755 index 0000000..b97e50e --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/models/domain_prepare.py @@ -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 + + + + diff --git a/odex30_base/advanced_web_domain_widget/models/models.py b/odex30_base/advanced_web_domain_widget/models/models.py new file mode 100755 index 0000000..8c5e4e1 --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/models/models.py @@ -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 []) \ No newline at end of file diff --git a/odex30_base/advanced_web_domain_widget/security/ir.model.access.csv b/odex30_base/advanced_web_domain_widget/security/ir.model.access.csv new file mode 100755 index 0000000..97dd8b9 --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/odex30_base/advanced_web_domain_widget/static/description/banner.png b/odex30_base/advanced_web_domain_widget/static/description/banner.png new file mode 100644 index 0000000..7068ad3 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/banner.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/icon.png b/odex30_base/advanced_web_domain_widget/static/description/icon.png new file mode 100644 index 0000000..6e2feaa Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/icon.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/configs/config-1.png b/odex30_base/advanced_web_domain_widget/static/description/img/configs/config-1.png new file mode 100644 index 0000000..d4025b3 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/configs/config-1.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/configs/config-2.png b/odex30_base/advanced_web_domain_widget/static/description/img/configs/config-2.png new file mode 100644 index 0000000..9e0405a Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/configs/config-2.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature1.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature1.png new file mode 100644 index 0000000..9bcafee Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature1.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature2.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature2.png new file mode 100644 index 0000000..f432950 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature2.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature3.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature3.png new file mode 100644 index 0000000..26f9a86 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature3.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature4.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature4.png new file mode 100644 index 0000000..2abe49d Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature4.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature5.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature5.png new file mode 100644 index 0000000..148d973 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature5.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature6.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature6.png new file mode 100644 index 0000000..ae03782 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature6.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature7.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature7.png new file mode 100644 index 0000000..c5cbde2 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature7.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/feature8.png b/odex30_base/advanced_web_domain_widget/static/description/img/feature8.png new file mode 100644 index 0000000..8d2b2fb Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/feature8.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss4.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss4.png new file mode 100644 index 0000000..9963418 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss4.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss5.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss5.png new file mode 100644 index 0000000..0cef7e6 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss5.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss6.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss6.png new file mode 100644 index 0000000..103827c Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss6.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss7.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss7.png new file mode 100644 index 0000000..e89f9e6 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/date_filter_ss7.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_01.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_01.png new file mode 100644 index 0000000..96c33d4 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_01.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_02.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_02.png new file mode 100644 index 0000000..27965a9 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_02.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_03.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_03.png new file mode 100644 index 0000000..c577a15 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_bg_img_03.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_header_img.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_header_img.png new file mode 100644 index 0000000..ee14470 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/domain_header_img.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/icon_img.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/icon_img.png new file mode 100644 index 0000000..500ffc3 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/icon_img.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss1.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss1.png new file mode 100644 index 0000000..1c694a1 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss1.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss2.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss2.png new file mode 100644 index 0000000..3bbfe4d Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss2.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss3.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss3.png new file mode 100644 index 0000000..242c622 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss3.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss4.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss4.png new file mode 100644 index 0000000..aa9ccc1 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss4.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss5.png b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss5.png new file mode 100644 index 0000000..6dda9f8 Binary files /dev/null and b/odex30_base/advanced_web_domain_widget/static/description/img/screens/ss5.png differ diff --git a/odex30_base/advanced_web_domain_widget/static/description/index.html b/odex30_base/advanced_web_domain_widget/static/description/index.html new file mode 100644 index 0000000..8b7a51d --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/static/description/index.html @@ -0,0 +1,545 @@ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + +
+
+ + +
+
+
+ +
+ +
+

+ Advanced web domian widget +

+

+ "Now use the feature of select any models record in domain while using any relational field." +

+

+ Odoo base domain widget allows you to only match value or id while user wants to create
+ domain using any relational fields. So, user confused when model has multiple record's id
+ and he/she does't remembered. So, we have simplified that by showing models record to
+ the user. so, he/she can select by finding record and select it. our module will autometic
+ adds ids of selected records in domain. To select related model's record and create
+ domain, we allowed additional two domain operators ('in', 'not in'). +

+
+ +
+
+
+
+ + + + + +
+
+ + +
+
+
+

+ Features +

+
+ +
+
+
+ Select any models records +
+
+

+ Easy to create domain of relational fields by
+ selecting any models record in domain. We
+ provide additional operators ('in' and 'not in')
+ to create relational fields domain. +

+
+
+ +
+
+ Autometic id add in domain +
+
+

+ When user select models records from popup,
+ there will generate tags of record's names and
+ add records id in domain. +

+
+
+ +
+
+
+
+
+ + + + +
+
+
+ +
+ +
+ +
+
+

You just need to change in xml + files to use our advanced domain feature. +

+
+
+ +
+
+
  • + Replace name of odoo's 'domain' widget to 'terabits_domain' + widget. + +
  • +
    +
    +
    + Odoo's domain widget +
    +
    + Terabits's domain widget +
    +
    + + +
    + + + +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    +

    + Here is odoo's 'domain' widget for domain creation. +

    +
    +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +

    + Here is customized 'terabits_domain' widget for domain creation. +

    +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +

    + Here is customized 'date and filter' widget for domain creation. +

    +
    +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +

    + Here is how you can select environment company and user. +

    +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    +

    + 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. +

    +
    +
    +
    +
    + +
    +
    + +
    +
    +

    + 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. +

    +
    +
    +
    +
    + +
    +
    + +
    +
    +

    + Please drop an email at info@terabits.xyz or raise a + ticket through the Odoo store itself. + +

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    + Yes, I do provide free support for 90 days for any + queries or any bug/issue fixing. + +

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    + 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. +

    +
    +
    +
    +
    + +
    +
    +
    + + +
    + +
    +
    +

    Changelog(s)

    +
    + +

    + v14.0.1.0.2 + - + December 10, 2022 +

    +

    + Minor bug fix. +

    + +

    + v14.0.0.0.0 + - + November 23, 2022 +

    +

    + Initial release for v14 +

    +
    +
    + +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    + +
    +
    +

    + Need a help for this module? +

    +

    + Contact me + info@terabits.xyz + for your queries +

    +
    +
    + + +
    +
    \ No newline at end of file diff --git a/odex30_base/advanced_web_domain_widget/static/src/components/domain_selector/domain_selector.js b/odex30_base/advanced_web_domain_widget/static/src/components/domain_selector/domain_selector.js new file mode 100644 index 0000000..b106cb8 --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/static/src/components/domain_selector/domain_selector.js @@ -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); \ No newline at end of file diff --git a/odex30_base/advanced_web_domain_widget/static/src/scss/style.scss b/odex30_base/advanced_web_domain_widget/static/src/scss/style.scss new file mode 100755 index 0000000..a7188c2 --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/static/src/scss/style.scss @@ -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; + } +} \ No newline at end of file diff --git a/odex30_base/advanced_web_domain_widget/static/src/xml/domain_templates.xml b/odex30_base/advanced_web_domain_widget/static/src/xml/domain_templates.xml new file mode 100644 index 0000000..100b179 --- /dev/null +++ b/odex30_base/advanced_web_domain_widget/static/src/xml/domain_templates.xml @@ -0,0 +1,95 @@ + + + +
    +
    + + +
    +
    +
    + + + +
    + +
    +
    + Loading... +
    +
    Loading records...
    +
    +
    + +
    +
    +
    Available Records
    + +
    + +
    + + No records found matching your search. + + + No records available. + +
    +
    + + + + + + + +
    +
    +
    +
    Domain Preview
    +
    +                                
    +                            
    +
    +
    + Selected Records: + + + +
    + +
    + + Selected IDs: + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    \ No newline at end of file diff --git a/odex30_base/droggol_dblclick_edit/COPYRIGHT b/odex30_base/droggol_dblclick_edit/COPYRIGHT new file mode 100644 index 0000000..29dedfc --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/COPYRIGHT @@ -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. + diff --git a/odex30_base/droggol_dblclick_edit/LICENSE b/odex30_base/droggol_dblclick_edit/LICENSE new file mode 100644 index 0000000..7a5c68e --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/LICENSE @@ -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. \ No newline at end of file diff --git a/odex30_base/droggol_dblclick_edit/__init__.py b/odex30_base/droggol_dblclick_edit/__init__.py new file mode 100644 index 0000000..11615c6 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Droggol. See LICENSE file for full copyright and licensing details. diff --git a/odex30_base/droggol_dblclick_edit/__manifest__.py b/odex30_base/droggol_dblclick_edit/__manifest__.py new file mode 100644 index 0000000..0799c26 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/__manifest__.py @@ -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, +} diff --git a/odex30_base/droggol_dblclick_edit/static/description/icon.png b/odex30_base/droggol_dblclick_edit/static/description/icon.png new file mode 100644 index 0000000..7376f8e Binary files /dev/null and b/odex30_base/droggol_dblclick_edit/static/description/icon.png differ diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/banner.png b/odex30_base/droggol_dblclick_edit/static/description/images/banner.png new file mode 100644 index 0000000..b651f04 Binary files /dev/null and b/odex30_base/droggol_dblclick_edit/static/description/images/banner.png differ diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/bg.svg b/odex30_base/droggol_dblclick_edit/static/description/images/bg.svg new file mode 100644 index 0000000..63bb3d0 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/cloud.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/cloud.svg new file mode 100644 index 0000000..0e3e29f --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/cloud.svg @@ -0,0 +1,62 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/code.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/code.svg new file mode 100644 index 0000000..c297b08 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/code.svg @@ -0,0 +1,67 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/coffee.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/coffee.svg new file mode 100644 index 0000000..cdc67ea --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/coffee.svg @@ -0,0 +1,62 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/hat.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/hat.svg new file mode 100644 index 0000000..587b117 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/hat.svg @@ -0,0 +1,57 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/highlight.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/highlight.svg new file mode 100644 index 0000000..1c212ca --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/highlight.svg @@ -0,0 +1,57 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/layers.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/layers.svg new file mode 100644 index 0000000..245eb10 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/layers.svg @@ -0,0 +1,67 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/pulse.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/pulse.svg new file mode 100644 index 0000000..e5709e1 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/pulse.svg @@ -0,0 +1,67 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/store.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/store.svg new file mode 100644 index 0000000..cd20ed3 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/store.svg @@ -0,0 +1,77 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/cards/tablet.svg b/odex30_base/droggol_dblclick_edit/static/description/images/cards/tablet.svg new file mode 100644 index 0000000..e0c4d5b --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/cards/tablet.svg @@ -0,0 +1,67 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/lock.svg b/odex30_base/droggol_dblclick_edit/static/description/images/lock.svg new file mode 100644 index 0000000..1362b18 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/lock.svg @@ -0,0 +1,57 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/logo.svg b/odex30_base/droggol_dblclick_edit/static/description/images/logo.svg new file mode 100644 index 0000000..26fa193 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/logo.svg @@ -0,0 +1,131 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/screen_shot.png b/odex30_base/droggol_dblclick_edit/static/description/images/screen_shot.png new file mode 100644 index 0000000..de5f4c9 Binary files /dev/null and b/odex30_base/droggol_dblclick_edit/static/description/images/screen_shot.png differ diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/select.svg b/odex30_base/droggol_dblclick_edit/static/description/images/select.svg new file mode 100644 index 0000000..9e146c3 --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/images/select.svg @@ -0,0 +1,62 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/odex30_base/droggol_dblclick_edit/static/description/images/youtube cover.png b/odex30_base/droggol_dblclick_edit/static/description/images/youtube cover.png new file mode 100644 index 0000000..06f8fe8 Binary files /dev/null and b/odex30_base/droggol_dblclick_edit/static/description/images/youtube cover.png differ diff --git a/odex30_base/droggol_dblclick_edit/static/description/index.html b/odex30_base/droggol_dblclick_edit/static/description/index.html new file mode 100644 index 0000000..d3fc9ff --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/description/index.html @@ -0,0 +1,313 @@ +
    + + + + + + +
    +
    +

    + Double click + to edit/save form view. +

    +

    + This module provides the feature to switch between edit/save mode form view just with double click. +

    +
    +
    +
    + +
    +
    +
    +
    Simple to use
    +

    Just double click on empty area of from view to edit/save the + form view.

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    Based On Access Rules
    +

    + This module does not allow to switch into editable mode if user don't have access rights. +

    +
    +
    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    How to use it?

    +

    + 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. +

    +
    + +
    + + + +
    + +
    + + + +
    +
    + + Our Services + +

    Checkout Our Odoo Services

    +

    Great variety of Odoo services that help you to uplift your awesome business.

    +
    + + +
    +
    +
    +
    + +
    +

    Development

    +

    + Customize Odoo modules, integrate with external services, migration from the old version and more. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Implementation +

    +

    + We understand your business needs and setup Odoo in the right way to fulfil your business needs. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Mobile Apps +

    +

    + Android, iOS or Hybrid mobile applications which are fully integrated with Odoo instances. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Website & Themes +

    +

    + Build eye-catching, blazing fast, configurable Odoo website with beautiful custom themes. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Support +

    +

    + Odoo functional or technical support, bug-fixes, debugging, performance optimization, and more. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + POS & IoT +

    +

    + Customize Odoo POS for your retail shops and restaurants. Integrate it with hardware devices to + boost your productivity. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Deployment +

    +

    + Deploy or optimize your Odoo instances on the cloud, Odoo.sh or on any other platform with top-notch + security. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Training +

    +

    + Learn to develop or customize Odoo backend, website, JavaScript framework, sysadmin, themes and + more. +

    + + Read more + +
    +
    +
    +
    +
    +
    +
    + +
    +

    + Hire Developers +

    +

    + Hire full-stack Odoo developer to develop, maintain or configure Odoo applications on a regular + basis. +

    + + Read more + +
    +
    +
    + +
    + + + +
    +
    +

    Let's have a talk.

    +

    Don't be a stranger. Our team is happy to answer all your questions.

    +
    + +
    + + +
    \ No newline at end of file diff --git a/odex30_base/droggol_dblclick_edit/static/src/js/form_controller.js b/odex30_base/droggol_dblclick_edit/static/src/js/form_controller.js new file mode 100644 index 0000000..a29d96e --- /dev/null +++ b/odex30_base/droggol_dblclick_edit/static/src/js/form_controller.js @@ -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(); + } + } + } +}); \ No newline at end of file diff --git a/odex30_base/dynamic_reject_workflow/__init__.py b/odex30_base/dynamic_reject_workflow/__init__.py new file mode 100644 index 0000000..408a600 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import wizard diff --git a/odex30_base/dynamic_reject_workflow/__manifest__.py b/odex30_base/dynamic_reject_workflow/__manifest__.py new file mode 100644 index 0000000..0af0dab --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/__manifest__.py @@ -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', + ], + }, +} diff --git a/odex30_base/dynamic_reject_workflow/description/icon.png b/odex30_base/dynamic_reject_workflow/description/icon.png new file mode 100644 index 0000000..4141f52 Binary files /dev/null and b/odex30_base/dynamic_reject_workflow/description/icon.png differ diff --git a/odex30_base/dynamic_reject_workflow/i18n/ar_001.po b/odex30_base/dynamic_reject_workflow/i18n/ar_001.po new file mode 100644 index 0000000..e39a4ed --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/i18n/ar_001.po @@ -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 "" diff --git a/odex30_base/dynamic_reject_workflow/models/__init__.py b/odex30_base/dynamic_reject_workflow/models/__init__.py new file mode 100644 index 0000000..9fadf8b --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import reject_workflow diff --git a/odex30_base/dynamic_reject_workflow/models/reject_workflow.py b/odex30_base/dynamic_reject_workflow/models/reject_workflow.py new file mode 100644 index 0000000..63da9e6 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/models/reject_workflow.py @@ -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 diff --git a/odex30_base/dynamic_reject_workflow/security/groups.xml b/odex30_base/dynamic_reject_workflow/security/groups.xml new file mode 100644 index 0000000..3bf0b90 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/security/groups.xml @@ -0,0 +1,10 @@ + + + + + + Reject Workflow Manager + + + + \ No newline at end of file diff --git a/odex30_base/dynamic_reject_workflow/security/ir.model.access.csv b/odex30_base/dynamic_reject_workflow/security/ir.model.access.csv new file mode 100644 index 0000000..b1ea188 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/security/ir.model.access.csv @@ -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 + diff --git a/odex30_base/dynamic_reject_workflow/static/src/js/form.js b/odex30_base/dynamic_reject_workflow/static/src/js/form.js new file mode 100644 index 0000000..e95ca98 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/static/src/js/form.js @@ -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; + } + } + } +}); \ No newline at end of file diff --git a/odex30_base/dynamic_reject_workflow/views/reject_buttons.xml b/odex30_base/dynamic_reject_workflow/views/reject_buttons.xml new file mode 100644 index 0000000..fb76be1 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/views/reject_buttons.xml @@ -0,0 +1,19 @@ + + + + + reject.button.wizard.form + reject.button.wizard + +
    + +
    +
    + +
    +
    +
    +
    \ No newline at end of file diff --git a/odex30_base/dynamic_reject_workflow/views/reject_workflow.xml b/odex30_base/dynamic_reject_workflow/views/reject_workflow.xml new file mode 100644 index 0000000..ae7a06a --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/views/reject_workflow.xml @@ -0,0 +1,49 @@ + + + + + reject.workflow.form + reject.workflow + +
    + +
    +
    + + + + + +
    +
    +
    +
    + + reject.workflow.tree + reject.workflow + + + + + + + + + Reject Workflow + ir.actions.act_window + reject.workflow + tree,form + + + + +
    +
    \ No newline at end of file diff --git a/odex30_base/dynamic_reject_workflow/wizard/__init__.py b/odex30_base/dynamic_reject_workflow/wizard/__init__.py new file mode 100644 index 0000000..c5e94fb --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/wizard/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import reject_wizard diff --git a/odex30_base/dynamic_reject_workflow/wizard/reject_wizard.py b/odex30_base/dynamic_reject_workflow/wizard/reject_wizard.py new file mode 100644 index 0000000..a5ca882 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/wizard/reject_wizard.py @@ -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 diff --git a/odex30_base/dynamic_reject_workflow/wizard/reject_wizard.xml b/odex30_base/dynamic_reject_workflow/wizard/reject_wizard.xml new file mode 100644 index 0000000..c5f1d80 --- /dev/null +++ b/odex30_base/dynamic_reject_workflow/wizard/reject_wizard.xml @@ -0,0 +1,21 @@ + + + + + Reason Wizard + all.reject.wizard + +
    + + + +
    +
    +
    +
    +
    + +
    +
    diff --git a/odex30_base/kpi_scorecard/LICENSE b/odex30_base/kpi_scorecard/LICENSE new file mode 100644 index 0000000..b01eaf1 --- /dev/null +++ b/odex30_base/kpi_scorecard/LICENSE @@ -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. diff --git a/odex30_base/kpi_scorecard/__init__.py b/odex30_base/kpi_scorecard/__init__.py new file mode 100644 index 0000000..3466e95 --- /dev/null +++ b/odex30_base/kpi_scorecard/__init__.py @@ -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 diff --git a/odex30_base/kpi_scorecard/__manifest__.py b/odex30_base/kpi_scorecard/__manifest__.py new file mode 100644 index 0000000..aaad198 --- /dev/null +++ b/odex30_base/kpi_scorecard/__manifest__.py @@ -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", +} \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/controllers/__init__.py b/odex30_base/kpi_scorecard/controllers/__init__.py new file mode 100644 index 0000000..7c68785 --- /dev/null +++ b/odex30_base/kpi_scorecard/controllers/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/data/crm_measures.xml b/odex30_base/kpi_scorecard/data/crm_measures.xml new file mode 100644 index 0000000..af611a4 --- /dev/null +++ b/odex30_base/kpi_scorecard/data/crm_measures.xml @@ -0,0 +1,120 @@ + + + + + [crm] Leads and Opportunities: Count + count + crm.lead + create_date + company_id + + + [crm] Opportunities: Count + count + crm.lead + create_date + [("type", "=", "opportunity")] + company_id + + + [crm] Leads: Count + count + crm.lead + create_date + [("type", "=", "lead")] + company_id + + + [crm] Won Opportunities: Count + count + crm.lead + date_closed + [("stage_id.is_won", "=", "True")] + company_id + + + [crm] Opportunities: Average Days to Assign + average + crm.lead + create_date + day_open + [] + company_id + + + [crm] Opportunities: Average Days to Close + average + crm.lead + create_date + day_close + [] + company_id + + + [crm] Opportunities: Average Expected Revenue + average + crm.lead + create_date + expected_revenue + [] + company_id + + + [crm] Opportunities: Total Expected Revenue + sum + crm.lead + create_date + expected_revenue + [] + company_id + + + [crm] Opportunities: Total Sale Orders + sum + crm.lead + create_date + sale_amount_total + [] + company_id + + + [crm] Opportunities: Average of Sale Orders + average + crm.lead + create_date + sale_amount_total + [] + company_id + + + diff --git a/odex30_base/kpi_scorecard/data/cron.xml b/odex30_base/kpi_scorecard/data/cron.xml new file mode 100644 index 0000000..353b002 --- /dev/null +++ b/odex30_base/kpi_scorecard/data/cron.xml @@ -0,0 +1,16 @@ + + + + + Calculate KPIs + + code + model.action_cron_calculate_kpi() + + + 8 + hours + -1 + + + diff --git a/odex30_base/kpi_scorecard/data/data.xml b/odex30_base/kpi_scorecard/data/data.xml new file mode 100644 index 0000000..2cf289c --- /dev/null +++ b/odex30_base/kpi_scorecard/data/data.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/odex30_base/kpi_scorecard/data/invoice_measures.xml b/odex30_base/kpi_scorecard/data/invoice_measures.xml new file mode 100644 index 0000000..160f648 --- /dev/null +++ b/odex30_base/kpi_scorecard/data/invoice_measures.xml @@ -0,0 +1,54 @@ + + + + + + [account] Posted Customer Invoices: Count + count + account.move + date + company_id + [("move_type", "=", "out_invoice"), ("state", "=", "posted")] + + + [account] Posted Vendor Bills: Count + count + account.move + date + company_id + [("move_type", "=", "in_invoice"), ("state", "=", "posted")] + + + [account] Posted Customer Invoices: Total + sum + account.invoice.report + invoice_date + price_subtotal + company_id + [("move_type", "=", "out_invoice"), ("state", "=", "posted")] + + + [account] Posted Customer Invoices: Total + sum + account.invoice.report + invoice_date + price_subtotal + company_id + [("move_type", "=", "in_invoice"), ("state", "=", "posted")] + + + + + \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/data/project_measures.xml b/odex30_base/kpi_scorecard/data/project_measures.xml new file mode 100644 index 0000000..f892cf3 --- /dev/null +++ b/odex30_base/kpi_scorecard/data/project_measures.xml @@ -0,0 +1,62 @@ + + + + + + [project] Tasks: Count + count + project.task + create_date + company_id + + + [project] Tasks: Total Planned Hours + sum + project.task + create_date + planned_hours + company_id + + + [project] Tasks: Average Planned Hours + average + project.task + create_date + planned_hours + company_id + + + [project] Tasks: Working Days to Close + sum + report.project.task.user + date_assign + working_days_close + company_id + + + [project] Tasks: Working Days to Assign + sum + report.project.task.user + date_assign + working_days_open + company_id + + + + + \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/data/sale_measures.xml b/odex30_base/kpi_scorecard/data/sale_measures.xml new file mode 100644 index 0000000..e389b73 --- /dev/null +++ b/odex30_base/kpi_scorecard/data/sale_measures.xml @@ -0,0 +1,88 @@ + + + + + [sales] All Sale Orders and Quotations: Count + count + sale.order + date_order + company_id + [] + + + [sales] Сonfirmed/Done Sale Orders: Count + count + sale.order + date_order + company_id + ["|", ("state","=","sale"), ("state","=","done")] + + + [sales] Сonfirmed/Done Sale Orders: Total + sum + sale.report + date + price_total + company_id + ["|", ("state","=","sale"), ("state","=","done")] + + + [sales]Сonfirmed/Done Sale Orders: Product Units + sum + sale.report + date + product_uom_qty + company_id + ["|", ("state","=","sale"), ("state","=","done")] + + + [sales] Сonfirmed/Done Sale Orders: Invoiced Units + sum + sale.report + date + qty_invoiced + company_id + ["|", ("state","=","sale"), ("state","=","done")] + + + [sales] Сonfirmed/Done Sale Orders: Delivered Units + sum + sale.report + date + qty_delivered + company_id + ["|", ("state","=","sale"), ("state","=","done")] + + + [sales] Сonfirmed/Done Sale Orders: Number of Lines + sum + sale.report + date + nbr + company_id + ["|", ("state","=","sale"), ("state","=","done")] + + + + diff --git a/odex30_base/kpi_scorecard/hooks.py b/odex30_base/kpi_scorecard/hooks.py new file mode 100644 index 0000000..e282119 --- /dev/null +++ b/odex30_base/kpi_scorecard/hooks.py @@ -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 diff --git a/odex30_base/kpi_scorecard/models/__init__.py b/odex30_base/kpi_scorecard/models/__init__.py new file mode 100644 index 0000000..a5bd57c --- /dev/null +++ b/odex30_base/kpi_scorecard/models/__init__.py @@ -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 diff --git a/odex30_base/kpi_scorecard/models/kpi_category.py b/odex30_base/kpi_scorecard/models/kpi_category.py new file mode 100644 index 0000000..18cd591 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_category.py @@ -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 diff --git a/odex30_base/kpi_scorecard/models/kpi_constant.py b/odex30_base/kpi_scorecard/models/kpi_constant.py new file mode 100644 index 0000000..89a1685 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_constant.py @@ -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 diff --git a/odex30_base/kpi_scorecard/models/kpi_help.py b/odex30_base/kpi_scorecard/models/kpi_help.py new file mode 100644 index 0000000..09878a1 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_help.py @@ -0,0 +1,257 @@ +#coding: utf-8 + +from odoo import _, api, fields, models + +help_dict = { + "kpi.period": _(""" +
    +

    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).

    +

    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.

    +

    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.

    +

    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).

    +

    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).

    +

    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).

    +

    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.

    +
    + """), + "kpi.item": _(""" +
    +

    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.

    +

    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.

    +

    KPIs, KPI periods, and KPI targets

    +

    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.

    +

    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.

    +

    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.

    +

    Formula parts

    +

    As variables for formula you may use:

    +

    Measurements (KPI measurements, KPI variables) are figures calculated from actual +Odoo data for a checked period. For example, 'number of quotations of the sales team Europe'.

    +

    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'.

    +

    Constants (KPI constants) 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'.

    +

    Other KPIs 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'.

    +

    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:

    +
      +
    • "-" - subtraction;
    • +
    • "+" - addition;
    • +
    • "*" - multiplication;
    • +
    • "/" - division;
    • +
    • "(", ")" - to make proper calculation order as it is in Math;
    • +
    • "**" - exponentiation (**2 – squaring; **0.5 – square root extraction);
    • +
    • Float number.
    • +
    +

    Result appearance

    +

    Depending of business logic of KPI formula, the final calculation result might have +different form:

    +
      +
    • Simple number: for example, 'Average sales count per week';
    • +
    • Percentage: for example, 'Sales to opportunities success ratio';
    • +
    • Monetary: for example, 'Total Sales per period'. Make sure the measurement field you used is in the same +currency (usually company default currency)!
    • +
    +

    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%"). For monetary result type it is recommended +to define a currency, which symbol would be added to result.

    +

    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).

    +

    Categories and hierarchy

    +

    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.

    +

    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.

    +

    Additive access to KPIs and targets

    +

    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!).

    +

    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.

    +

    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.

    +

    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.

    +
    + """), + "kpi.measure.item": _(""" +
    +

    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'.

    +

    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').

    +

    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.

    +

    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).

    +
    + """), + "kpi.constant": _(""" +
    +

    +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.

    +

    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.

    +

    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.

    +
    + """), + "kpi.measure": _(""" +
    +

    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).

    +

    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.

    +

    Calculation Types

    +

    Basic measurements assume a few types of low-level calculations:

    +
      +
    • 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'.
    • +
    • 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'.
    • +
    • 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'.
    • +
    • 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).
    • +
    +

    Basic Measurement Settings

    +

    The first 3 calculation types assume that you define how records should be searched and +which records fields should be used for computations.

    +
      +
    • Model 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').
    • +
    • Date fields 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.
    • +
    • Filters 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.
    • +
    • Measure field 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.
    • +
    • Company field 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.
    • +
    +

    All settings might relate to your custom objects or custom fields, including ones +created through the interface or the Odoo studio.

    +
    + """), + "kpi.category": _(""" +
    +

    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.

    +

    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').

    +

    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.

    +
    + """), + "kpi.scorecard.line": _(""" +

    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.

    +

    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.

    +"""), + "kpi.copy.template": _(""" +

    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.

    + """), +} + + +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") + diff --git a/odex30_base/kpi_scorecard/models/kpi_item.py b/odex30_base/kpi_scorecard/models/kpi_item.py new file mode 100644 index 0000000..8c04f2d --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_item.py @@ -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 = _("") + +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 + diff --git a/odex30_base/kpi_scorecard/models/kpi_measure.py b/odex30_base/kpi_scorecard/models/kpi_measure.py new file mode 100644 index 0000000..8fa71a8 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_measure.py @@ -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 diff --git a/odex30_base/kpi_scorecard/models/kpi_measure_item.py b/odex30_base/kpi_scorecard/models/kpi_measure_item.py new file mode 100644 index 0000000..b5b9b12 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_measure_item.py @@ -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 \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/models/kpi_period.py b/odex30_base/kpi_scorecard/models/kpi_period.py new file mode 100644 index 0000000..8054b81 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_period.py @@ -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() diff --git a/odex30_base/kpi_scorecard/models/kpi_period_value.py b/odex30_base/kpi_scorecard/models/kpi_period_value.py new file mode 100644 index 0000000..831aedb --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_period_value.py @@ -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!'), + ) + ] diff --git a/odex30_base/kpi_scorecard/models/kpi_scorecard_line.py b/odex30_base/kpi_scorecard/models/kpi_scorecard_line.py new file mode 100644 index 0000000..ad9701b --- /dev/null +++ b/odex30_base/kpi_scorecard/models/kpi_scorecard_line.py @@ -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 \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/models/res_company.py b/odex30_base/kpi_scorecard/models/res_company.py new file mode 100644 index 0000000..00dd9ec --- /dev/null +++ b/odex30_base/kpi_scorecard/models/res_company.py @@ -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, + ) diff --git a/odex30_base/kpi_scorecard/models/res_config_settings.py b/odex30_base/kpi_scorecard/models/res_config_settings.py new file mode 100644 index 0000000..a4cdcf9 --- /dev/null +++ b/odex30_base/kpi_scorecard/models/res_config_settings.py @@ -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", + } + + diff --git a/odex30_base/kpi_scorecard/reports/__init__.py b/odex30_base/kpi_scorecard/reports/__init__.py new file mode 100644 index 0000000..7c68785 --- /dev/null +++ b/odex30_base/kpi_scorecard/reports/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/security/ir.model.access.csv b/odex30_base/kpi_scorecard/security/ir.model.access.csv new file mode 100644 index 0000000..3b8ad9b --- /dev/null +++ b/odex30_base/kpi_scorecard/security/ir.model.access.csv @@ -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 \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/security/security.xml b/odex30_base/kpi_scorecard/security/security.xml new file mode 100644 index 0000000..c5e4bd5 --- /dev/null +++ b/odex30_base/kpi_scorecard/security/security.xml @@ -0,0 +1,152 @@ + + + + + KPI Management + 30 + + + + KPI User + + + + + + KPI Manager + + + + + + + + KPI Constants: Multi Company + + [ + '|', + ('company_id','=', False), + ('company_id', 'in', company_ids), + ] + + + + KPI Measurements (Variables): Multi Company + + [ + '|', + ('company_id','=', False), + ('company_id', 'in', company_ids), + ] + + + + KPI Periods: Multi Company + + [ + '|', + ('company_id','=', False), + ('company_id', 'in', company_ids), + ] + + + + All users - all KPI Categories of own company + + + [ + '|', + ("access_user_ids", "in", [user.id]), + ("edit_access_user_ids", "in", [user.id]), + '|', + ('company_id','=', False), + ('company_id', 'in', company_ids), + ] + + + + KPI Admin - all KPI Categories of own company + + [ + '|', + ('company_id','=', False), + ('company_id', 'in', company_ids), + ] + + + + + All users: KPIs ow own companies + + [ + '|', + ("access_user_ids", "in", [user.id]), + ("edit_access_user_ids", "in", [user.id]), + '|', + ('company_id','=', user.company_id.id), + ('company_id','=', False), + ] + + + + KPIs: Multi Company + + [ + '|', + ('company_id','=', False), + ('company_id', 'in', company_ids), + ] + + + + + All users - Read targets of own companies related to them / their groups + + [ + '|', + ("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), + ] + + + + + + + + All users - Edit targets of own companies related to them / their groups + + [ + ("kpi_id.edit_access_user_ids", "in", [user.id]), + '|', + ('period_id.company_id', '=', False), + ('period_id.company_id', 'in', company_ids), + ] + + + + + + + + KPI Admin - any KPI Targets of own companies + + [ + '|', + ('period_id.company_id', '=', False), + ('period_id.company_id', 'in', company_ids), + ] + + + + + + + + + + + diff --git a/odex30_base/kpi_scorecard/static/description/KPI calculation formula parts b/odex30_base/kpi_scorecard/static/description/KPI calculation formula parts new file mode 100644 index 0000000..458596e Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/KPI calculation formula parts differ diff --git a/odex30_base/kpi_scorecard/static/description/KPI targets' history b/odex30_base/kpi_scorecard/static/description/KPI targets' history new file mode 100644 index 0000000..f1beb58 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/KPI targets' history differ diff --git a/odex30_base/kpi_scorecard/static/description/Setting up a new Odoo target b/odex30_base/kpi_scorecard/static/description/Setting up a new Odoo target new file mode 100644 index 0000000..e35a5b0 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/Setting up a new Odoo target differ diff --git a/odex30_base/kpi_scorecard/static/description/Users and User groups for KPI b/odex30_base/kpi_scorecard/static/description/Users and User groups for KPI new file mode 100644 index 0000000..f8da3a3 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/Users and User groups for KPI differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_1.png b/odex30_base/kpi_scorecard/static/description/app_icon_1.png new file mode 100644 index 0000000..d6689b6 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_1.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_2.png b/odex30_base/kpi_scorecard/static/description/app_icon_2.png new file mode 100644 index 0000000..128c0b3 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_2.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_3.png b/odex30_base/kpi_scorecard/static/description/app_icon_3.png new file mode 100644 index 0000000..c7a76f5 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_3.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_4.png b/odex30_base/kpi_scorecard/static/description/app_icon_4.png new file mode 100644 index 0000000..1f25d81 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_4.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_5.png b/odex30_base/kpi_scorecard/static/description/app_icon_5.png new file mode 100644 index 0000000..2dd31c8 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_5.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_6.png b/odex30_base/kpi_scorecard/static/description/app_icon_6.png new file mode 100644 index 0000000..9424ec4 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_6.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_7.png b/odex30_base/kpi_scorecard/static/description/app_icon_7.png new file mode 100644 index 0000000..3d7630b Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_7.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_8.png b/odex30_base/kpi_scorecard/static/description/app_icon_8.png new file mode 100644 index 0000000..a44612a Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_8.png differ diff --git a/odex30_base/kpi_scorecard/static/description/app_icon_9.png b/odex30_base/kpi_scorecard/static/description/app_icon_9.png new file mode 100644 index 0000000..7ba03dd Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/app_icon_9.png differ diff --git a/odex30_base/kpi_scorecard/static/description/icon.png b/odex30_base/kpi_scorecard/static/description/icon.png new file mode 100644 index 0000000..94f3851 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/icon.png differ diff --git a/odex30_base/kpi_scorecard/static/description/index.html b/odex30_base/kpi_scorecard/static/description/index.html new file mode 100644 index 0000000..3d80564 --- /dev/null +++ b/odex30_base/kpi_scorecard/static/description/index.html @@ -0,0 +1,1112 @@ +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    The app introduces KPI dashboard to manage company and personal targets. The tool helps to organize work as a set of periodical goals and to control those goals in any business or functional area.

    +
    +
    +
    + + Documentation: + + https://faotools.com/docs/1/kpi-balanced-scorecard-16 +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Set KPI targets by periods

    +

    Scorecard is not just a dashboard, but it is the tool to set and control measurable and time-constrained targets. Both company and individual

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Use any Odoo data to calculate KPIs

    +

    The app introduces a clear system to get any Odoo storable details: a count of records, a sum or an average of numeric fields, pure Python code

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Drag and Drop interface for KPI formulas

    +

    Construct advanced formulas to calculate derivative KPIs. Complexity of each scorecard is totally up to you!

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Managerial and self control

    +

    Share KPI targets with interested users to organize a transparent dashboard of goals: from global to very specific ones

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Structured hierarchically and by categories

    +

    Consider only those KPIs which are of interest: for example, only sales-related or of a definite user. Hierarchy allows to highlight targets with indicative padding for instant overview of sub-KPIs

    +
    +
    +
    +
    +
    +
    + +
    +
    +

    KPIs are auto and regularly updated

    +

    Actual values are re-calculated by the special Odoo cron job. It is always possible to understand which KPIs require more attention in real time at a first glance

    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Real-time control and historical trends

    +
    +
    +
    +
    +
    +
      +
    • +
      +
      KPIs values are calculated for a specific period according to chosen date fields (e.g. quotations by 'order date')
      +
    • +
    • +
      +
      Plan KPI periods of any duration: annual, quarterly, monthly, weekly, 10-days-long, etc. KPI periods might intersect and might be different for various business areas
      +
    • +
    • +
      +
      By default KPI targets of a current period are shown for an instant overview
      +
    • +
    • +
      +
      For each KPI target you may observe trends by other periods. Define also tolerance to picture graph (e.g 2-days tolerance - for quarterly periods - 90-92 days, 3-days - for months - 28-31 days)
      +
    • +
    +
    +
    +
    +
    +
      +
    • +
      +
      Navigate between various KPI categories and KPI periods in a few clicks from the same Odoo dashboard
      +
    • +
    • +
      +
      Export scorecard to an Excel table if you need
      +
    • +
    • +
      +
      Copy or substitute KPI targets from other periods to simplify goals' setting
      +
    • +
    • +
      +
      Close KPI periods as it is done for fiscal and accounting purposes. It not only holds the Past values, but let speed calculations up
      +
    • +
    • +
      +
      KPI periods and KPI targets are set up for each company individually (multi companies' environment)
      +
    • +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Drag-and-drop formulas for KPIs

    +
    +
    +
    +
    +
    +
      +
    • +
      +
      KPI construction is as simple as it is to write down a mathematical expression: just drag and drop the parts in a right order with correct operators
      +
    • +
    • +
      +
      Odoo would automatically retrieve data from a database according to a defined parts' order for a specified period
      +
    • +
    • +
      +
      Formula might be as complex as you require. Merely make sure the operators (subtraction, addition, multiplication, division, exponentiation, brackets, any float number) comply with basic Math rules.
      +
    • +
    • +
      +
      For each KPI configure prefix and suffix to make results nice looking
      +
    • +
    • +
      +
      Define rounding rules from 0 to 4 decimal points (1 > 1.2 > 1.23 > 1.235 > 1.2346).
      +
    • +
    +
    +
    +
    +
    +
      +
    • +
      +
      KPI variables indicate the rules of how to get data. For example, 'total number of sales orders' or 'number of quotations of sales team Europe'
      +
    • +
    • +
      +
      Other KPIs - the parts used for derivative calculation. For example, 'Opportunity to sales success ratio' based on KPIs 'Sales count' and 'Opportunities count'
      +
    • +
    • +
      +
      KPI constants - the parts which are frozen globally or for a specific time frame. Needed for figures which can't be retrieved from modules and/or which should remain the same during a period
      +
    • +
    • +
      +
      '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'
      +
    • +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Shared KPIs and self-control

    +
    +
    +
    +
    +
    +
      +
    • +
      +
      Full rights for scorecards, all KPIs, targets, and settings belong to users with the right 'KPI Management - KPI manager'
      +
    • +
    • +
      +
      Categories of KPIs and/or individual KPI targets might be shared for specific users or user groups
      +
    • +
    • +
      +
      It is possible to both grant read-only or editor rights
      +
    • +
    • +
      +
      Read-only access for a target let users only observe shared KPIs
      +
    • +
    • +
      +
      The section 'Edit rights' on category or KPI forms would also allow to set and change targets. The latter might be useful in case you ask your manager to manage goals within a department, for example
      +
    • +
    • +
      +
      The security settings are additive - not restrictive. KPI managers would have full rights for all KPIs disregarding the settings, while other users would have rights only to KPIs which settings (or category settings) allow them so
      +
    • +
    • +
      +
      The tool supports multi companies: so, make sure user company correlates with a KPI dashboard company.
      +
    • +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    KPI settings to process Odoo data

    +
    +
    +
    +
    +
    +

    Any storable data

    +
      +
    • +
      +
      Preparing basis for KPIs' calculation is not a simple task. The good news is that such job should be done only once, while results are used forever
      +
    • +
    • +
      +
      KPI Settings assume 3 levels of precision: (1) basic measurements - (2) KPI variables and constants - (3) KPI drag and drop formulas
      +
    • +
    • +
      +
      A basic measurement is the core object used for retrieving actual KPI value from Odoo database, while KPI variables 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
      +
    • +
    • +
      +
      Each basic measurement might have an unlimited number of linked KPI variables. KPI formulas might combine any number of variables, constants, and other KPIs with mathematical operators.
      +
    • +
    +
    +
    +
    +
    +

    Python code measurements

    +
      +
    • +
      +
      'Python code' is the special type of basic measurements. This type requires technical knowledge but let you compute any sort of figures based on any Odoo data without restrictions
      +
    • +
    • +
      +
      Such measurement might be any Python basic script with possibility of SQL queries (through using 'env.cr')
      +
    • +
    • +
      +
      In calculations you may rely upon the special 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 a KPI period under consideration)
      +
    • +
    • +
      +
      Correct Python code assumes saving the value into the special variable 'result'.
      +
    • +
    +
    +
    +
    +
    +

    Basic measurements

    +
      +
    • +
      +
      Basic measurements are not used for formulas, but they define how Odoo data should be calculated: count of records, sum, average, pure Python code. Basic measurements assume a number of settings
      +
    • +
    • +
      +
      Model - an Odoo document type with which data set you work ('Sales Order', 'Task', etc.). Here you can rely on standard or custom objects (including created in the Odoo studio), Odoo reports
      +
    • +
    • +
      +
      Date fields - define whether a document relates to a KPI period. For example, for tasks you might use 'create date'. It is possible to apply a few date fields (e.g. 'Opportunities opened and won in January 2021') or no date fields at all for global calculations
      +
    • +
    • +
      +
      Filters - allow restrict set of records. Used for low-level filtering and then specified in KPI variables
      +
    • +
    • +
      +
      Measure field - is required for calculation types 'Average' and 'Sum'. It defines which figure you use for calculations. For example, 'total amount' of Sales Analysis Report or 'work hours of tasks'
      +
    • +
    • +
      +
      Company field - would be used to restrict calculation for a KPI period target company. Take into account that records without company stated would be used for all companies' KPI calculations.
      +
    • +
    +
    +
    +
    +
    +

    KPI variables and constants

    +
      +
    • +
      +
      The key idea to distinct KPI variables from basic measurements is to simplify data preparation. You define 'total sales' once, and then unlimitedly use it for 'sales Europe', 'sales John Brown', 'website sales', etc.
      +
    • +
    • +
      +
      Specify basic measurements through the Odoo built-in domain constructor, which let filter by any storable attributes. For instance, filter sale orders by state (only 'done'), sales person ('John Brown or Mike Green'), sales team, etc.
      +
    • +
    • +
      +
      KPI constants are the special type of KPI variables used when data can not be retrieved from modules and/or when figures remain the same during the whole period. For example, 'total investments' or 'number of employees' are often frozen for a specific period
      +
    • +
    • +
      +
      In a multi company environment KPI variables are applied globally or for each company. In the former case that variable is available for any company KPI formulas, while in the latter - only for specific one
      +
    • +
    • +
      +
      Basic measurements of the type 'Execute Python code' can't be any more specified, since they do not relate to any records. Such measurements should have a single KPI variable linked.
      +
    • +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    To change the frequency of KPI targets' recalculation:

    +
      +
    1. Turn on the debug mode
    2. +
    3. Go to the menu KPI > Configuration > Settings
    4. +
    5. Find the button 'Configure cron job'
    6. +
    7. Feel free to update the settings 'Execute Every' and 'Next Execution Date'. However, please do not make it too frequent (e.g. once in 5 minutes). Since the job is resource demanding, it require at least a few minutes to be fully and correctly finished.
    8. +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Basic Measurements are not used to create a formula by themselves. Even if you don't want to add any more filters to the Basic Measurement, you should still create a Variable linked to it. Then, this Variable will appear in the list of available ones for creating a formula.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    The app itself does not introduce its own data (with exception of the KPI constants aiming exactly to apply missing data) or integrations. The app calculates and prepares interfaces based on existing Odoo data. For example, you can get the information about the number of sale orders for a particular period from the app Sales.

    +

    The module is universal and can get data from any Odoo module, including account balances. For example, you can calculate the total profit for a month based on the invoices (journal entry).

    +

    You can also get the data from a source outside of Odoo, through connecting it with an Odoo module. For example, if you have data from Google Analytics in your Odoo PostgreSQL, then such data might be used to prepare KPIs.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Measurement (Variable) - is a specification of a basic measurement. The key idea is to simplify data preparation. You define 'Total Sales' once, and then unlimitedly use it for 'Sales Europe', 'Sales John Brown', 'Website Sales', etc by adding extra filters.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    According to the current Odoo Apps Store policies:

    • every module bought for version 12.0 and prior gives you access to all versions up to 12.0.
    • starting from version 13.0, every module version should be purchased separately.
    • disregarding the version, purchasing a tool grants you a right to all updates and bug fixes within a major version.

    Take into account that the faOtools team does not control those policies. For all questions, please contact the Odoo Apps Store representatives directly.

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    The easiest approach is to use the Odoo store built-in workflow:

    +

    1. Open the module's page and click the button Deploy on odoo.sh

    +

    2. After that, you will be redirected to the GitHub page. Login to your account and click 'Create a new repo' or use the existing one. Please, make sure, that your repository is private. It is not permitted to publish the apps under the OPL-1 license. If necessary, create a new repo for your Odoo.sh project

    +

    3. Then, go to odoo.sh and click on the deploy button, submit the decision in the pop-up window and click 'Continue'. The action will trigger the installation process.

    +

    These steps would install the app for your project production branch. If you wanted to deploy the apps for other branches or update the module, you should undertake the following actions:

    +

    1. Upload the source code for the app from the Odoo store

    +

    2. Commit the module to a required GitHub repository. Make sure that none of the app folders/files are ignored (included in the .gitignore of your repo). Repositories are automatically created by odoo.sh, which might add by default some crucial items there (e.g. /lib). You should upload all module directories, subdirectories, and files without exceptions

    +

    3. Deploy a target branch of the odoo.sh project or wait until it is automatically built if your settings assume that.

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    1. Unzip the source code of the purchased tools in one of your Odoo add-ons' directories;

    2. Re-start the Odoo server;

    3. Turn on the developer mode (technical settings);

    4. Update the apps' list (the apps' menu);

    5. Find the app and push the button 'Install';

    6. Follow the guidelines on the app's page if those exist.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Yes, sure. Take into account that Odoo automatically adds all dependencies to a cart. You should exclude previously purchased tools.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    A red/orange warning itself does not influence features of the app. Regretfully, sometimes our modules do not pass standard automatic tests, since the latter assumes behavior which is in conflict with our apps goals. For example, we change price calculation, while standard Odoo module tests compare final price to standard algorithm.

    So, first of all, please check deployed database features. Does everything work correctly?

    If you still assume that warning influences real features, please contact us and forward full installation logs and the full lists of deployed modules (including core and third party ones).

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    No, we distribute the tools only through the official Odoo apps store

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Regretfully, we do not have a technical possibility to provide individual prices.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    No, third party apps can not be used on Odoo Online.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Yes, all modules marked in dependencies are absolutely required for a correct work of our tool. Take into account that price marked on the app page already includes all necessary dependencies.  

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    The price for our modules is set up in euros. The Odoo store converts prices in others currencies according to its internal exchange rate. Thus, the price in US Dollars may change, when exchange rate changes.

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +

    Odoo demonstration databases (live previews)

    +

    For this app, we might provide a free personalized demo database.

    +

    To demand such a database, press the button "Live Preview" on the top of this page or copy the link https://faotools.com/s/90cuiy

    +

    No phone number or credit card is required to contact us: only a short email sign up which does not take more than 30 seconds.

    +

    By your request, we will prepare an individual live preview database, where you would be able to apply any tests and check assumptions for 14 days.

    +
    +
    +

    Bug reporting

    +

    In case you have faced any bugs or inconsistent behavior, do not hesitate to contact us. We guarantee to provide fixes within 60 days after the purchase, while even after this period we are strongly interested to improve our tools.

    +

    To send us a bug report: press the "Website" link on the top of this page and push the button "Bug Report" (the tab Support). Alternatively, copy the link https://faotools.com/s/oo7puz

    +

    No phone number or credit card is required to contact us: only a short email sign up which does not take more than 30 seconds.

    +

    Please include in your request as many details as possible: screenshots, Odoo server logs, a full description of how to reproduce your problem, and so on. Usually, it takes a few business days to prepare a working plan for an issue (if a bug is confirmed) or provide you with guidelines on what should be done (otherwise).

    +
    +
    +

    Public features requests and module ideas (free development)

    +

    We are strongly motivated to improve our tools and would be grateful for any sort of feedback. In case your requirements are of public use and might be efficiently implemented, the team would include those in our to-do list.

    +

    Such a to-do list is processed on a regular basis and does not assume extra fees. Although we cannot promise deadlines and final design, it might be a good way to get desired features without investments and risks.

    +

    To share ideas: press the "Website" link on the top of this page and push the button "Share Features Ideas" (the tab Support). Alternatively, copy the link https://faotools.com/s/umtpqo

    +

    No phone number or credit card is required to contact us: only a short email sign up which does not take more than 30 seconds.

    +
    +
    +

    Questions and misc issues

    +

    Feel free to contact us with any other concerns, doubts, or questions: press the "Website" link on the top of this page and push the button "Raise Question" (the tab Support). Alternatively, copy the link https://faotools.com/s/fxte1r

    +
    +
    +

    About the team

    +

    faOtools (faotools.com, former odootools.com) is the team of developers and business analysts to help you extend Odoo's potential. We have been communicating with end users to whom the software became the main business tool since 2012. As a result, we are proud of dozens of successful Odoo apps developed. We are open for new ideas and challenges to create the best Odoo apps for business needs all over the world.

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    You may like the tools

    +
    +
    +
    +
    +
    +
    + + Cloud Storage Solutions + +
    +
    + + Cloud Storage Solutions + +

    The technical core to synchronize your cloud storage solution with Odoo

    + + + 245 + + +
    +
    +
    +
    +
    +
    + + OneDrive / SharePoint Odoo Integration + +
    +
    + + OneDrive / SharePoint Odoo Integration + +

    The tool to automatically synchronize Odoo attachments with OneDrive files in both ways

    + + + 394 + + +
    +
    +
    +
    +
    +
    + + Google Drive Odoo Integration + +
    +
    + + Google Drive Odoo Integration + +

    The tool to automatically synchronize Odoo attachments with Google Drive files in both ways

    + + + 394 + + +
    +
    +
    +
    +
    +
    + + OwnCloud / NextCloud Odoo Integration + +
    +
    + + OwnCloud / NextCloud Odoo Integration + +

    The tool to automatically synchronize Odoo attachments with OwnCloud / NextCloud files in both ways

    + + + 394 + + +
    +
    +
    +
    +
    +
    + + KnowSystem: Knowledge Base System + +
    +
    + + KnowSystem: Knowledge Base System + +

    The tool to build deep and structured knowledge base for internal and external use. Knowledge System. KMS

    + + + 298 + + + 268 + + +
    +
    +
    +
    +
    +
    + + Universal Appointments and Time Reservations + +
    +
    + + Universal Appointments and Time Reservations + +

    The tool for time-based service management from booking appointment to sale and reviews

    + + + 398 + + +
    +
    +
    +
    +
    +
    + + Password Manager + +
    +
    + + Password Manager + +

    The tool to safely keep passwords in Odoo for shared use

    + + + 198 + + + 178 + + +
    +
    +
    +
    +
    +
    + + Joint Calendar + +
    +
    + + Joint Calendar + +

    The tool to combine different Odoo events in a few configurable super calendars. Shared calendar. Common calendar.

    + + + 78 + + +
    +
    +
    +
    +
    +
    + + Smart Alerts + +
    +
    + + Smart Alerts + +

    The tool to draw users' attention to important document warnings and details

    + + + 48 + + +
    +
    +
    +
    +
    + +
    +
    +
    \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/static/description/kpi_basic_measurement.png b/odex30_base/kpi_scorecard/static/description/kpi_basic_measurement.png new file mode 100644 index 0000000..579638e Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_basic_measurement.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_category_security.png b/odex30_base/kpi_scorecard/static/description/kpi_category_security.png new file mode 100644 index 0000000..aab4b21 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_category_security.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_choose_period.png b/odex30_base/kpi_scorecard/static/description/kpi_choose_period.png new file mode 100644 index 0000000..2fd4f41 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_choose_period.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_chosen_category.png b/odex30_base/kpi_scorecard/static/description/kpi_chosen_category.png new file mode 100644 index 0000000..3438b26 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_chosen_category.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_constant.png b/odex30_base/kpi_scorecard/static/description/kpi_constant.png new file mode 100644 index 0000000..83fc842 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_constant.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_cron_job.png b/odex30_base/kpi_scorecard/static/description/kpi_cron_job.png new file mode 100644 index 0000000..7209b8b Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_cron_job.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_formula_overall.png b/odex30_base/kpi_scorecard/static/description/kpi_formula_overall.png new file mode 100644 index 0000000..458596e Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_formula_overall.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_formula_relative.png b/odex30_base/kpi_scorecard/static/description/kpi_formula_relative.png new file mode 100644 index 0000000..4b63c88 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_formula_relative.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_formula_settings.png b/odex30_base/kpi_scorecard/static/description/kpi_formula_settings.png new file mode 100644 index 0000000..ac05d41 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_formula_settings.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_history.png b/odex30_base/kpi_scorecard/static/description/kpi_history.png new file mode 100644 index 0000000..f1beb58 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_history.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_open_new_period.png b/odex30_base/kpi_scorecard/static/description/kpi_open_new_period.png new file mode 100644 index 0000000..bb0436e Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_open_new_period.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_python_code_measurement.png b/odex30_base/kpi_scorecard/static/description/kpi_python_code_measurement.png new file mode 100644 index 0000000..4b7ec3a Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_python_code_measurement.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_scorecard_xls.png b/odex30_base/kpi_scorecard/static/description/kpi_scorecard_xls.png new file mode 100644 index 0000000..a1de73c Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_scorecard_xls.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_security.png b/odex30_base/kpi_scorecard/static/description/kpi_security.png new file mode 100644 index 0000000..f8da3a3 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_security.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_user.png b/odex30_base/kpi_scorecard/static/description/kpi_user.png new file mode 100644 index 0000000..259bdb5 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_user.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpi_variable.png b/odex30_base/kpi_scorecard/static/description/kpi_variable.png new file mode 100644 index 0000000..d416e98 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpi_variable.png differ diff --git a/odex30_base/kpi_scorecard/static/description/kpis_overall.png b/odex30_base/kpi_scorecard/static/description/kpis_overall.png new file mode 100644 index 0000000..81fa784 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/kpis_overall.png differ diff --git a/odex30_base/kpi_scorecard/static/description/list_of_kpis.png b/odex30_base/kpi_scorecard/static/description/list_of_kpis.png new file mode 100644 index 0000000..d33ae97 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/list_of_kpis.png differ diff --git a/odex30_base/kpi_scorecard/static/description/main.png b/odex30_base/kpi_scorecard/static/description/main.png new file mode 100644 index 0000000..8a7fb58 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/main.png differ diff --git a/odex30_base/kpi_scorecard/static/description/main_nopromo.png b/odex30_base/kpi_scorecard/static/description/main_nopromo.png new file mode 100644 index 0000000..8a7fb58 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/main_nopromo.png differ diff --git a/odex30_base/kpi_scorecard/static/description/odoo_kpi_configuration.png b/odex30_base/kpi_scorecard/static/description/odoo_kpi_configuration.png new file mode 100644 index 0000000..3006ba9 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/odoo_kpi_configuration.png differ diff --git a/odex30_base/kpi_scorecard/static/description/odoo_kpi_copy_targets.png b/odex30_base/kpi_scorecard/static/description/odoo_kpi_copy_targets.png new file mode 100644 index 0000000..2d367bf Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/odoo_kpi_copy_targets.png differ diff --git a/odex30_base/kpi_scorecard/static/description/roadmap.txt b/odex30_base/kpi_scorecard/static/description/roadmap.txt new file mode 100644 index 0000000..df3ea4b --- /dev/null +++ b/odex30_base/kpi_scorecard/static/description/roadmap.txt @@ -0,0 +1,7 @@ +Under consideration +-------------------- +* manual (not compute actual value) for KPI target +* clear formula button +* choose period using select2 with search +* result representation - progress bar +* Block the menu for ordinary users as settings (assign for the menu the group editor) \ No newline at end of file diff --git a/odex30_base/kpi_scorecard/static/description/set_kpi_target.png b/odex30_base/kpi_scorecard/static/description/set_kpi_target.png new file mode 100644 index 0000000..e35a5b0 Binary files /dev/null and b/odex30_base/kpi_scorecard/static/description/set_kpi_target.png differ diff --git a/odex30_base/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js b/odex30_base/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js new file mode 100644 index 0000000..66ddb51 --- /dev/null +++ b/odex30_base/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js @@ -0,0 +1,1522 @@ +/*! + * Draggabilly PACKAGED v2.3.0 + * Make that shiz draggable + * https://draggabilly.desandro.com + * MIT license + */ + +/** + * Bridget makes jQuery widgets + * v2.0.1 + * MIT license + */ + +/* jshint browser: true, strict: true, undef: true, unused: true */ + +( function( window, factory ) { + // universal module definition + /*jshint strict: false */ /* globals define, module, require */ + if ( typeof define == 'function' && define.amd ) { + // AMD + define( 'jquery-bridget/jquery-bridget',[ 'jquery' ], function( jQuery ) { + return factory( window, jQuery ); + }); + } else if ( typeof module == 'object' && module.exports ) { + // CommonJS + module.exports = factory( + window, + require('jquery') + ); + } else { + // browser global + window.jQueryBridget = factory( + window, + window.jQuery + ); + } + +}( window, function factory( window, jQuery ) { +'use strict'; + +// ----- utils ----- // + +var arraySlice = Array.prototype.slice; + +// helper function for logging errors +// $.error breaks jQuery chaining +var console = window.console; +var logError = typeof console == 'undefined' ? function() {} : + function( message ) { + console.error( message ); + }; + +// ----- jQueryBridget ----- // + +function jQueryBridget( namespace, PluginClass, $ ) { + $ = $ || jQuery || window.jQuery; + if ( !$ ) { + return; + } + + // add option method -> $().plugin('option', {...}) + if ( !PluginClass.prototype.option ) { + // option setter + PluginClass.prototype.option = function( opts ) { + // bail out if not an object + if ( !$.isPlainObject( opts ) ){ + return; + } + this.options = $.extend( true, this.options, opts ); + }; + } + + // make jQuery plugin + $.fn[ namespace ] = function( arg0 /*, arg1 */ ) { + if ( typeof arg0 == 'string' ) { + // method call $().plugin( 'methodName', { options } ) + // shift arguments by 1 + var args = arraySlice.call( arguments, 1 ); + return methodCall( this, arg0, args ); + } + // just $().plugin({ options }) + plainCall( this, arg0 ); + return this; + }; + + // $().plugin('methodName') + function methodCall( $elems, methodName, args ) { + var returnValue; + var pluginMethodStr = '$().' + namespace + '("' + methodName + '")'; + + $elems.each( function( i, elem ) { + // get instance + var instance = $.data( elem, namespace ); + if ( !instance ) { + logError( namespace + ' not initialized. Cannot call methods, i.e. ' + + pluginMethodStr ); + return; + } + + var method = instance[ methodName ]; + if ( !method || methodName.charAt(0) == '_' ) { + logError( pluginMethodStr + ' is not a valid method' ); + return; + } + + // apply method, get return value + var value = method.apply( instance, args ); + // set return value if value is returned, use only first value + returnValue = returnValue === undefined ? value : returnValue; + }); + + return returnValue !== undefined ? returnValue : $elems; + } + + function plainCall( $elems, options ) { + $elems.each( function( i, elem ) { + var instance = $.data( elem, namespace ); + if ( instance ) { + // set options & init + instance.option( options ); + instance._init(); + } else { + // initialize new instance + instance = new PluginClass( elem, options ); + $.data( elem, namespace, instance ); + } + }); + } + + updateJQuery( $ ); + +} + +// ----- updateJQuery ----- // + +// set $.bridget for v1 backwards compatibility +function updateJQuery( $ ) { + if ( !$ || ( $ && $.bridget ) ) { + return; + } + $.bridget = jQueryBridget; +} + +updateJQuery( jQuery || window.jQuery ); + +// ----- ----- // + +return jQueryBridget; + +})); + +/*! + * getSize v2.0.2 + * measure size of elements + * MIT license + */ + +/*jshint browser: true, strict: true, undef: true, unused: true */ +/*global define: false, module: false, console: false */ + +( function( window, factory ) { + 'use strict'; + + if ( typeof define == 'function' && define.amd ) { + // AMD + define( 'get-size/get-size',[],function() { + return factory(); + }); + } else if ( typeof module == 'object' && module.exports ) { + // CommonJS + module.exports = factory(); + } else { + // browser global + window.getSize = factory(); + } + +})( window, function factory() { +'use strict'; + +// -------------------------- helpers -------------------------- // + +// get a number from a string, not a percentage +function getStyleSize( value ) { + var num = parseFloat( value ); + // not a percent like '100%', and a number + var isValid = value.indexOf('%') == -1 && !isNaN( num ); + return isValid && num; +} + +function noop() {} + +var logError = typeof console == 'undefined' ? noop : + function( message ) { + console.error( message ); + }; + +// -------------------------- measurements -------------------------- // + +var measurements = [ + 'paddingLeft', + 'paddingRight', + 'paddingTop', + 'paddingBottom', + 'marginLeft', + 'marginRight', + 'marginTop', + 'marginBottom', + 'borderLeftWidth', + 'borderRightWidth', + 'borderTopWidth', + 'borderBottomWidth' +]; + +var measurementsLength = measurements.length; + +function getZeroSize() { + var size = { + width: 0, + height: 0, + innerWidth: 0, + innerHeight: 0, + outerWidth: 0, + outerHeight: 0 + }; + for ( var i=0; i < measurementsLength; i++ ) { + var measurement = measurements[i]; + size[ measurement ] = 0; + } + return size; +} + +// -------------------------- getStyle -------------------------- // + +/** + * getStyle, get style of element, check for Firefox bug + * https://bugzilla.mozilla.org/show_bug.cgi?id=548397 + */ +function getStyle( elem ) { + var style = getComputedStyle( elem ); + if ( !style ) { + logError( 'Style returned ' + style + + '. Are you running this code in a hidden iframe on Firefox? ' + + 'See http://bit.ly/getsizebug1' ); + } + return style; +} + +// -------------------------- setup -------------------------- // + +var isSetup = false; + +var isBoxSizeOuter; + +/** + * setup + * check isBoxSizerOuter + * do on first getSize() rather than on page load for Firefox bug + */ +function setup() { + // setup once + if ( isSetup ) { + return; + } + isSetup = true; + + // -------------------------- box sizing -------------------------- // + + /** + * WebKit measures the outer-width on style.width on border-box elems + * IE & Firefox<29 measures the inner-width + */ + var div = document.createElement('div'); + div.style.width = '200px'; + div.style.padding = '1px 2px 3px 4px'; + div.style.borderStyle = 'solid'; + div.style.borderWidth = '1px 2px 3px 4px'; + div.style.boxSizing = 'border-box'; + + var body = document.body || document.documentElement; + body.appendChild( div ); + var style = getStyle( div ); + + getSize.isBoxSizeOuter = isBoxSizeOuter = getStyleSize( style.width ) == 200; + body.removeChild( div ); + +} + +// -------------------------- getSize -------------------------- // + +function getSize( elem ) { + setup(); + + // use querySeletor if elem is string + if ( typeof elem == 'string' ) { + elem = document.querySelector( elem ); + } + + // do not proceed on non-objects + if ( !elem || typeof elem != 'object' || !elem.nodeType ) { + return; + } + + var style = getStyle( elem ); + + // if hidden, everything is 0 + if ( style.display == 'none' ) { + return getZeroSize(); + } + + var size = {}; + size.width = elem.offsetWidth; + size.height = elem.offsetHeight; + + var isBorderBox = size.isBorderBox = style.boxSizing == 'border-box'; + + // get all measurements + for ( var i=0; i < measurementsLength; i++ ) { + var measurement = measurements[i]; + var value = style[ measurement ]; + var num = parseFloat( value ); + // any 'auto', 'medium' value will be 0 + size[ measurement ] = !isNaN( num ) ? num : 0; + } + + var paddingWidth = size.paddingLeft + size.paddingRight; + var paddingHeight = size.paddingTop + size.paddingBottom; + var marginWidth = size.marginLeft + size.marginRight; + var marginHeight = size.marginTop + size.marginBottom; + var borderWidth = size.borderLeftWidth + size.borderRightWidth; + var borderHeight = size.borderTopWidth + size.borderBottomWidth; + + var isBorderBoxSizeOuter = isBorderBox && isBoxSizeOuter; + + // overwrite width and height if we can get it from style + var styleWidth = getStyleSize( style.width ); + if ( styleWidth !== false ) { + size.width = styleWidth + + // add padding and border unless it's already including it + ( isBorderBoxSizeOuter ? 0 : paddingWidth + borderWidth ); + } + + var styleHeight = getStyleSize( style.height ); + if ( styleHeight !== false ) { + size.height = styleHeight + + // add padding and border unless it's already including it + ( isBorderBoxSizeOuter ? 0 : paddingHeight + borderHeight ); + } + + size.innerWidth = size.width - ( paddingWidth + borderWidth ); + size.innerHeight = size.height - ( paddingHeight + borderHeight ); + + size.outerWidth = size.width + marginWidth; + size.outerHeight = size.height + marginHeight; + + return size; +} + +return getSize; + +}); + +/** + * EvEmitter v1.1.0 + * Lil' event emitter + * MIT License + */ + +/* jshint unused: true, undef: true, strict: true */ + +( function( global, factory ) { + // universal module definition + /* jshint strict: false */ /* globals define, module, window */ + if ( typeof define == 'function' && define.amd ) { + // AMD - RequireJS + define( 'ev-emitter/ev-emitter',factory ); + } else if ( typeof module == 'object' && module.exports ) { + // CommonJS - Browserify, Webpack + module.exports = factory(); + } else { + // Browser globals + global.EvEmitter = factory(); + } + +}( typeof window != 'undefined' ? window : this, function() { + + + +function EvEmitter() {} + +var proto = EvEmitter.prototype; + +proto.on = function( eventName, listener ) { + if ( !eventName || !listener ) { + return; + } + // set events hash + var events = this._events = this._events || {}; + // set listeners array + var listeners = events[ eventName ] = events[ eventName ] || []; + // only add once + if ( listeners.indexOf( listener ) == -1 ) { + listeners.push( listener ); + } + + return this; +}; + +proto.once = function( eventName, listener ) { + if ( !eventName || !listener ) { + return; + } + // add event + this.on( eventName, listener ); + // set once flag + // set onceEvents hash + var onceEvents = this._onceEvents = this._onceEvents || {}; + // set onceListeners object + var onceListeners = onceEvents[ eventName ] = onceEvents[ eventName ] || {}; + // set flag + onceListeners[ listener ] = true; + + return this; +}; + +proto.off = function( eventName, listener ) { + var listeners = this._events && this._events[ eventName ]; + if ( !listeners || !listeners.length ) { + return; + } + var index = listeners.indexOf( listener ); + if ( index != -1 ) { + listeners.splice( index, 1 ); + } + + return this; +}; + +proto.emitEvent = function( eventName, args ) { + var listeners = this._events && this._events[ eventName ]; + if ( !listeners || !listeners.length ) { + return; + } + // copy over to avoid interference if .off() in listener + listeners = listeners.slice(0); + args = args || []; + // once stuff + var onceListeners = this._onceEvents && this._onceEvents[ eventName ]; + + for ( var i=0; i < listeners.length; i++ ) { + var listener = listeners[i] + var isOnce = onceListeners && onceListeners[ listener ]; + if ( isOnce ) { + // remove listener + // remove before trigger to prevent recursion + this.off( eventName, listener ); + // unset once flag + delete onceListeners[ listener ]; + } + // trigger listener + listener.apply( this, args ); + } + + return this; +}; + +proto.allOff = function() { + delete this._events; + delete this._onceEvents; +}; + +return EvEmitter; + +})); + +/*! + * Unipointer v2.3.0 + * base class for doing one thing with pointer event + * MIT license + */ + +/*jshint browser: true, undef: true, unused: true, strict: true */ + +( function( window, factory ) { + // universal module definition + /* jshint strict: false */ /*global define, module, require */ + if ( typeof define == 'function' && define.amd ) { + // AMD + define( 'unipointer/unipointer',[ + 'ev-emitter/ev-emitter' + ], function( EvEmitter ) { + return factory( window, EvEmitter ); + }); + } else if ( typeof module == 'object' && module.exports ) { + // CommonJS + module.exports = factory( + window, + require('ev-emitter') + ); + } else { + // browser global + window.Unipointer = factory( + window, + window.EvEmitter + ); + } + +}( window, function factory( window, EvEmitter ) { + + + +function noop() {} + +function Unipointer() {} + +// inherit EvEmitter +var proto = Unipointer.prototype = Object.create( EvEmitter.prototype ); + +proto.bindStartEvent = function( elem ) { + this._bindStartEvent( elem, true ); +}; + +proto.unbindStartEvent = function( elem ) { + this._bindStartEvent( elem, false ); +}; + +/** + * Add or remove start event + * @param {Boolean} isAdd - remove if falsey + */ +proto._bindStartEvent = function( elem, isAdd ) { + // munge isAdd, default to true + isAdd = isAdd === undefined ? true : isAdd; + var bindMethod = isAdd ? 'addEventListener' : 'removeEventListener'; + + // default to mouse events + var startEvent = 'mousedown'; + if ( window.PointerEvent ) { + // Pointer Events + startEvent = 'pointerdown'; + } else if ( 'ontouchstart' in window ) { + // Touch Events. iOS Safari + startEvent = 'touchstart'; + } + elem[ bindMethod ]( startEvent, this ); +}; + +// trigger handler methods for events +proto.handleEvent = function( event ) { + var method = 'on' + event.type; + if ( this[ method ] ) { + this[ method ]( event ); + } +}; + +// returns the touch that we're keeping track of +proto.getTouch = function( touches ) { + for ( var i=0; i < touches.length; i++ ) { + var touch = touches[i]; + if ( touch.identifier == this.pointerIdentifier ) { + return touch; + } + } +}; + +// ----- start event ----- // + +proto.onmousedown = function( event ) { + // dismiss clicks from right or middle buttons + var button = event.button; + if ( button && ( button !== 0 && button !== 1 ) ) { + return; + } + this._pointerDown( event, event ); +}; + +proto.ontouchstart = function( event ) { + this._pointerDown( event, event.changedTouches[0] ); +}; + +proto.onpointerdown = function( event ) { + this._pointerDown( event, event ); +}; + +/** + * pointer start + * @param {Event} event + * @param {Event or Touch} pointer + */ +proto._pointerDown = function( event, pointer ) { + // dismiss right click and other pointers + // button = 0 is okay, 1-4 not + if ( event.button || this.isPointerDown ) { + return; + } + + this.isPointerDown = true; + // save pointer identifier to match up touch events + this.pointerIdentifier = pointer.pointerId !== undefined ? + // pointerId for pointer events, touch.indentifier for touch events + pointer.pointerId : pointer.identifier; + + this.pointerDown( event, pointer ); +}; + +proto.pointerDown = function( event, pointer ) { + this._bindPostStartEvents( event ); + this.emitEvent( 'pointerDown', [ event, pointer ] ); +}; + +// hash of events to be bound after start event +var postStartEvents = { + mousedown: [ 'mousemove', 'mouseup' ], + touchstart: [ 'touchmove', 'touchend', 'touchcancel' ], + pointerdown: [ 'pointermove', 'pointerup', 'pointercancel' ], +}; + +proto._bindPostStartEvents = function( event ) { + if ( !event ) { + return; + } + // get proper events to match start event + var events = postStartEvents[ event.type ]; + // bind events to node + events.forEach( function( eventName ) { + window.addEventListener( eventName, this ); + }, this ); + // save these arguments + this._boundPointerEvents = events; +}; + +proto._unbindPostStartEvents = function() { + // check for _boundEvents, in case dragEnd triggered twice (old IE8 bug) + if ( !this._boundPointerEvents ) { + return; + } + this._boundPointerEvents.forEach( function( eventName ) { + window.removeEventListener( eventName, this ); + }, this ); + + delete this._boundPointerEvents; +}; + +// ----- move event ----- // + +proto.onmousemove = function( event ) { + this._pointerMove( event, event ); +}; + +proto.onpointermove = function( event ) { + if ( event.pointerId == this.pointerIdentifier ) { + this._pointerMove( event, event ); + } +}; + +proto.ontouchmove = function( event ) { + var touch = this.getTouch( event.changedTouches ); + if ( touch ) { + this._pointerMove( event, touch ); + } +}; + +/** + * pointer move + * @param {Event} event + * @param {Event or Touch} pointer + * @private + */ +proto._pointerMove = function( event, pointer ) { + this.pointerMove( event, pointer ); +}; + +// public +proto.pointerMove = function( event, pointer ) { + this.emitEvent( 'pointerMove', [ event, pointer ] ); +}; + +// ----- end event ----- // + + +proto.onmouseup = function( event ) { + this._pointerUp( event, event ); +}; + +proto.onpointerup = function( event ) { + if ( event.pointerId == this.pointerIdentifier ) { + this._pointerUp( event, event ); + } +}; + +proto.ontouchend = function( event ) { + var touch = this.getTouch( event.changedTouches ); + if ( touch ) { + this._pointerUp( event, touch ); + } +}; + +/** + * pointer up + * @param {Event} event + * @param {Event or Touch} pointer + * @private + */ +proto._pointerUp = function( event, pointer ) { + this._pointerDone(); + this.pointerUp( event, pointer ); +}; + +// public +proto.pointerUp = function( event, pointer ) { + this.emitEvent( 'pointerUp', [ event, pointer ] ); +}; + +// ----- pointer done ----- // + +// triggered on pointer up & pointer cancel +proto._pointerDone = function() { + this._pointerReset(); + this._unbindPostStartEvents(); + this.pointerDone(); +}; + +proto._pointerReset = function() { + // reset properties + this.isPointerDown = false; + delete this.pointerIdentifier; +}; + +proto.pointerDone = noop; + +// ----- pointer cancel ----- // + +proto.onpointercancel = function( event ) { + if ( event.pointerId == this.pointerIdentifier ) { + this._pointerCancel( event, event ); + } +}; + +proto.ontouchcancel = function( event ) { + var touch = this.getTouch( event.changedTouches ); + if ( touch ) { + this._pointerCancel( event, touch ); + } +}; + +/** + * pointer cancel + * @param {Event} event + * @param {Event or Touch} pointer + * @private + */ +proto._pointerCancel = function( event, pointer ) { + this._pointerDone(); + this.pointerCancel( event, pointer ); +}; + +// public +proto.pointerCancel = function( event, pointer ) { + this.emitEvent( 'pointerCancel', [ event, pointer ] ); +}; + +// ----- ----- // + +// utility function for getting x/y coords from event +Unipointer.getPointerPoint = function( pointer ) { + return { + x: pointer.pageX, + y: pointer.pageY + }; +}; + +// ----- ----- // + +return Unipointer; + +})); + +/*! + * Unidragger v2.3.0 + * Draggable base class + * MIT license + */ + +/*jshint browser: true, unused: true, undef: true, strict: true */ + +( function( window, factory ) { + // universal module definition + /*jshint strict: false */ /*globals define, module, require */ + + if ( typeof define == 'function' && define.amd ) { + // AMD + define( 'unidragger/unidragger',[ + 'unipointer/unipointer' + ], function( Unipointer ) { + return factory( window, Unipointer ); + }); + } else if ( typeof module == 'object' && module.exports ) { + // CommonJS + module.exports = factory( + window, + require('unipointer') + ); + } else { + // browser global + window.Unidragger = factory( + window, + window.Unipointer + ); + } + +}( window, function factory( window, Unipointer ) { + + + +// -------------------------- Unidragger -------------------------- // + +function Unidragger() {} + +// inherit Unipointer & EvEmitter +var proto = Unidragger.prototype = Object.create( Unipointer.prototype ); + +// ----- bind start ----- // + +proto.bindHandles = function() { + this._bindHandles( true ); +}; + +proto.unbindHandles = function() { + this._bindHandles( false ); +}; + +/** + * Add or remove start event + * @param {Boolean} isAdd + */ +proto._bindHandles = function( isAdd ) { + // munge isAdd, default to true + isAdd = isAdd === undefined ? true : isAdd; + // bind each handle + var bindMethod = isAdd ? 'addEventListener' : 'removeEventListener'; + var touchAction = isAdd ? this._touchActionValue : ''; + for ( var i=0; i < this.handles.length; i++ ) { + var handle = this.handles[i]; + this._bindStartEvent( handle, isAdd ); + handle[ bindMethod ]( 'click', this ); + // touch-action: none to override browser touch gestures. metafizzy/flickity#540 + if ( window.PointerEvent ) { + handle.style.touchAction = touchAction; + } + } +}; + +// prototype so it can be overwriteable by Flickity +proto._touchActionValue = 'none'; + +// ----- start event ----- // + +/** + * pointer start + * @param {Event} event + * @param {Event or Touch} pointer + */ +proto.pointerDown = function( event, pointer ) { + var isOkay = this.okayPointerDown( event ); + if ( !isOkay ) { + return; + } + // track start event position + this.pointerDownPointer = pointer; + + event.preventDefault(); + this.pointerDownBlur(); + // bind move and end events + this._bindPostStartEvents( event ); + this.emitEvent( 'pointerDown', [ event, pointer ] ); +}; + +// nodes that have text fields +var cursorNodes = { + TEXTAREA: true, + INPUT: true, + SELECT: true, + OPTION: true, +}; + +// input types that do not have text fields +var clickTypes = { + radio: true, + checkbox: true, + button: true, + submit: true, + image: true, + file: true, +}; + +// dismiss inputs with text fields. flickity#403, flickity#404 +proto.okayPointerDown = function( event ) { + var isCursorNode = cursorNodes[ event.target.nodeName ]; + var isClickType = clickTypes[ event.target.type ]; + var isOkay = !isCursorNode || isClickType; + if ( !isOkay ) { + this._pointerReset(); + } + return isOkay; +}; + +// kludge to blur previously focused input +proto.pointerDownBlur = function() { + var focused = document.activeElement; + // do not blur body for IE10, metafizzy/flickity#117 + var canBlur = focused && focused.blur && focused != document.body; + if ( canBlur ) { + focused.blur(); + } +}; + +// ----- move event ----- // + +/** + * drag move + * @param {Event} event + * @param {Event or Touch} pointer + */ +proto.pointerMove = function( event, pointer ) { + var moveVector = this._dragPointerMove( event, pointer ); + this.emitEvent( 'pointerMove', [ event, pointer, moveVector ] ); + this._dragMove( event, pointer, moveVector ); +}; + +// base pointer move logic +proto._dragPointerMove = function( event, pointer ) { + var moveVector = { + x: pointer.pageX - this.pointerDownPointer.pageX, + y: pointer.pageY - this.pointerDownPointer.pageY + }; + // start drag if pointer has moved far enough to start drag + if ( !this.isDragging && this.hasDragStarted( moveVector ) ) { + this._dragStart( event, pointer ); + } + return moveVector; +}; + +// condition if pointer has moved far enough to start drag +proto.hasDragStarted = function( moveVector ) { + return Math.abs( moveVector.x ) > 3 || Math.abs( moveVector.y ) > 3; +}; + +// ----- end event ----- // + +/** + * pointer up + * @param {Event} event + * @param {Event or Touch} pointer + */ +proto.pointerUp = function( event, pointer ) { + this.emitEvent( 'pointerUp', [ event, pointer ] ); + this._dragPointerUp( event, pointer ); +}; + +proto._dragPointerUp = function( event, pointer ) { + if ( this.isDragging ) { + this._dragEnd( event, pointer ); + } else { + // pointer didn't move enough for drag to start + this._staticClick( event, pointer ); + } +}; + +// -------------------------- drag -------------------------- // + +// dragStart +proto._dragStart = function( event, pointer ) { + this.isDragging = true; + // prevent clicks + this.isPreventingClicks = true; + this.dragStart( event, pointer ); +}; + +proto.dragStart = function( event, pointer ) { + this.emitEvent( 'dragStart', [ event, pointer ] ); +}; + +// dragMove +proto._dragMove = function( event, pointer, moveVector ) { + // do not drag if not dragging yet + if ( !this.isDragging ) { + return; + } + + this.dragMove( event, pointer, moveVector ); +}; + +proto.dragMove = function( event, pointer, moveVector ) { + event.preventDefault(); + this.emitEvent( 'dragMove', [ event, pointer, moveVector ] ); +}; + +// dragEnd +proto._dragEnd = function( event, pointer ) { + // set flags + this.isDragging = false; + // re-enable clicking async + setTimeout( function() { + delete this.isPreventingClicks; + }.bind( this ) ); + + this.dragEnd( event, pointer ); +}; + +proto.dragEnd = function( event, pointer ) { + this.emitEvent( 'dragEnd', [ event, pointer ] ); +}; + +// ----- onclick ----- // + +// handle all clicks and prevent clicks when dragging +proto.onclick = function( event ) { + if ( this.isPreventingClicks ) { + event.preventDefault(); + } +}; + +// ----- staticClick ----- // + +// triggered after pointer down & up with no/tiny movement +proto._staticClick = function( event, pointer ) { + // ignore emulated mouse up clicks + if ( this.isIgnoringMouseUp && event.type == 'mouseup' ) { + return; + } + + this.staticClick( event, pointer ); + + // set flag for emulated clicks 300ms after touchend + if ( event.type != 'mouseup' ) { + this.isIgnoringMouseUp = true; + // reset flag after 300ms + setTimeout( function() { + delete this.isIgnoringMouseUp; + }.bind( this ), 400 ); + } +}; + +proto.staticClick = function( event, pointer ) { + this.emitEvent( 'staticClick', [ event, pointer ] ); +}; + +// ----- utils ----- // + +Unidragger.getPointerPoint = Unipointer.getPointerPoint; + +// ----- ----- // + +return Unidragger; + +})); + +/*! + * Draggabilly v2.3.0 + * Make that shiz draggable + * https://draggabilly.desandro.com + * MIT license + */ + +/* jshint browser: true, strict: true, undef: true, unused: true */ + +( function( window, factory ) { + // universal module definition + /* jshint strict: false */ /* globals define */ + if ( typeof define == 'function' && define.amd ) { + // AMD + define( [ + 'get-size/get-size', + 'unidragger/unidragger', + ], + function( getSize, Unidragger ) { + return factory( window, getSize, Unidragger ); + } ); + } else if ( typeof module == 'object' && module.exports ) { + // CommonJS + module.exports = factory( + window, + require('get-size'), + require('unidragger') + ); + } else { + // browser global + window.Draggabilly = factory( + window, + window.getSize, + window.Unidragger + ); + } + +}( window, function factory( window, getSize, Unidragger ) { + +// -------------------------- helpers & variables -------------------------- // + +// extend objects +function extend( a, b ) { + for ( var prop in b ) { + a[ prop ] = b[ prop ]; + } + return a; +} + +function noop() {} + +var jQuery = window.jQuery; + +// -------------------------- -------------------------- // + +function Draggabilly( element, options ) { + // querySelector if string + this.element = typeof element == 'string' ? + document.querySelector( element ) : element; + + if ( jQuery ) { + this.$element = jQuery( this.element ); + } + + // options + this.options = extend( {}, this.constructor.defaults ); + this.option( options ); + + this._create(); +} + +// inherit Unidragger methods +var proto = Draggabilly.prototype = Object.create( Unidragger.prototype ); + +Draggabilly.defaults = { +}; + +/** + * set options + * @param {Object} opts + */ +proto.option = function( opts ) { + extend( this.options, opts ); +}; + +// css position values that don't need to be set +var positionValues = { + relative: true, + absolute: true, + fixed: true, +}; + +proto._create = function() { + // properties + this.position = {}; + this._getPosition(); + + this.startPoint = { x: 0, y: 0 }; + this.dragPoint = { x: 0, y: 0 }; + + this.startPosition = extend( {}, this.position ); + + // set relative positioning + var style = getComputedStyle( this.element ); + if ( !positionValues[ style.position ] ) { + this.element.style.position = 'relative'; + } + + // events, bridge jQuery events from vanilla + this.on( 'pointerMove', this.onPointerMove ); + this.on( 'pointerUp', this.onPointerUp ); + + this.enable(); + this.setHandles(); +}; + +/** + * set this.handles and bind start events to 'em + */ +proto.setHandles = function() { + this.handles = this.options.handle ? + this.element.querySelectorAll( this.options.handle ) : [ this.element ]; + + this.bindHandles(); +}; + +/** + * emits events via EvEmitter and jQuery events + * @param {String} type - name of event + * @param {Event} event - original event + * @param {Array} args - extra arguments + */ +proto.dispatchEvent = function( type, event, args ) { + var emitArgs = [ event ].concat( args ); + this.emitEvent( type, emitArgs ); + this.dispatchJQueryEvent( type, event, args ); +}; + +proto.dispatchJQueryEvent = function( type, event, args ) { + var jquery = window.jQuery; + // trigger jQuery event + if ( !jquery || !this.$element ) { + return; + } + // create jQuery event + /* eslint-disable-next-line new-cap */ + var $event = jquery.Event( event ); + $event.type = type; + this.$element.trigger( $event, args ); +}; + +// -------------------------- position -------------------------- // + +// get x/y position from style +proto._getPosition = function() { + var style = getComputedStyle( this.element ); + var x = this._getPositionCoord( style.left, 'width' ); + var y = this._getPositionCoord( style.top, 'height' ); + // clean up 'auto' or other non-integer values + this.position.x = isNaN( x ) ? 0 : x; + this.position.y = isNaN( y ) ? 0 : y; + + this._addTransformPosition( style ); +}; + +proto._getPositionCoord = function( styleSide, measure ) { + if ( styleSide.indexOf('%') != -1 ) { + // convert percent into pixel for Safari, #75 + var parentSize = getSize( this.element.parentNode ); + // prevent not-in-DOM element throwing bug, #131 + return !parentSize ? 0 : + ( parseFloat( styleSide ) / 100 ) * parentSize[ measure ]; + } + return parseInt( styleSide, 10 ); +}; + +// add transform: translate( x, y ) to position +proto._addTransformPosition = function( style ) { + var transform = style.transform; + // bail out if value is 'none' + if ( transform.indexOf('matrix') !== 0 ) { + return; + } + // split matrix(1, 0, 0, 1, x, y) + var matrixValues = transform.split(','); + // translate X value is in 12th or 4th position + var xIndex = transform.indexOf('matrix3d') === 0 ? 12 : 4; + var translateX = parseInt( matrixValues[ xIndex ], 10 ); + // translate Y value is in 13th or 5th position + var translateY = parseInt( matrixValues[ xIndex + 1 ], 10 ); + this.position.x += translateX; + this.position.y += translateY; +}; + +// -------------------------- events -------------------------- // + +proto.onPointerDown = function( event, pointer ) { + this.element.classList.add('is-pointer-down'); + this.dispatchJQueryEvent( 'pointerDown', event, [ pointer ] ); +}; + +proto.pointerDown = function( event, pointer ) { + var isOkay = this.okayPointerDown( event ); + if ( !isOkay || !this.isEnabled ) { + this._pointerReset(); + return; + } + // track start event position + // Safari 9 overrides pageX and pageY. These values needs to be copied. flickity#842 + this.pointerDownPointer = { + pageX: pointer.pageX, + pageY: pointer.pageY, + }; + + event.preventDefault(); + this.pointerDownBlur(); + // bind move and end events + this._bindPostStartEvents( event ); + this.element.classList.add('is-pointer-down'); + this.dispatchEvent( 'pointerDown', event, [ pointer ] ); +}; + +/** + * drag start + * @param {Event} event + * @param {[Event, Touch]} pointer + */ +proto.dragStart = function( event, pointer ) { + if ( !this.isEnabled ) { + return; + } + this._getPosition(); + this.measureContainment(); + // position _when_ drag began + this.startPosition.x = this.position.x; + this.startPosition.y = this.position.y; + // reset left/top style + this.setLeftTop(); + + this.dragPoint.x = 0; + this.dragPoint.y = 0; + + this.element.classList.add('is-dragging'); + this.dispatchEvent( 'dragStart', event, [ pointer ] ); + // start animation + this.animate(); +}; + +proto.measureContainment = function() { + var container = this.getContainer(); + if ( !container ) { + return; + } + + var elemSize = getSize( this.element ); + var containerSize = getSize( container ); + var elemRect = this.element.getBoundingClientRect(); + var containerRect = container.getBoundingClientRect(); + + var borderSizeX = containerSize.borderLeftWidth + containerSize.borderRightWidth; + var borderSizeY = containerSize.borderTopWidth + containerSize.borderBottomWidth; + + var position = this.relativeStartPosition = { + x: elemRect.left - ( containerRect.left + containerSize.borderLeftWidth ), + y: elemRect.top - ( containerRect.top + containerSize.borderTopWidth ), + }; + + this.containSize = { + width: ( containerSize.width - borderSizeX ) - position.x - elemSize.width, + height: ( containerSize.height - borderSizeY ) - position.y - elemSize.height, + }; +}; + +proto.getContainer = function() { + var containment = this.options.containment; + if ( !containment ) { + return; + } + var isElement = containment instanceof HTMLElement; + // use as element + if ( isElement ) { + return containment; + } + // querySelector if string + if ( typeof containment == 'string' ) { + return document.querySelector( containment ); + } + // fallback to parent element + return this.element.parentNode; +}; + +// ----- move event ----- // + +proto.onPointerMove = function( event, pointer, moveVector ) { + this.dispatchJQueryEvent( 'pointerMove', event, [ pointer, moveVector ] ); +}; + +/** + * drag move + * @param {Event} event + * @param {[Event, Touch]} pointer + * @param {Object} moveVector - x and y coordinates + */ +proto.dragMove = function( event, pointer, moveVector ) { + if ( !this.isEnabled ) { + return; + } + var dragX = moveVector.x; + var dragY = moveVector.y; + + var grid = this.options.grid; + var gridX = grid && grid[0]; + var gridY = grid && grid[1]; + + dragX = applyGrid( dragX, gridX ); + dragY = applyGrid( dragY, gridY ); + + dragX = this.containDrag( 'x', dragX, gridX ); + dragY = this.containDrag( 'y', dragY, gridY ); + + // constrain to axis + dragX = this.options.axis == 'y' ? 0 : dragX; + dragY = this.options.axis == 'x' ? 0 : dragY; + + this.position.x = this.startPosition.x + dragX; + this.position.y = this.startPosition.y + dragY; + // set dragPoint properties + this.dragPoint.x = dragX; + this.dragPoint.y = dragY; + + this.dispatchEvent( 'dragMove', event, [ pointer, moveVector ] ); +}; + +function applyGrid( value, grid, method ) { + method = method || 'round'; + return grid ? Math[ method ]( value/grid ) * grid : value; +} + +proto.containDrag = function( axis, drag, grid ) { + if ( !this.options.containment ) { + return drag; + } + var measure = axis == 'x' ? 'width' : 'height'; + + var rel = this.relativeStartPosition[ axis ]; + var min = applyGrid( -rel, grid, 'ceil' ); + var max = this.containSize[ measure ]; + max = applyGrid( max, grid, 'floor' ); + return Math.max( min, Math.min( max, drag ) ); +}; + +// ----- end event ----- // + +/** + * pointer up + * @param {Event} event + * @param {[Event, Touch]} pointer + */ +proto.onPointerUp = function( event, pointer ) { + this.element.classList.remove('is-pointer-down'); + this.dispatchJQueryEvent( 'pointerUp', event, [ pointer ] ); +}; + +/** + * drag end + * @param {Event} event + * @param {[Event, Touch]} pointer + */ +proto.dragEnd = function( event, pointer ) { + if ( !this.isEnabled ) { + return; + } + // use top left position when complete + this.element.style.transform = ''; + this.setLeftTop(); + this.element.classList.remove('is-dragging'); + this.dispatchEvent( 'dragEnd', event, [ pointer ] ); +}; + +// -------------------------- animation -------------------------- // + +proto.animate = function() { + // only render and animate if dragging + if ( !this.isDragging ) { + return; + } + + this.positionDrag(); + + var _this = this; + requestAnimationFrame( function animateFrame() { + _this.animate(); + } ); + +}; + +// left/top positioning +proto.setLeftTop = function() { + this.element.style.left = this.position.x + 'px'; + this.element.style.top = this.position.y + 'px'; +}; + +proto.positionDrag = function() { + this.element.style.transform = 'translate3d( ' + this.dragPoint.x + + 'px, ' + this.dragPoint.y + 'px, 0)'; +}; + +// ----- staticClick ----- // + +proto.staticClick = function( event, pointer ) { + this.dispatchEvent( 'staticClick', event, [ pointer ] ); +}; + +// ----- methods ----- // + +/** + * @param {Number} x + * @param {Number} y + */ +proto.setPosition = function( x, y ) { + this.position.x = x; + this.position.y = y; + this.setLeftTop(); +}; + +proto.enable = function() { + this.isEnabled = true; +}; + +proto.disable = function() { + this.isEnabled = false; + if ( this.isDragging ) { + this.dragEnd(); + } +}; + +proto.destroy = function() { + this.disable(); + // reset styles + this.element.style.transform = ''; + this.element.style.left = ''; + this.element.style.top = ''; + this.element.style.position = ''; + // unbind handles + this.unbindHandles(); + // remove jQuery data + if ( this.$element ) { + this.$element.removeData('draggabilly'); + } +}; + +// ----- jQuery bridget ----- // + +// required for jQuery bridget +proto._init = noop; + +if ( jQuery && jQuery.bridget ) { + jQuery.bridget( 'draggabilly', Draggabilly ); +} + +// ----- ----- // + +return Draggabilly; + +} ) ); + diff --git a/odex30_base/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.min.js b/odex30_base/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.min.js new file mode 100644 index 0000000..e3ec5e1 --- /dev/null +++ b/odex30_base/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.min.js @@ -0,0 +1,32 @@ +/*! + * Draggabilly PACKAGED v2.3.0 + * Make that shiz draggable + * https://draggabilly.desandro.com + * MIT license + */ +(function(e,i){if(typeof define=="function"&&define.amd){define("jquery-bridget/jquery-bridget",["jquery"],function(t){return i(e,t)})}else if(typeof module=="object"&&module.exports){module.exports=i(e,require("jquery"))}else{e.jQueryBridget=i(e,e.jQuery)}})(window,function t(e,r){"use strict";var s=Array.prototype.slice;var i=e.console;var f=typeof i=="undefined"?function(){}:function(t){i.error(t)};function n(h,o,d){d=d||r||e.jQuery;if(!d){return}if(!o.prototype.option){o.prototype.option=function(t){if(!d.isPlainObject(t)){return}this.options=d.extend(true,this.options,t)}}d.fn[h]=function(t){if(typeof t=="string"){var e=s.call(arguments,1);return i(this,t,e)}n(this,t);return this};function i(t,r,s){var a;var u="$()."+h+'("'+r+'")';t.each(function(t,e){var i=d.data(e,h);if(!i){f(h+" not initialized. Cannot call methods, i.e. "+u);return}var n=i[r];if(!n||r.charAt(0)=="_"){f(u+" is not a valid method");return}var o=n.apply(i,s);a=a===undefined?o:a});return a!==undefined?a:t}function n(t,n){t.each(function(t,e){var i=d.data(e,h);if(i){i.option(n);i._init()}else{i=new o(e,n);d.data(e,h,i)}})}a(d)}function a(t){if(!t||t&&t.bridget){return}t.bridget=n}a(r||e.jQuery);return n}); +/*! + * getSize v2.0.2 + * measure size of elements + * MIT license + */ +(function(t,e){"use strict";if(typeof define=="function"&&define.amd){define("get-size/get-size",[],function(){return e()})}else if(typeof module=="object"&&module.exports){module.exports=e()}else{t.getSize=e()}})(window,function t(){"use strict";function m(t){var e=parseFloat(t);var i=t.indexOf("%")==-1&&!isNaN(e);return i&&e}function e(){}var i=typeof console=="undefined"?e:function(t){console.error(t)};var y=["paddingLeft","paddingRight","paddingTop","paddingBottom","marginLeft","marginRight","marginTop","marginBottom","borderLeftWidth","borderRightWidth","borderTopWidth","borderBottomWidth"];var b=y.length;function E(){var t={width:0,height:0,innerWidth:0,innerHeight:0,outerWidth:0,outerHeight:0};for(var e=0;e