Initial commit
5
__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
48
__manifest__.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
{
|
||||||
|
'name': 'Gamification',
|
||||||
|
'version': '1.0',
|
||||||
|
'sequence': 160,
|
||||||
|
'category': 'Human Resources',
|
||||||
|
'depends': ['mail'],
|
||||||
|
'description': """
|
||||||
|
Gamification process
|
||||||
|
====================
|
||||||
|
The Gamification module provides ways to evaluate and motivate the users of Odoo.
|
||||||
|
|
||||||
|
The users can be evaluated using goals and numerical objectives to reach.
|
||||||
|
**Goals** are assigned through **challenges** to evaluate and compare members of a team with each others and through time.
|
||||||
|
|
||||||
|
For non-numerical achievements, **badges** can be granted to users. From a simple "thank you" to an exceptional achievement, a badge is an easy way to exprimate gratitude to a user for their good work.
|
||||||
|
|
||||||
|
Both goals and badges are flexibles and can be adapted to a large range of modules and actions. When installed, this module creates easy goals to help new users to discover Odoo and configure their user profile.
|
||||||
|
""",
|
||||||
|
|
||||||
|
'data': [
|
||||||
|
'wizard/update_goal.xml',
|
||||||
|
'wizard/grant_badge.xml',
|
||||||
|
'views/res_users_views.xml',
|
||||||
|
'views/gamification_karma_rank_views.xml',
|
||||||
|
'views/gamification_karma_tracking_views.xml',
|
||||||
|
'views/gamification_badge_views.xml',
|
||||||
|
'views/gamification_badge_user_views.xml',
|
||||||
|
'views/gamification_goal_views.xml',
|
||||||
|
'views/gamification_goal_definition_views.xml',
|
||||||
|
'views/gamification_challenge_views.xml',
|
||||||
|
'views/gamification_challenge_line_views.xml',
|
||||||
|
'views/gamification_menus.xml',
|
||||||
|
'security/gamification_security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/ir_cron_data.xml',
|
||||||
|
'data/mail_template_data.xml', # keep before to populate challenge reports
|
||||||
|
'data/gamification_badge_data.xml',
|
||||||
|
'data/gamification_challenge_data.xml',
|
||||||
|
'data/gamification_karma_rank_data.xml',
|
||||||
|
],
|
||||||
|
'demo': [
|
||||||
|
'data/gamification_karma_rank_demo.xml',
|
||||||
|
'data/gamification_karma_tracking_demo.xml',
|
||||||
|
],
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
35
data/gamification_badge_data.xml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="badge_good_job" model="gamification.badge">
|
||||||
|
<field name="name">Good Job</field>
|
||||||
|
<field name="description">You did great at your job.</field>
|
||||||
|
<field name="rule_auth">everyone</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/badge_good_job-image.png"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="badge_problem_solver" model="gamification.badge">
|
||||||
|
<field name="name">Problem Solver</field>
|
||||||
|
<field name="description">No one can solve challenges like you do.</field>
|
||||||
|
<field name="rule_auth">everyone</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/badge_problem_solver-image.png"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="badge_hidden" model="gamification.badge">
|
||||||
|
<field name="name">Hidden</field>
|
||||||
|
<field name="description">You have found the hidden badge</field>
|
||||||
|
<field name="rule_auth">nobody</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/badge_hidden-image.png"/>
|
||||||
|
<field name="active" eval="False" />
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="badge_idea" model="gamification.badge">
|
||||||
|
<field name="name">Brilliant</field>
|
||||||
|
<field name="description">With your brilliant ideas, you are an inspiration to others.</field>
|
||||||
|
<field name="rule_auth">everyone</field>
|
||||||
|
<field name="rule_max">True</field>
|
||||||
|
<field name="rule_max_number">2</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/badge_idea-image.png"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
96
data/gamification_challenge_data.xml
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- goal definitions -->
|
||||||
|
<record model="gamification.goal.definition" id="definition_base_timezone">
|
||||||
|
<field name="name">Set your Timezone</field>
|
||||||
|
<field name="description">Configure your profile and specify your timezone</field>
|
||||||
|
<field name="computation_mode">count</field>
|
||||||
|
<field name="display_mode">boolean</field>
|
||||||
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
|
<field name="domain">[('partner_id.tz', '!=', False)]</field>
|
||||||
|
<field name="action_id" ref="base.action_res_users_my"/>
|
||||||
|
<field name="res_id_field">user.id</field>
|
||||||
|
<field name="batch_mode">True</field>
|
||||||
|
<field name="batch_distinctive_field" ref="base.field_res_users__id"/>
|
||||||
|
<field name="batch_user_expression">user.id</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="gamification.goal.definition" id="definition_base_company_data">
|
||||||
|
<field name="name">Set your Company Data</field>
|
||||||
|
<field name="description">Write some information about your company (specify at least a name)</field>
|
||||||
|
<field name="computation_mode">count</field>
|
||||||
|
<field name="display_mode">boolean</field>
|
||||||
|
<field name="model_id" ref="base.model_res_company"/>
|
||||||
|
<field name="domain">[('user_ids', 'in', [user.id]), ('name', '=', 'YourCompany')]</field>
|
||||||
|
<field name="condition">lower</field>
|
||||||
|
<field name="action_id" ref="base.action_res_company_form"/>
|
||||||
|
<field name="res_id_field">user.company_id.id</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="gamification.goal.definition" id="definition_base_company_logo">
|
||||||
|
<field name="name">Set your Company Logo</field>
|
||||||
|
<field name="computation_mode">count</field>
|
||||||
|
<field name="display_mode">boolean</field>
|
||||||
|
<field name="model_id" ref="base.model_res_company"/>
|
||||||
|
<field name="domain">[('user_ids', 'in', [user.id]),('logo', '!=', False)]</field>
|
||||||
|
<field name="action_id" ref="base.action_res_company_form"/>
|
||||||
|
<field name="res_id_field">user.company_id.id</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="gamification.goal.definition" id="definition_base_invite">
|
||||||
|
<field name="name">Invite new Users</field>
|
||||||
|
<field name="description">Create at least another user</field>
|
||||||
|
<field name="display_mode">boolean</field>
|
||||||
|
<field name="computation_mode">count</field>
|
||||||
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
|
<field name="domain">[('id', '!=', user.id)]</field>
|
||||||
|
<field name="action_id" ref="action_new_simplified_res_users"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- challenges -->
|
||||||
|
<record model="gamification.challenge" id="challenge_base_discover">
|
||||||
|
<field name="name">Complete your Profile</field>
|
||||||
|
<field name="period">once</field>
|
||||||
|
<field name="visibility_mode">personal</field>
|
||||||
|
<field name="report_message_frequency">never</field>
|
||||||
|
<field name="user_domain" eval="str([('groups_id.id', '=', ref('base.group_user'))])" />
|
||||||
|
<field name="state">inprogress</field>
|
||||||
|
<field name="challenge_category">other</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="gamification.challenge" id="challenge_base_configure">
|
||||||
|
<field name="name">Setup your Company</field>
|
||||||
|
<field name="period">once</field>
|
||||||
|
<field name="visibility_mode">personal</field>
|
||||||
|
<field name="report_message_frequency">never</field>
|
||||||
|
<field name="user_domain" eval="str([('groups_id.id', '=', ref('base.group_erp_manager'))])" />
|
||||||
|
<field name="state">inprogress</field>
|
||||||
|
<field name="challenge_category">other</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- lines -->
|
||||||
|
<record model="gamification.challenge.line" id="line_base_discover1">
|
||||||
|
<field name="definition_id" ref="definition_base_timezone"/>
|
||||||
|
<field name="target_goal">1</field>
|
||||||
|
<field name="challenge_id" ref="challenge_base_discover"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="gamification.challenge.line" id="line_base_admin2">
|
||||||
|
<field name="definition_id" ref="definition_base_company_logo"/>
|
||||||
|
<field name="target_goal">1</field>
|
||||||
|
<field name="challenge_id" ref="challenge_base_configure"/>
|
||||||
|
</record>
|
||||||
|
<record model="gamification.challenge.line" id="line_base_admin1">
|
||||||
|
<field name="definition_id" ref="definition_base_company_data"/>
|
||||||
|
<field name="target_goal">0</field>
|
||||||
|
<field name="challenge_id" ref="challenge_base_configure"/>
|
||||||
|
</record>
|
||||||
|
<record model="gamification.challenge.line" id="line_base_admin3">
|
||||||
|
<field name="definition_id" ref="definition_base_invite"/>
|
||||||
|
<field name="target_goal">1</field>
|
||||||
|
<field name="challenge_id" ref="challenge_base_configure"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
</odoo>
|
80
data/gamification_karma_rank_data.xml
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
<!--Karma-->
|
||||||
|
<record id="karma_tracking_user_root" model="gamification.karma.tracking" forcecreate="0">
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
<field name="new_value">2500</field>
|
||||||
|
<field name="reason">I am the Root!</field>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_admin" model="gamification.karma.tracking" forcecreate="0">
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="new_value">2500</field>
|
||||||
|
<field name="reason">I am the Admin!</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!--Ranks-->
|
||||||
|
<record id="rank_newbie" model="gamification.karma.rank">
|
||||||
|
<field name="name">Newbie</field>
|
||||||
|
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Earn your first points and join the adventure!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_newbie_badge.svg"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
<field name="karma_min">1</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_student" model="gamification.karma.rank">
|
||||||
|
<field name="name">Student</field>
|
||||||
|
<field name="description" type="html"><p>You're a young padawan now. May the force be with you!</p></field>
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank to show the rest of the world you exist.</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_student_badge.svg"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
<field name="karma_min">100</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_bachelor" model="gamification.karma.rank">
|
||||||
|
<field name="name">Bachelor</field>
|
||||||
|
<field name="description" type="html"><p>You love learning things. Curiosity is a good way to progress.</p></field>
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank to improve your status!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_bachelor_badge.svg"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
<field name="karma_min">500</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_master" model="gamification.karma.rank">
|
||||||
|
<field name="name">Master</field>
|
||||||
|
<field name="description" type="html"><p>You know what you are talking about. People learn from you.</p></field>
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank and become a Master!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_master_badge.svg"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
<field name="karma_min">2000</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_doctor" model="gamification.karma.rank">
|
||||||
|
<field name="name">Doctor</field>
|
||||||
|
<field name="description" type="html"><p>You have reached the last rank. Congratulations!</p></field>
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank and become a powerful user!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_doctor_badge.svg"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
<field name="karma_min">10000</field>
|
||||||
|
<field name="image_1920" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
|
||||||
|
</record>
|
||||||
|
</data></odoo>
|
44
data/gamification_karma_rank_demo.xml
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
<!--Ranks-->
|
||||||
|
<!-- note that original motivational messages are duplicated+hidden to ensure they are included in .pot export when demo data installed -->
|
||||||
|
<record id="rank_student" model="gamification.karma.rank">
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div hidden="true">Reach the next rank to show the rest of the world you exist.</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank and gain a very nice mug!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_bachelor" model="gamification.karma.rank">
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div hidden="true">Reach the next rank to improve your status!</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank and gain a very magic wand!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_master" model="gamification.karma.rank">
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div hidden="true">Reach the next rank and become a Master!</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank and gain a very nice hat!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rank_doctor" model="gamification.karma.rank">
|
||||||
|
<field name="description_motivational" type="html">
|
||||||
|
<div hidden="true">Reach the next rank and become a powerful user!</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">Reach the next rank and gain a very nice unicorn!</div>
|
||||||
|
<img class="ms-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data></odoo>
|
110
data/gamification_karma_tracking_demo.xml
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo><data noupdate="1">
|
||||||
|
|
||||||
|
<!--base.user_demo-->
|
||||||
|
<record id="karma_tracking_user_demo_1st_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_demo"/>
|
||||||
|
<field name="old_value">0</field>
|
||||||
|
<field name="new_value">1000</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=1, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_demo_2nd_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_demo"/>
|
||||||
|
<field name="old_value">1000</field>
|
||||||
|
<field name="new_value">1500</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=2, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_demo_5th_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_demo"/>
|
||||||
|
<field name="old_value">1500</field>
|
||||||
|
<field name="new_value">2000</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=5, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_demo_20th_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_demo"/>
|
||||||
|
<field name="old_value">2000</field>
|
||||||
|
<field name="new_value">2050</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=20, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_demo_today" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_demo"/>
|
||||||
|
<field name="old_value">2050</field>
|
||||||
|
<field name="new_value">2500</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now()).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<function model="gamification.karma.tracking" name="unlink">
|
||||||
|
<value model="gamification.karma.tracking" eval="obj().search([
|
||||||
|
('user_id', '=', ref('base.user_demo')),
|
||||||
|
('old_value', '=', 0),
|
||||||
|
('new_value', '=', 2500)
|
||||||
|
]).id"/>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
<!--base.demo_user0 -->
|
||||||
|
<record id="karma_tracking_user_portal_2nd_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.demo_user0"/>
|
||||||
|
<field name="old_value">0</field>
|
||||||
|
<field name="new_value">5</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=2, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_portal_3rd_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.demo_user0"/>
|
||||||
|
<field name="old_value">5</field>
|
||||||
|
<field name="new_value">10</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=3, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_portal_10th_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.demo_user0"/>
|
||||||
|
<field name="old_value">10</field>
|
||||||
|
<field name="new_value">20</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=10, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_portal_yesterday" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.demo_user0"/>
|
||||||
|
<field name="old_value">20</field>
|
||||||
|
<field name="new_value">25</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_portal_today" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.demo_user0"/>
|
||||||
|
<field name="old_value">25</field>
|
||||||
|
<field name="new_value">30</field>
|
||||||
|
<field name="tracking_date" eval="DateTime.now()"/>
|
||||||
|
</record>
|
||||||
|
<function model="gamification.karma.tracking" name="unlink">
|
||||||
|
<value model="gamification.karma.tracking" eval="obj().search([
|
||||||
|
('user_id', '=', ref('base.demo_user0')),
|
||||||
|
('old_value', '=', 0),
|
||||||
|
('new_value', '=', 30)
|
||||||
|
]).id"/>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
<!--base.user_admin (already have a tracking to 2500)-->
|
||||||
|
<record id="karma_tracking_user_admin_1st_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="old_value">0</field>
|
||||||
|
<field name="new_value">2000</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=1, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_admin_5th_day_last_month" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="old_value">2000</field>
|
||||||
|
<field name="new_value">2250</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now() - relativedelta(day=5, months=1)).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<record id="karma_tracking_user_admin_today" model="gamification.karma.tracking">
|
||||||
|
<field name="user_id" ref="base.user_admin"/>
|
||||||
|
<field name="old_value">2250</field>
|
||||||
|
<field name="new_value">2500</field>
|
||||||
|
<field name="tracking_date" eval="(DateTime.now()).strftime('%Y-%m-%d')"/>
|
||||||
|
</record>
|
||||||
|
<function model="gamification.karma.tracking" name="unlink">
|
||||||
|
<value model="gamification.karma.tracking" eval="obj().search([
|
||||||
|
('user_id', '=', ref('base.user_admin')),
|
||||||
|
('old_value', '=', 0),
|
||||||
|
('new_value', '=', 2500)
|
||||||
|
]).id"/>
|
||||||
|
</function>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
27
data/ir_cron_data.xml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record forcecreate="True" id="ir_cron_check_challenge" model="ir.cron">
|
||||||
|
<field name="name">Gamification: Goal Challenge Check</field>
|
||||||
|
<field name="model_id" ref="model_gamification_challenge"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_update()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field eval="False" name="doall" />
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="ir_cron_consolidate" model="ir.cron">
|
||||||
|
<field name="name">Gamification: Karma tracking consolidation</field>
|
||||||
|
<field name="model_id" ref="model_gamification_karma_tracking"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._consolidate_cron()</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">months</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="nextcall" eval="(DateTime.now() + relativedelta(day=1, months=1)).strftime('%Y-%m-%d 04:00:00')" />
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
373
data/mail_template_data.xml
Normal file
|
@ -0,0 +1,373 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="email_template_badge_received" model="mail.template">
|
||||||
|
<field name="name">Gamification: Badge Received</field>
|
||||||
|
<field name="subject">New badge {{ object.badge_id.name }} granted</field>
|
||||||
|
<field name="model_id" ref="gamification.model_gamification_badge_user"/>
|
||||||
|
<field name="partner_to">{{ object.user_id.partner_id.id }}</field>
|
||||||
|
<field name="description">Sent automatically to the user who received a badge</field>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<table border="0" cellpadding="0" style="padding-top: 16px; background-color: #F1F1F1; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||||
|
<table border="0" width="590" cellpadding="0" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;" summary="o_mail_notification">
|
||||||
|
<tbody>
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table width="590" border="0" cellpadding="0" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="middle">
|
||||||
|
<span style="font-size: 10px;">Your Badge</span><br/>
|
||||||
|
<span style="font-size: 20px; font-weight: bold;" t-out="object.badge_id.name or ''"></span>
|
||||||
|
</td><td valign="middle" align="right" t-if="not object.user_id.company_id.uses_default_logo">
|
||||||
|
<img t-attf-src="/logo.png?company={{ object.user_id.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.user_id.company_id.name"/>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td colspan="2" style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table width="590" border="0" cellpadding="0" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="top" style="font-size: 14px;">
|
||||||
|
<div>
|
||||||
|
Congratulations <t t-out="object.user_id.name or ''"></t>!<br/>
|
||||||
|
You just received badge <strong t-out="object.badge_id.name or ''"></strong>!<br/>
|
||||||
|
<table t-if="not is_html_empty(object.badge_id.description)" cellspacing="0" cellpadding="0" border="0" style="width: 560px; margin-top: 5px;">
|
||||||
|
<tbody><tr>
|
||||||
|
<td valign="center">
|
||||||
|
<img t-attf-src="/web/image/gamification.badge/{{ object.badge_id.id }}/image_128/80x80" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="user.company_id.name"/>
|
||||||
|
</td>
|
||||||
|
<td valign="center">
|
||||||
|
<cite t-out="object.badge_id.description or ''"></cite>
|
||||||
|
</td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table>
|
||||||
|
<br/>
|
||||||
|
<t t-if="object.sender_id">
|
||||||
|
This badge was granted by <strong t-out="object.sender_id.name or ''"></strong>.
|
||||||
|
</t>
|
||||||
|
<br/>
|
||||||
|
<t t-if="object.comment" t-out="object.comment or ''"></t>
|
||||||
|
<br/><br/>
|
||||||
|
Thank you,
|
||||||
|
<t t-if="object.sender_id.signature">
|
||||||
|
<br />
|
||||||
|
<t t-out="object.sender_id.signature or ''"></t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table width="590" border="0" cellpadding="0" style="min-width: 590px; background-color: white; font-size: 12px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr><td valign="middle" align="left">
|
||||||
|
<t t-out="object.user_id.company_id.name or ''">YourCompany</t>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||||
|
<t t-out="object.user_id.company_id.phone or ''">+1 650-123-4567</t>
|
||||||
|
<t t-if="object.user_id.company_id.email">
|
||||||
|
| <a t-attf-href="'mailto:%s' % {{ object.user_id.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.user_id.company_id.email or ''">info@yourcompany.com</a>
|
||||||
|
</t>
|
||||||
|
<t t-if="object.user_id.company_id.website">
|
||||||
|
| <a t-attf-href="'%s' % {{ object.user_id.company_id.website }}" style="text-decoration:none; color: #454748;" t-out="object.user_id.company_id.website or ''">http://www.example.com</a>
|
||||||
|
</t>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<!-- POWERED BY -->
|
||||||
|
<tr><td align="center" style="min-width: 590px;">
|
||||||
|
<table width="590" border="0" cellpadding="0" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
|
||||||
|
<tr><td style="text-align: center; font-size: 14px;">
|
||||||
|
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&utm_medium=gamification" style="color: #875A7B;">Odoo</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table></field>
|
||||||
|
<field name="lang">{{ object.user_id.lang }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="email_template_goal_reminder" model="mail.template">
|
||||||
|
<field name="name">Gamification: Reminder For Goal Update</field>
|
||||||
|
<field name="model_id" ref="gamification.model_gamification_goal"/>
|
||||||
|
<field name="partner_to">{{ object.user_id.partner_id.id }}</field>
|
||||||
|
<field name="description">Sent automatically to participant who haven't updated their goal</field>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<div>
|
||||||
|
<strong>Reminder</strong><br/>
|
||||||
|
You have not updated your progress for the goal <t t-out="object.definition_id.name or ''"></t> (currently reached at <t t-out="object.completeness or ''"></t>%) for at least <t t-out="object.remind_update_delay or ''"></t> days. Do not forget to do it.
|
||||||
|
<br/><br/>
|
||||||
|
Thank you,
|
||||||
|
<t t-if="object.challenge_id.manager_id.signature">
|
||||||
|
<br />
|
||||||
|
<t t-out="object.challenge_id.manager_id.signature or ''"></t>
|
||||||
|
</t>
|
||||||
|
</div></field>
|
||||||
|
<field name="lang">{{ object.user_id.lang }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="simple_report_template" model="mail.template">
|
||||||
|
<field name="name">Gamification: Challenge Report</field>
|
||||||
|
<field name="model_id" ref="gamification.model_gamification_challenge"/>
|
||||||
|
<field name="description">Send a challenge report to all participants</field>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="background-color: #EEE; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td valign="top" align="center">
|
||||||
|
<t t-set="object_ctx" t-value="ctx.get('object')"/>
|
||||||
|
<t t-set="company" t-value="object_ctx and object_ctx.company_id or user.company_id"/>
|
||||||
|
<t t-set="challenge_lines" t-value="ctx.get('challenge_lines', [])"/>
|
||||||
|
<table cellspacing="0" cellpadding="0" width="600" style="margin: 0 auto; width: 570px;">
|
||||||
|
<tr><td>
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<div>
|
||||||
|
<t t-if="object.visibility_mode == 'ranking'">
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<p style="font-size:20px;color:#666666;" align="center">Leaderboard</p>
|
||||||
|
</td>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" bgcolor="#fff" style="background-color:#fff;">
|
||||||
|
<tr><td style="padding: 15px;">
|
||||||
|
<t t-if="object.visibility_mode == 'personal'">
|
||||||
|
<span style="color:#666666;font-size:13px;">Here is your current progress in the challenge <strong t-out="object.name or ''"></strong>.</span>
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="margin-top:20px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<div>Personal Performance</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="margin-top:30px;color:#666666;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:#9A6C8E; font-size:12px;">
|
||||||
|
<th align="left" style="padding-bottom: 0px;width:40%;text-align:left;">Goals</th>
|
||||||
|
<th style="width:20%;text-align:right;" align="left">Target</th>
|
||||||
|
<th style="width:20%;text-align:right;" align="right">Current</th>
|
||||||
|
<th style="width:20%;text-align:right;" align="right">Completeness</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="height:1px;background-color:#9A6C8E;"></td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody t-foreach="challenge_lines" t-as="line">
|
||||||
|
<tr style="font-weight:bold;">
|
||||||
|
<td style="padding: 20px 0;" align="left">
|
||||||
|
<t t-out="line['name'] or ''"></t>
|
||||||
|
<t t-if="line['suffix'] or line['monetary']">
|
||||||
|
(<t t-out="line['full_suffix'] or ''"></t>)
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 20px 0;" align="right"><t t-out=""%.2f" % line['target'] or ''"></t>
|
||||||
|
<t t-if="line['suffix']" t-out="line['suffix'] or ''"></t>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 20px 0;" align="right"><t t-out=""%.2f" % line['current'] or ''"></t>
|
||||||
|
<t t-if="line['suffix']" t-out="line['suffix'] or ''"></t>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 20px 0;font-size:25px;color:#9A6C8E;" align="right"><strong><t t-out="int(line['completeness']) or ''"></t>%</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="height:1px;background-color:#e3e3e3;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span style="color:#A8A8A8;font-size:13px;">
|
||||||
|
Challenge: <strong t-out="object.name or ''"></strong>.
|
||||||
|
</span>
|
||||||
|
<t t-foreach="challenge_lines" t-as="line">
|
||||||
|
<!-- Header + Button table -->
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="margin-top:35px;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
<div>Top Achievers for goal <strong t-out="line['name'] or ''"></strong></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- Podium -->
|
||||||
|
<t t-if="len(line['goals']) == 2">
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="margin-top:10px;">
|
||||||
|
<tr><td style="padding:0 30px;">
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="table-layout: fixed;">
|
||||||
|
<tr>
|
||||||
|
<t t-set="top_goals" t-value="line['goals'][:3]"/>
|
||||||
|
<t t-foreach="top_goals" t-as="goal">
|
||||||
|
<td align="center" style="width:32%;">
|
||||||
|
<t t-if="loop.index == 1">
|
||||||
|
<t t-set="extra_div" t-value="'<div style="height:40px;"></div>'"/>
|
||||||
|
<t t-set="heightA" t-value="95"/>
|
||||||
|
<t t-set="heightB" t-value="75"/>
|
||||||
|
<t t-set="bgColor" t-value="'#b898b0'"/>
|
||||||
|
<t t-set="fontSize" t-value="50"/>
|
||||||
|
<t t-set="podiumPosition" t-value="'2'"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="loop.index == 2">
|
||||||
|
<t t-set="extra_div" t-value="''"/>
|
||||||
|
<t t-set="heightA" t-value="55"/>
|
||||||
|
<t t-set="heightB" t-value="115"/>
|
||||||
|
<t t-set="bgColor" t-value="'#9A6C8E'"/>
|
||||||
|
<t t-set="fontSize" t-value="85"/>
|
||||||
|
<t t-set="podiumPosition" t-value="'1'"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="loop.index == 3">
|
||||||
|
<t t-set="extra_div" t-value="'<div style="height:60px;"></div>'"/>
|
||||||
|
<t t-set="heightA" t-value="115"/>
|
||||||
|
<t t-set="heightB" t-value="55"/>
|
||||||
|
<t t-set="bgColor" t-value="'#c8afc1'"/>
|
||||||
|
<t t-set="fontSize" t-value="35"/>
|
||||||
|
<t t-set="podiumPosition" t-value="'3'"/>
|
||||||
|
</t>
|
||||||
|
<div style="margin:0 3px 0 3px;height:220px;">
|
||||||
|
<div t-attf-style="height:{{ heightA }}px;">
|
||||||
|
<t t-out="extra_div or ''"></t>
|
||||||
|
<div style="height:55px;">
|
||||||
|
<img style="margin-bottom:5px;width:50px;height:50px;border-radius:50%;object-fit:cover;" t-att-src="image_data_uri(object.env['res.users'].browse(goal['user_id']).partner_id.image_128)" t-att-alt="goal['name']"/>
|
||||||
|
</div>
|
||||||
|
<div align="center" t-attf-style ="color:{{ bgColor }};height:20px">
|
||||||
|
<t t-out="goal['name'] or ''"></t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div t-attf-style="background-color:{{ bgColor }};height:{{ heightB }}px;">
|
||||||
|
<strong><span t-attf-style="color:#fff;font-size:{{ fontSize }}px;" t-out="podiumPosition or ''"></span></strong>
|
||||||
|
</div>
|
||||||
|
<div style="height:30px;">
|
||||||
|
<t t-out=""%.2f" % goal['current'] or ''"></t>
|
||||||
|
<t t-if="line['suffix'] or line['monetary']">
|
||||||
|
<t t-out="line['full_suffix'] or ''"></t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</t>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
<!-- data table -->
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="margin-bottom:5px">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table cellspacing="0" cellpadding="0" width="100%" style="margin-top:30px;margin-bottom:5px;color:#666666;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:#9A6C8E; font-size:12px;">
|
||||||
|
<th style="width:15%;text-align:center;">Rank</th>
|
||||||
|
<th style="width:25%;text-align:left;">Name</th>
|
||||||
|
<th style="width:30%;text-align:right;">Performance
|
||||||
|
<t t-if="line['suffix']">
|
||||||
|
(<t t-out="line['suffix'] or ''"></t>)
|
||||||
|
</t>
|
||||||
|
<t t-elif="line['monetary']">
|
||||||
|
(<t t-out="company.currency_id.symbol or ''"></t>)
|
||||||
|
</t>
|
||||||
|
</th>
|
||||||
|
<th style="width:30%;text-align:right;">Completeness</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="height:1px;background-color:#9A6C8E;"></td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody t-foreach="line['goals']" t-as="goal">
|
||||||
|
<tr>
|
||||||
|
<t t-set="tdBgColor" t-value="'#fff'"/>
|
||||||
|
<t t-set="tdColor" t-value="'gray'"/>
|
||||||
|
<t t-set="mutedColor" t-value="'#AAAAAA'"/>
|
||||||
|
<t t-set="tdPercentageColor" t-value="'#9A6C8E'"/>
|
||||||
|
<td width="15%" align="center" valign="middle" t-attf-style="background-color:{{ tdBgColor }};padding :5px 0;font-size:20px;"><t t-out="goal['rank']+1 or ''"></t>
|
||||||
|
</td>
|
||||||
|
<td width="25%" align="left" valign="middle" t-attf-style="background-color:{{ tdBgColor }};padding :5px 0;font-size:13px;"><t t-out="goal['name'] or ''"></t></td>
|
||||||
|
<td width="30%" align="right" t-attf-style="background-color:{{ tdBgColor }};padding:5px 0;line-height:1;"><t t-out=""%.2f" % goal['current'] or ''"></t><br/><span t-attf-style="font-size:13px;color:{{ mutedColor }};">on <t t-out=""%.2f" % line['target'] or ''"></t></span>
|
||||||
|
</td>
|
||||||
|
<td width="30%" t-attf-style="color:{{ tdPercentageColor }};background-color:{{ tdBgColor }};padding-right:15px;font-size:22px;" align="right"><strong><t t-out="int(goal['completeness']) or ''"></t>%</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="height:1px;background-color:#DADADA;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="mail_template_data_new_rank_reached" model="mail.template">
|
||||||
|
<field name="name">Gamification: New Rank Reached</field>
|
||||||
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
|
<field name="subject">New rank: {{ object.rank_id.name }}</field>
|
||||||
|
<field name="email_to"></field>
|
||||||
|
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||||
|
<field name="description">Sent automatically when user reaches a new rank</field>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<div style="background:#F0F0F0;color:#515166;padding:10px 0px;font-family:Arial,Helvetica,sans-serif;font-size:14px;">
|
||||||
|
<table style="width:600px;margin:0px auto;background:white;border:1px solid #e1e1e1;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:15px 20px 10px 20px;">
|
||||||
|
<p>
|
||||||
|
Congratulations
|
||||||
|
<span t-out="object.name or ''">Joel Willis</span>!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You just reached a new rank: <strong t-out="object.rank_id.name or ''">Newbie</strong>
|
||||||
|
</p>
|
||||||
|
<t t-if="object.next_rank_id.name">
|
||||||
|
<p>Continue your work to become a <strong t-out="object.next_rank_id.name or ''">Student</strong>!</p>
|
||||||
|
</t>
|
||||||
|
<div style="margin: 16px 0px 16px 0px;">
|
||||||
|
<t t-set="gamification_redirection_data" t-value="object.get_gamification_redirection_data()"/>
|
||||||
|
<t t-foreach="gamification_redirection_data" t-as="data">
|
||||||
|
<t t-set="url" t-value="data['url']"/>
|
||||||
|
<t t-set="label" t-value="data['label']"/>
|
||||||
|
<a t-att-href="url" style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;" t-out="label or ''">LABEL</a>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:15px 20px 10px 20px;">
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<img t-attf-src="/web/image/gamification.karma.rank/{{ object.rank_id.id }}/image_128"/>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="user.signature">
|
||||||
|
<td style="padding:15px 20px 10px 20px;">
|
||||||
|
<t t-out="user.signature or ''">--<br/>Mitchell Admin</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div></field>
|
||||||
|
<field name="lang">{{ object.lang }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
2611
i18n/af.po
Normal file
2607
i18n/am.po
Normal file
3093
i18n/ar.po
Normal file
2612
i18n/az.po
Normal file
2757
i18n/bg.po
Normal file
2612
i18n/bs.po
Normal file
2782
i18n/ca.po
Normal file
2771
i18n/cs.po
Normal file
2776
i18n/da.po
Normal file
3158
i18n/de.po
Normal file
2613
i18n/el.po
Normal file
2610
i18n/en_GB.po
Normal file
3125
i18n/es.po
Normal file
3129
i18n/es_419.po
Normal file
2610
i18n/es_BO.po
Normal file
2610
i18n/es_CL.po
Normal file
2610
i18n/es_CO.po
Normal file
2610
i18n/es_CR.po
Normal file
2610
i18n/es_DO.po
Normal file
2610
i18n/es_EC.po
Normal file
2610
i18n/es_PE.po
Normal file
2610
i18n/es_PY.po
Normal file
2610
i18n/es_VE.po
Normal file
2824
i18n/et.po
Normal file
2610
i18n/eu.po
Normal file
2745
i18n/fa.po
Normal file
3141
i18n/fi.po
Normal file
2610
i18n/fo.po
Normal file
3133
i18n/fr.po
Normal file
2610
i18n/fr_CA.po
Normal file
2690
i18n/gamification.pot
Normal file
2610
i18n/gl.po
Normal file
2611
i18n/gu.po
Normal file
2712
i18n/he.po
Normal file
2631
i18n/hr.po
Normal file
2746
i18n/hu.po
Normal file
3114
i18n/id.po
Normal file
2607
i18n/is.po
Normal file
3138
i18n/it.po
Normal file
3065
i18n/ja.po
Normal file
2610
i18n/ka.po
Normal file
2610
i18n/kab.po
Normal file
2613
i18n/km.po
Normal file
3068
i18n/ko.po
Normal file
2607
i18n/lb.po
Normal file
2610
i18n/lo.po
Normal file
2745
i18n/lt.po
Normal file
2705
i18n/lv.po
Normal file
2610
i18n/mk.po
Normal file
2630
i18n/mn.po
Normal file
2615
i18n/nb.po
Normal file
2607
i18n/ne.po
Normal file
3133
i18n/nl.po
Normal file
2974
i18n/pl.po
Normal file
2753
i18n/pt.po
Normal file
3123
i18n/pt_BR.po
Normal file
2638
i18n/ro.po
Normal file
3131
i18n/ru.po
Normal file
2744
i18n/sk.po
Normal file
2745
i18n/sl.po
Normal file
2610
i18n/sq.po
Normal file
2978
i18n/sr.po
Normal file
2613
i18n/sr@latin.po
Normal file
2716
i18n/sv.po
Normal file
3101
i18n/th.po
Normal file
2766
i18n/tr.po
Normal file
3117
i18n/uk.po
Normal file
2750
i18n/vi.po
Normal file
3056
i18n/zh_CN.po
Normal file
3055
i18n/zh_TW.po
Normal file
12
models/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import gamification_badge
|
||||||
|
from . import gamification_badge_user
|
||||||
|
from . import gamification_challenge
|
||||||
|
from . import gamification_challenge_line
|
||||||
|
from . import gamification_goal
|
||||||
|
from . import gamification_goal_definition
|
||||||
|
from . import gamification_karma_rank
|
||||||
|
from . import gamification_karma_tracking
|
||||||
|
from . import res_users
|
218
models/gamification_badge.py
Normal file
|
@ -0,0 +1,218 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _, exceptions
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GamificationBadge(models.Model):
|
||||||
|
"""Badge object that users can send and receive"""
|
||||||
|
|
||||||
|
CAN_GRANT = 1
|
||||||
|
NOBODY_CAN_GRANT = 2
|
||||||
|
USER_NOT_VIP = 3
|
||||||
|
BADGE_REQUIRED = 4
|
||||||
|
TOO_MANY = 5
|
||||||
|
|
||||||
|
_name = 'gamification.badge'
|
||||||
|
_description = 'Gamification Badge'
|
||||||
|
_inherit = ['mail.thread', 'image.mixin']
|
||||||
|
|
||||||
|
name = fields.Char('Badge', required=True, translate=True)
|
||||||
|
active = fields.Boolean('Active', default=True)
|
||||||
|
description = fields.Html('Description', translate=True, sanitize_attributes=False)
|
||||||
|
level = fields.Selection([
|
||||||
|
('bronze', 'Bronze'), ('silver', 'Silver'), ('gold', 'Gold')],
|
||||||
|
string='Forum Badge Level', default='bronze')
|
||||||
|
|
||||||
|
rule_auth = fields.Selection([
|
||||||
|
('everyone', 'Everyone'),
|
||||||
|
('users', 'A selected list of users'),
|
||||||
|
('having', 'People having some badges'),
|
||||||
|
('nobody', 'No one, assigned through challenges'),
|
||||||
|
], default='everyone',
|
||||||
|
string="Allowance to Grant", help="Who can grant this badge", required=True)
|
||||||
|
rule_auth_user_ids = fields.Many2many(
|
||||||
|
'res.users', 'rel_badge_auth_users',
|
||||||
|
string='Authorized Users',
|
||||||
|
help="Only these people can give this badge")
|
||||||
|
rule_auth_badge_ids = fields.Many2many(
|
||||||
|
'gamification.badge', 'gamification_badge_rule_badge_rel', 'badge1_id', 'badge2_id',
|
||||||
|
string='Required Badges',
|
||||||
|
help="Only the people having these badges can give this badge")
|
||||||
|
|
||||||
|
rule_max = fields.Boolean('Monthly Limited Sending', help="Check to set a monthly limit per person of sending this badge")
|
||||||
|
rule_max_number = fields.Integer('Limitation Number', help="The maximum number of time this badge can be sent per month per person.")
|
||||||
|
challenge_ids = fields.One2many('gamification.challenge', 'reward_id', string="Reward of Challenges")
|
||||||
|
|
||||||
|
goal_definition_ids = fields.Many2many(
|
||||||
|
'gamification.goal.definition', 'badge_unlocked_definition_rel',
|
||||||
|
string='Rewarded by', help="The users that have succeeded these goals will receive automatically the badge.")
|
||||||
|
|
||||||
|
owner_ids = fields.One2many(
|
||||||
|
'gamification.badge.user', 'badge_id',
|
||||||
|
string='Owners', help='The list of instances of this badge granted to users')
|
||||||
|
|
||||||
|
granted_count = fields.Integer("Total", compute='_get_owners_info', help="The number of time this badge has been received.")
|
||||||
|
granted_users_count = fields.Integer("Number of users", compute='_get_owners_info', help="The number of time this badge has been received by unique users.")
|
||||||
|
unique_owner_ids = fields.Many2many(
|
||||||
|
'res.users', string="Unique Owners", compute='_get_owners_info',
|
||||||
|
help="The list of unique users having received this badge.")
|
||||||
|
|
||||||
|
stat_this_month = fields.Integer(
|
||||||
|
"Monthly total", compute='_get_badge_user_stats',
|
||||||
|
help="The number of time this badge has been received this month.")
|
||||||
|
stat_my = fields.Integer(
|
||||||
|
"My Total", compute='_get_badge_user_stats',
|
||||||
|
help="The number of time the current user has received this badge.")
|
||||||
|
stat_my_this_month = fields.Integer(
|
||||||
|
"My Monthly Total", compute='_get_badge_user_stats',
|
||||||
|
help="The number of time the current user has received this badge this month.")
|
||||||
|
stat_my_monthly_sending = fields.Integer(
|
||||||
|
'My Monthly Sending Total',
|
||||||
|
compute='_get_badge_user_stats',
|
||||||
|
help="The number of time the current user has sent this badge this month.")
|
||||||
|
|
||||||
|
remaining_sending = fields.Integer(
|
||||||
|
"Remaining Sending Allowed", compute='_remaining_sending_calc',
|
||||||
|
help="If a maximum is set")
|
||||||
|
|
||||||
|
@api.depends('owner_ids')
|
||||||
|
def _get_owners_info(self):
|
||||||
|
"""Return:
|
||||||
|
the list of unique res.users ids having received this badge
|
||||||
|
the total number of time this badge was granted
|
||||||
|
the total number of users this badge was granted to
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
'granted_count': 0,
|
||||||
|
'granted_users_count': 0,
|
||||||
|
'unique_owner_ids': [],
|
||||||
|
}
|
||||||
|
if not self.ids:
|
||||||
|
self.update(defaults)
|
||||||
|
return
|
||||||
|
|
||||||
|
Users = self.env["res.users"]
|
||||||
|
query = Users._where_calc([])
|
||||||
|
Users._apply_ir_rules(query)
|
||||||
|
badge_alias = query.join("res_users", "id", "gamification_badge_user", "user_id", "badges")
|
||||||
|
|
||||||
|
tables, where_clauses, where_params = query.get_sql()
|
||||||
|
|
||||||
|
self.env.cr.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {badge_alias}.badge_id, count(res_users.id) as stat_count,
|
||||||
|
count(distinct(res_users.id)) as stat_count_distinct,
|
||||||
|
array_agg(distinct(res_users.id)) as unique_owner_ids
|
||||||
|
FROM {tables}
|
||||||
|
WHERE {where_clauses}
|
||||||
|
AND {badge_alias}.badge_id IN %s
|
||||||
|
GROUP BY {badge_alias}.badge_id
|
||||||
|
""",
|
||||||
|
[*where_params, tuple(self.ids)]
|
||||||
|
)
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
badge_id: {
|
||||||
|
'granted_count': count,
|
||||||
|
'granted_users_count': distinct_count,
|
||||||
|
'unique_owner_ids': owner_ids,
|
||||||
|
}
|
||||||
|
for (badge_id, count, distinct_count, owner_ids) in self.env.cr._obj
|
||||||
|
}
|
||||||
|
for badge in self:
|
||||||
|
badge.update(mapping.get(badge.id, defaults))
|
||||||
|
|
||||||
|
@api.depends('owner_ids.badge_id', 'owner_ids.create_date', 'owner_ids.user_id')
|
||||||
|
def _get_badge_user_stats(self):
|
||||||
|
"""Return stats related to badge users"""
|
||||||
|
first_month_day = date.today().replace(day=1)
|
||||||
|
|
||||||
|
for badge in self:
|
||||||
|
owners = badge.owner_ids
|
||||||
|
badge.stat_my = sum(o.user_id == self.env.user for o in owners)
|
||||||
|
badge.stat_this_month = sum(o.create_date.date() >= first_month_day for o in owners)
|
||||||
|
badge.stat_my_this_month = sum(
|
||||||
|
o.user_id == self.env.user and o.create_date.date() >= first_month_day
|
||||||
|
for o in owners
|
||||||
|
)
|
||||||
|
badge.stat_my_monthly_sending = sum(
|
||||||
|
o.create_uid == self.env.user and o.create_date.date() >= first_month_day
|
||||||
|
for o in owners
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'rule_auth',
|
||||||
|
'rule_auth_user_ids',
|
||||||
|
'rule_auth_badge_ids',
|
||||||
|
'rule_max',
|
||||||
|
'rule_max_number',
|
||||||
|
'stat_my_monthly_sending',
|
||||||
|
)
|
||||||
|
def _remaining_sending_calc(self):
|
||||||
|
"""Computes the number of badges remaining the user can send
|
||||||
|
|
||||||
|
0 if not allowed or no remaining
|
||||||
|
integer if limited sending
|
||||||
|
-1 if infinite (should not be displayed)
|
||||||
|
"""
|
||||||
|
for badge in self:
|
||||||
|
if badge._can_grant_badge() != self.CAN_GRANT:
|
||||||
|
# if the user cannot grant this badge at all, result is 0
|
||||||
|
badge.remaining_sending = 0
|
||||||
|
elif not badge.rule_max:
|
||||||
|
# if there is no limitation, -1 is returned which means 'infinite'
|
||||||
|
badge.remaining_sending = -1
|
||||||
|
else:
|
||||||
|
badge.remaining_sending = badge.rule_max_number - badge.stat_my_monthly_sending
|
||||||
|
|
||||||
|
def check_granting(self):
|
||||||
|
"""Check the user 'uid' can grant the badge 'badge_id' and raise the appropriate exception
|
||||||
|
if not
|
||||||
|
|
||||||
|
Do not check for SUPERUSER_ID
|
||||||
|
"""
|
||||||
|
status_code = self._can_grant_badge()
|
||||||
|
if status_code == self.CAN_GRANT:
|
||||||
|
return True
|
||||||
|
elif status_code == self.NOBODY_CAN_GRANT:
|
||||||
|
raise exceptions.UserError(_('This badge can not be sent by users.'))
|
||||||
|
elif status_code == self.USER_NOT_VIP:
|
||||||
|
raise exceptions.UserError(_('You are not in the user allowed list.'))
|
||||||
|
elif status_code == self.BADGE_REQUIRED:
|
||||||
|
raise exceptions.UserError(_('You do not have the required badges.'))
|
||||||
|
elif status_code == self.TOO_MANY:
|
||||||
|
raise exceptions.UserError(_('You have already sent this badge too many time this month.'))
|
||||||
|
else:
|
||||||
|
_logger.error("Unknown badge status code: %s" % status_code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _can_grant_badge(self):
|
||||||
|
"""Check if a user can grant a badge to another user
|
||||||
|
|
||||||
|
:param uid: the id of the res.users trying to send the badge
|
||||||
|
:param badge_id: the granted badge id
|
||||||
|
:return: integer representing the permission.
|
||||||
|
"""
|
||||||
|
if self.env.is_admin():
|
||||||
|
return self.CAN_GRANT
|
||||||
|
|
||||||
|
if self.rule_auth == 'nobody':
|
||||||
|
return self.NOBODY_CAN_GRANT
|
||||||
|
elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
|
||||||
|
return self.USER_NOT_VIP
|
||||||
|
elif self.rule_auth == 'having':
|
||||||
|
all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
|
||||||
|
if self.rule_auth_badge_ids - all_user_badges:
|
||||||
|
return self.BADGE_REQUIRED
|
||||||
|
|
||||||
|
if self.rule_max and self.stat_my_monthly_sending >= self.rule_max_number:
|
||||||
|
return self.TOO_MANY
|
||||||
|
|
||||||
|
# badge.rule_auth == 'everyone' -> no check
|
||||||
|
return self.CAN_GRANT
|
50
models/gamification_badge_user.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeUser(models.Model):
|
||||||
|
"""User having received a badge"""
|
||||||
|
|
||||||
|
_name = 'gamification.badge.user'
|
||||||
|
_description = 'Gamification User Badge'
|
||||||
|
_order = "create_date desc"
|
||||||
|
_rec_name = "badge_name"
|
||||||
|
|
||||||
|
user_id = fields.Many2one('res.users', string="User", required=True, ondelete="cascade", index=True)
|
||||||
|
sender_id = fields.Many2one('res.users', string="Sender")
|
||||||
|
badge_id = fields.Many2one('gamification.badge', string='Badge', required=True, ondelete="cascade", index=True)
|
||||||
|
challenge_id = fields.Many2one('gamification.challenge', string='Challenge')
|
||||||
|
comment = fields.Text('Comment')
|
||||||
|
badge_name = fields.Char(related='badge_id.name', string="Badge Name", readonly=False)
|
||||||
|
level = fields.Selection(
|
||||||
|
string='Badge Level', related="badge_id.level", store=True, readonly=True)
|
||||||
|
|
||||||
|
def _send_badge(self):
|
||||||
|
"""Send a notification to a user for receiving a badge
|
||||||
|
|
||||||
|
Does not verify constrains on badge granting.
|
||||||
|
The users are added to the owner_ids (create badge_user if needed)
|
||||||
|
The stats counters are incremented
|
||||||
|
:param ids: list(int) of badge users that will receive the badge
|
||||||
|
"""
|
||||||
|
template = self.env.ref(
|
||||||
|
'gamification.email_template_badge_received',
|
||||||
|
raise_if_not_found=False
|
||||||
|
)
|
||||||
|
if not template:
|
||||||
|
return
|
||||||
|
|
||||||
|
for badge_user in self:
|
||||||
|
template.send_mail(
|
||||||
|
badge_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
self.env['gamification.badge'].browse(vals['badge_id']).check_granting()
|
||||||
|
return super().create(vals_list)
|
814
models/gamification_challenge.py
Normal file
|
@ -0,0 +1,814 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
import ast
|
||||||
|
import itertools
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta, MO
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import _, api, exceptions, fields, models
|
||||||
|
from odoo.http import SESSION_LIFETIME
|
||||||
|
from odoo.tools import ustr
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# display top 3 in ranking, could be db variable
|
||||||
|
MAX_VISIBILITY_RANKING = 3
|
||||||
|
|
||||||
|
def start_end_date_for_period(period, default_start_date=False, default_end_date=False):
|
||||||
|
"""Return the start and end date for a goal period based on today
|
||||||
|
|
||||||
|
:param str default_start_date: string date in DEFAULT_SERVER_DATE_FORMAT format
|
||||||
|
:param str default_end_date: string date in DEFAULT_SERVER_DATE_FORMAT format
|
||||||
|
|
||||||
|
:return: (start_date, end_date), dates in string format, False if the period is
|
||||||
|
not defined or unknown"""
|
||||||
|
today = date.today()
|
||||||
|
if period == 'daily':
|
||||||
|
start_date = today
|
||||||
|
end_date = start_date
|
||||||
|
elif period == 'weekly':
|
||||||
|
start_date = today + relativedelta(weekday=MO(-1))
|
||||||
|
end_date = start_date + timedelta(days=7)
|
||||||
|
elif period == 'monthly':
|
||||||
|
start_date = today.replace(day=1)
|
||||||
|
end_date = today + relativedelta(months=1, day=1, days=-1)
|
||||||
|
elif period == 'yearly':
|
||||||
|
start_date = today.replace(month=1, day=1)
|
||||||
|
end_date = today.replace(month=12, day=31)
|
||||||
|
else: # period == 'once':
|
||||||
|
start_date = default_start_date # for manual goal, start each time
|
||||||
|
end_date = default_end_date
|
||||||
|
|
||||||
|
return (start_date, end_date)
|
||||||
|
|
||||||
|
return fields.Datetime.to_string(start_date), fields.Datetime.to_string(end_date)
|
||||||
|
|
||||||
|
class Challenge(models.Model):
|
||||||
|
"""Gamification challenge
|
||||||
|
|
||||||
|
Set of predifined objectives assigned to people with rules for recurrence and
|
||||||
|
rewards
|
||||||
|
|
||||||
|
If 'user_ids' is defined and 'period' is different than 'one', the set will
|
||||||
|
be assigned to the users for each period (eg: every 1st of each month if
|
||||||
|
'monthly' is selected)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = 'gamification.challenge'
|
||||||
|
_description = 'Gamification Challenge'
|
||||||
|
_inherit = 'mail.thread'
|
||||||
|
_order = 'end_date, start_date, name, id'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
res = super().default_get(fields_list)
|
||||||
|
if 'user_domain' in fields_list and 'user_domain' not in res:
|
||||||
|
user_group_id = self.env.ref('base.group_user')
|
||||||
|
res['user_domain'] = f'["&", ("groups_id", "=", "{user_group_id.name}"), ("active", "=", True)]'
|
||||||
|
return res
|
||||||
|
|
||||||
|
# description
|
||||||
|
name = fields.Char("Challenge Name", required=True, translate=True)
|
||||||
|
description = fields.Text("Description", translate=True)
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', "Draft"),
|
||||||
|
('inprogress', "In Progress"),
|
||||||
|
('done', "Done"),
|
||||||
|
], default='draft', copy=False,
|
||||||
|
string="State", required=True, tracking=True)
|
||||||
|
manager_id = fields.Many2one(
|
||||||
|
'res.users', default=lambda self: self.env.uid,
|
||||||
|
string="Responsible")
|
||||||
|
# members
|
||||||
|
user_ids = fields.Many2many('res.users', 'gamification_challenge_users_rel', string="Participants")
|
||||||
|
user_domain = fields.Char("User domain") # Alternative to a list of users
|
||||||
|
user_count = fields.Integer('# Users', compute='_compute_user_count')
|
||||||
|
# periodicity
|
||||||
|
period = fields.Selection([
|
||||||
|
('once', "Non recurring"),
|
||||||
|
('daily', "Daily"),
|
||||||
|
('weekly', "Weekly"),
|
||||||
|
('monthly', "Monthly"),
|
||||||
|
('yearly', "Yearly")
|
||||||
|
], default='once',
|
||||||
|
string="Periodicity",
|
||||||
|
help="Period of automatic goal assignment. If none is selected, should be launched manually.",
|
||||||
|
required=True)
|
||||||
|
start_date = fields.Date("Start Date", help="The day a new challenge will be automatically started. If no periodicity is set, will use this date as the goal start date.")
|
||||||
|
end_date = fields.Date("End Date", help="The day a new challenge will be automatically closed. If no periodicity is set, will use this date as the goal end date.")
|
||||||
|
|
||||||
|
invited_user_ids = fields.Many2many('res.users', 'gamification_invited_user_ids_rel', string="Suggest to users")
|
||||||
|
|
||||||
|
line_ids = fields.One2many('gamification.challenge.line', 'challenge_id',
|
||||||
|
string="Lines",
|
||||||
|
help="List of goals that will be set",
|
||||||
|
required=True, copy=True)
|
||||||
|
|
||||||
|
reward_id = fields.Many2one('gamification.badge', string="For Every Succeeding User")
|
||||||
|
reward_first_id = fields.Many2one('gamification.badge', string="For 1st user")
|
||||||
|
reward_second_id = fields.Many2one('gamification.badge', string="For 2nd user")
|
||||||
|
reward_third_id = fields.Many2one('gamification.badge', string="For 3rd user")
|
||||||
|
reward_failure = fields.Boolean("Reward Bests if not Succeeded?")
|
||||||
|
reward_realtime = fields.Boolean("Reward as soon as every goal is reached", default=True, help="With this option enabled, a user can receive a badge only once. The top 3 badges are still rewarded only at the end of the challenge.")
|
||||||
|
|
||||||
|
visibility_mode = fields.Selection([
|
||||||
|
('personal', "Individual Goals"),
|
||||||
|
('ranking', "Leader Board (Group Ranking)"),
|
||||||
|
], default='personal',
|
||||||
|
string="Display Mode", required=True)
|
||||||
|
|
||||||
|
report_message_frequency = fields.Selection([
|
||||||
|
('never', "Never"),
|
||||||
|
('onchange', "On change"),
|
||||||
|
('daily', "Daily"),
|
||||||
|
('weekly', "Weekly"),
|
||||||
|
('monthly', "Monthly"),
|
||||||
|
('yearly', "Yearly")
|
||||||
|
], default='never',
|
||||||
|
string="Report Frequency", required=True)
|
||||||
|
report_message_group_id = fields.Many2one('discuss.channel', string="Send a copy to", help="Group that will receive a copy of the report in addition to the user")
|
||||||
|
report_template_id = fields.Many2one('mail.template', default=lambda self: self._get_report_template(), string="Report Template", required=True)
|
||||||
|
remind_update_delay = fields.Integer("Non-updated manual goals will be reminded after", help="Never reminded if no value or zero is specified.")
|
||||||
|
last_report_date = fields.Date("Last Report Date", default=fields.Date.today)
|
||||||
|
next_report_date = fields.Date("Next Report Date", compute='_get_next_report_date', store=True)
|
||||||
|
|
||||||
|
challenge_category = fields.Selection([
|
||||||
|
('hr', 'Human Resources / Engagement'),
|
||||||
|
('other', 'Settings / Gamification Tools'),
|
||||||
|
], string="Appears in", required=True, default='hr',
|
||||||
|
help="Define the visibility of the challenge through menus")
|
||||||
|
|
||||||
|
@api.depends('user_ids')
|
||||||
|
def _compute_user_count(self):
|
||||||
|
mapped_data = {}
|
||||||
|
if self.ids:
|
||||||
|
query = """
|
||||||
|
SELECT gamification_challenge_id, count(res_users_id)
|
||||||
|
FROM gamification_challenge_users_rel rel
|
||||||
|
LEFT JOIN res_users users
|
||||||
|
ON users.id=rel.res_users_id AND users.active = TRUE
|
||||||
|
WHERE gamification_challenge_id IN %s
|
||||||
|
GROUP BY gamification_challenge_id
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(query, [tuple(self.ids)])
|
||||||
|
mapped_data = dict(
|
||||||
|
(challenge_id, user_count)
|
||||||
|
for challenge_id, user_count in self.env.cr.fetchall()
|
||||||
|
)
|
||||||
|
for challenge in self:
|
||||||
|
challenge.user_count = mapped_data.get(challenge.id, 0)
|
||||||
|
|
||||||
|
REPORT_OFFSETS = {
|
||||||
|
'daily': timedelta(days=1),
|
||||||
|
'weekly': timedelta(days=7),
|
||||||
|
'monthly': relativedelta(months=1),
|
||||||
|
'yearly': relativedelta(years=1),
|
||||||
|
}
|
||||||
|
@api.depends('last_report_date', 'report_message_frequency')
|
||||||
|
def _get_next_report_date(self):
|
||||||
|
""" Return the next report date based on the last report date and
|
||||||
|
report period.
|
||||||
|
"""
|
||||||
|
for challenge in self:
|
||||||
|
last = challenge.last_report_date
|
||||||
|
offset = self.REPORT_OFFSETS.get(challenge.report_message_frequency)
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
challenge.next_report_date = last + offset
|
||||||
|
else:
|
||||||
|
challenge.next_report_date = False
|
||||||
|
|
||||||
|
def _get_report_template(self):
|
||||||
|
template = self.env.ref('gamification.simple_report_template', raise_if_not_found=False)
|
||||||
|
|
||||||
|
return template.id if template else False
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Overwrite the create method to add the user of groups"""
|
||||||
|
for vals in vals_list:
|
||||||
|
if vals.get('user_domain'):
|
||||||
|
users = self._get_challenger_users(ustr(vals.get('user_domain')))
|
||||||
|
|
||||||
|
if not vals.get('user_ids'):
|
||||||
|
vals['user_ids'] = []
|
||||||
|
vals['user_ids'].extend((4, user.id) for user in users)
|
||||||
|
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if vals.get('user_domain'):
|
||||||
|
users = self._get_challenger_users(ustr(vals.get('user_domain')))
|
||||||
|
|
||||||
|
if not vals.get('user_ids'):
|
||||||
|
vals['user_ids'] = []
|
||||||
|
vals['user_ids'].extend((4, user.id) for user in users)
|
||||||
|
|
||||||
|
write_res = super(Challenge, self).write(vals)
|
||||||
|
|
||||||
|
if vals.get('report_message_frequency', 'never') != 'never':
|
||||||
|
# _recompute_challenge_users do not set users for challenges with no reports, subscribing them now
|
||||||
|
for challenge in self:
|
||||||
|
challenge.message_subscribe([user.partner_id.id for user in challenge.user_ids])
|
||||||
|
|
||||||
|
if vals.get('state') == 'inprogress':
|
||||||
|
self._recompute_challenge_users()
|
||||||
|
self._generate_goals_from_challenge()
|
||||||
|
|
||||||
|
elif vals.get('state') == 'done':
|
||||||
|
self._check_challenge_reward(force=True)
|
||||||
|
|
||||||
|
elif vals.get('state') == 'draft':
|
||||||
|
# resetting progress
|
||||||
|
if self.env['gamification.goal'].search([('challenge_id', 'in', self.ids), ('state', '=', 'inprogress')], limit=1):
|
||||||
|
raise exceptions.UserError(_("You can not reset a challenge with unfinished goals."))
|
||||||
|
|
||||||
|
return write_res
|
||||||
|
|
||||||
|
|
||||||
|
##### Update #####
|
||||||
|
|
||||||
|
@api.model # FIXME: check how cron functions are called to see if decorator necessary
|
||||||
|
def _cron_update(self, ids=False, commit=True):
|
||||||
|
"""Daily cron check.
|
||||||
|
|
||||||
|
- Start planned challenges (in draft and with start_date = today)
|
||||||
|
- Create the missing goals (eg: modified the challenge to add lines)
|
||||||
|
- Update every running challenge
|
||||||
|
"""
|
||||||
|
# in cron mode, will do intermediate commits
|
||||||
|
# cannot be replaced by a parameter because it is intended to impact side-effects of
|
||||||
|
# write operations
|
||||||
|
self = self.with_context(commit_gamification=commit)
|
||||||
|
# start scheduled challenges
|
||||||
|
planned_challenges = self.search([
|
||||||
|
('state', '=', 'draft'),
|
||||||
|
('start_date', '<=', fields.Date.today())
|
||||||
|
])
|
||||||
|
if planned_challenges:
|
||||||
|
planned_challenges.write({'state': 'inprogress'})
|
||||||
|
|
||||||
|
# close scheduled challenges
|
||||||
|
scheduled_challenges = self.search([
|
||||||
|
('state', '=', 'inprogress'),
|
||||||
|
('end_date', '<', fields.Date.today())
|
||||||
|
])
|
||||||
|
if scheduled_challenges:
|
||||||
|
scheduled_challenges.write({'state': 'done'})
|
||||||
|
|
||||||
|
records = self.browse(ids) if ids else self.search([('state', '=', 'inprogress')])
|
||||||
|
|
||||||
|
return records._update_all()
|
||||||
|
|
||||||
|
def _update_all(self):
|
||||||
|
"""Update the challenges and related goals."""
|
||||||
|
if not self:
|
||||||
|
return True
|
||||||
|
|
||||||
|
Goals = self.env['gamification.goal']
|
||||||
|
|
||||||
|
# include yesterday goals to update the goals that just ended
|
||||||
|
# exclude goals for users that have not interacted with the
|
||||||
|
# webclient since the last update or whose session is no longer
|
||||||
|
# valid.
|
||||||
|
yesterday = fields.Date.to_string(date.today() - timedelta(days=1))
|
||||||
|
self.env.cr.execute("""SELECT gg.id
|
||||||
|
FROM gamification_goal as gg
|
||||||
|
JOIN bus_presence as bp ON bp.user_id = gg.user_id
|
||||||
|
WHERE gg.write_date <= bp.last_presence
|
||||||
|
AND bp.last_presence >= now() AT TIME ZONE 'UTC' - interval '%(session_lifetime)s seconds'
|
||||||
|
AND gg.closed IS NOT TRUE
|
||||||
|
AND gg.challenge_id IN %(challenge_ids)s
|
||||||
|
AND (gg.state = 'inprogress'
|
||||||
|
OR (gg.state = 'reached' AND gg.end_date >= %(yesterday)s))
|
||||||
|
GROUP BY gg.id
|
||||||
|
""", {
|
||||||
|
'session_lifetime': SESSION_LIFETIME,
|
||||||
|
'challenge_ids': tuple(self.ids),
|
||||||
|
'yesterday': yesterday
|
||||||
|
})
|
||||||
|
|
||||||
|
Goals.browse(goal_id for [goal_id] in self.env.cr.fetchall()).update_goal()
|
||||||
|
|
||||||
|
self._recompute_challenge_users()
|
||||||
|
self._generate_goals_from_challenge()
|
||||||
|
|
||||||
|
for challenge in self:
|
||||||
|
if challenge.last_report_date != fields.Date.today():
|
||||||
|
if challenge.next_report_date and fields.Date.today() >= challenge.next_report_date:
|
||||||
|
challenge.report_progress()
|
||||||
|
else:
|
||||||
|
# goals closed but still opened at the last report date
|
||||||
|
closed_goals_to_report = Goals.search([
|
||||||
|
('challenge_id', '=', challenge.id),
|
||||||
|
('start_date', '>=', challenge.last_report_date),
|
||||||
|
('end_date', '<=', challenge.last_report_date)
|
||||||
|
])
|
||||||
|
if closed_goals_to_report:
|
||||||
|
# some goals need a final report
|
||||||
|
challenge.report_progress(subset_goals=closed_goals_to_report)
|
||||||
|
|
||||||
|
self._check_challenge_reward()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_challenger_users(self, domain):
|
||||||
|
user_domain = ast.literal_eval(domain)
|
||||||
|
return self.env['res.users'].search(user_domain)
|
||||||
|
|
||||||
|
def _recompute_challenge_users(self):
|
||||||
|
"""Recompute the domain to add new users and remove the one no longer matching the domain"""
|
||||||
|
for challenge in self.filtered(lambda c: c.user_domain):
|
||||||
|
current_users = challenge.user_ids
|
||||||
|
new_users = self._get_challenger_users(challenge.user_domain)
|
||||||
|
|
||||||
|
if current_users != new_users:
|
||||||
|
challenge.user_ids = new_users
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_start(self):
|
||||||
|
"""Start a challenge"""
|
||||||
|
return self.write({'state': 'inprogress'})
|
||||||
|
|
||||||
|
def action_check(self):
|
||||||
|
"""Check a challenge
|
||||||
|
|
||||||
|
Create goals that haven't been created yet (eg: if added users)
|
||||||
|
Recompute the current value for each goal related"""
|
||||||
|
self.env['gamification.goal'].search([
|
||||||
|
('challenge_id', 'in', self.ids),
|
||||||
|
('state', '=', 'inprogress')
|
||||||
|
]).unlink()
|
||||||
|
|
||||||
|
return self._update_all()
|
||||||
|
|
||||||
|
def action_report_progress(self):
|
||||||
|
"""Manual report of a goal, does not influence automatic report frequency"""
|
||||||
|
for challenge in self:
|
||||||
|
challenge.report_progress()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_view_users(self):
|
||||||
|
""" Redirect to the participants (users) list. """
|
||||||
|
action = self.env["ir.actions.actions"]._for_xml_id("base.action_res_users")
|
||||||
|
action['domain'] = [('id', 'in', self.user_ids.ids)]
|
||||||
|
return action
|
||||||
|
|
||||||
|
##### Automatic actions #####
|
||||||
|
|
||||||
|
def _generate_goals_from_challenge(self):
|
||||||
|
"""Generate the goals for each line and user.
|
||||||
|
|
||||||
|
If goals already exist for this line and user, the line is skipped. This
|
||||||
|
can be called after each change in the list of users or lines.
|
||||||
|
:param list(int) ids: the list of challenge concerned"""
|
||||||
|
|
||||||
|
Goals = self.env['gamification.goal']
|
||||||
|
for challenge in self:
|
||||||
|
(start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
|
||||||
|
to_update = Goals.browse(())
|
||||||
|
|
||||||
|
for line in challenge.line_ids:
|
||||||
|
# there is potentially a lot of users
|
||||||
|
# detect the ones with no goal linked to this line
|
||||||
|
date_clause = ""
|
||||||
|
query_params = [line.id]
|
||||||
|
if start_date:
|
||||||
|
date_clause += " AND g.start_date = %s"
|
||||||
|
query_params.append(start_date)
|
||||||
|
if end_date:
|
||||||
|
date_clause += " AND g.end_date = %s"
|
||||||
|
query_params.append(end_date)
|
||||||
|
|
||||||
|
query = """SELECT u.id AS user_id
|
||||||
|
FROM res_users u
|
||||||
|
LEFT JOIN gamification_goal g
|
||||||
|
ON (u.id = g.user_id)
|
||||||
|
WHERE line_id = %s
|
||||||
|
{date_clause}
|
||||||
|
""".format(date_clause=date_clause)
|
||||||
|
self.env.cr.execute(query, query_params)
|
||||||
|
user_with_goal_ids = {it for [it] in self.env.cr._obj}
|
||||||
|
|
||||||
|
participant_user_ids = set(challenge.user_ids.ids)
|
||||||
|
user_squating_challenge_ids = user_with_goal_ids - participant_user_ids
|
||||||
|
if user_squating_challenge_ids:
|
||||||
|
# users that used to match the challenge
|
||||||
|
Goals.search([
|
||||||
|
('challenge_id', '=', challenge.id),
|
||||||
|
('user_id', 'in', list(user_squating_challenge_ids))
|
||||||
|
]).unlink()
|
||||||
|
|
||||||
|
values = {
|
||||||
|
'definition_id': line.definition_id.id,
|
||||||
|
'line_id': line.id,
|
||||||
|
'target_goal': line.target_goal,
|
||||||
|
'state': 'inprogress',
|
||||||
|
}
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
values['start_date'] = start_date
|
||||||
|
if end_date:
|
||||||
|
values['end_date'] = end_date
|
||||||
|
|
||||||
|
# the goal is initialised over the limit to make sure we will compute it at least once
|
||||||
|
if line.condition == 'higher':
|
||||||
|
values['current'] = min(line.target_goal - 1, 0)
|
||||||
|
else:
|
||||||
|
values['current'] = max(line.target_goal + 1, 0)
|
||||||
|
|
||||||
|
if challenge.remind_update_delay:
|
||||||
|
values['remind_update_delay'] = challenge.remind_update_delay
|
||||||
|
|
||||||
|
for user_id in (participant_user_ids - user_with_goal_ids):
|
||||||
|
values['user_id'] = user_id
|
||||||
|
to_update |= Goals.create(values)
|
||||||
|
|
||||||
|
to_update.update_goal()
|
||||||
|
|
||||||
|
if self.env.context.get('commit_gamification'):
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
##### JS utilities #####
|
||||||
|
|
||||||
|
def _get_serialized_challenge_lines(self, user=(), restrict_goals=(), restrict_top=0):
|
||||||
|
"""Return a serialised version of the goals information if the user has not completed every goal
|
||||||
|
|
||||||
|
:param user: user retrieving progress (False if no distinction,
|
||||||
|
only for ranking challenges)
|
||||||
|
:param restrict_goals: compute only the results for this subset of
|
||||||
|
gamification.goal ids, if False retrieve every
|
||||||
|
goal of current running challenge
|
||||||
|
:param int restrict_top: for challenge lines where visibility_mode is
|
||||||
|
``ranking``, retrieve only the best
|
||||||
|
``restrict_top`` results and itself, if 0
|
||||||
|
retrieve all restrict_goal_ids has priority
|
||||||
|
over restrict_top
|
||||||
|
|
||||||
|
format list
|
||||||
|
# if visibility_mode == 'ranking'
|
||||||
|
{
|
||||||
|
'name': <gamification.goal.description name>,
|
||||||
|
'description': <gamification.goal.description description>,
|
||||||
|
'condition': <reach condition {lower,higher}>,
|
||||||
|
'computation_mode': <target computation {manually,count,sum,python}>,
|
||||||
|
'monetary': <{True,False}>,
|
||||||
|
'suffix': <value suffix>,
|
||||||
|
'action': <{True,False}>,
|
||||||
|
'display_mode': <{progress,boolean}>,
|
||||||
|
'target': <challenge line target>,
|
||||||
|
'own_goal_id': <gamification.goal id where user_id == uid>,
|
||||||
|
'goals': [
|
||||||
|
{
|
||||||
|
'id': <gamification.goal id>,
|
||||||
|
'rank': <user ranking>,
|
||||||
|
'user_id': <res.users id>,
|
||||||
|
'name': <res.users name>,
|
||||||
|
'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>,
|
||||||
|
'completeness': <percentage>,
|
||||||
|
'current': <current value>,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
# if visibility_mode == 'personal'
|
||||||
|
{
|
||||||
|
'id': <gamification.goal id>,
|
||||||
|
'name': <gamification.goal.description name>,
|
||||||
|
'description': <gamification.goal.description description>,
|
||||||
|
'condition': <reach condition {lower,higher}>,
|
||||||
|
'computation_mode': <target computation {manually,count,sum,python}>,
|
||||||
|
'monetary': <{True,False}>,
|
||||||
|
'suffix': <value suffix>,
|
||||||
|
'action': <{True,False}>,
|
||||||
|
'display_mode': <{progress,boolean}>,
|
||||||
|
'target': <challenge line target>,
|
||||||
|
'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>,
|
||||||
|
'completeness': <percentage>,
|
||||||
|
'current': <current value>,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
Goals = self.env['gamification.goal']
|
||||||
|
(start_date, end_date) = start_end_date_for_period(self.period)
|
||||||
|
|
||||||
|
res_lines = []
|
||||||
|
for line in self.line_ids:
|
||||||
|
line_data = {
|
||||||
|
'name': line.definition_id.name,
|
||||||
|
'description': line.definition_id.description,
|
||||||
|
'condition': line.definition_id.condition,
|
||||||
|
'computation_mode': line.definition_id.computation_mode,
|
||||||
|
'monetary': line.definition_id.monetary,
|
||||||
|
'suffix': line.definition_id.suffix,
|
||||||
|
'action': True if line.definition_id.action_id else False,
|
||||||
|
'display_mode': line.definition_id.display_mode,
|
||||||
|
'target': line.target_goal,
|
||||||
|
}
|
||||||
|
domain = [
|
||||||
|
('line_id', '=', line.id),
|
||||||
|
('state', '!=', 'draft'),
|
||||||
|
]
|
||||||
|
if restrict_goals:
|
||||||
|
domain.append(('id', 'in', restrict_goals.ids))
|
||||||
|
else:
|
||||||
|
# if no subset goals, use the dates for restriction
|
||||||
|
if start_date:
|
||||||
|
domain.append(('start_date', '=', start_date))
|
||||||
|
if end_date:
|
||||||
|
domain.append(('end_date', '=', end_date))
|
||||||
|
|
||||||
|
if self.visibility_mode == 'personal':
|
||||||
|
if not user:
|
||||||
|
raise exceptions.UserError(_("Retrieving progress for personal challenge without user information"))
|
||||||
|
|
||||||
|
domain.append(('user_id', '=', user.id))
|
||||||
|
|
||||||
|
goal = Goals.search_fetch(domain, ['current', 'completeness', 'state'], limit=1)
|
||||||
|
if not goal:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if goal.state != 'reached':
|
||||||
|
return []
|
||||||
|
line_data.update({
|
||||||
|
fname: goal[fname]
|
||||||
|
for fname in ['id', 'current', 'completeness', 'state']
|
||||||
|
})
|
||||||
|
res_lines.append(line_data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_data['own_goal_id'] = False,
|
||||||
|
line_data['goals'] = []
|
||||||
|
if line.condition=='higher':
|
||||||
|
goals = Goals.search(domain, order="completeness desc, current desc")
|
||||||
|
else:
|
||||||
|
goals = Goals.search(domain, order="completeness desc, current asc")
|
||||||
|
if not goals:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for ranking, goal in enumerate(goals):
|
||||||
|
if user and goal.user_id == user:
|
||||||
|
line_data['own_goal_id'] = goal.id
|
||||||
|
elif restrict_top and ranking > restrict_top:
|
||||||
|
# not own goal and too low to be in top
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_data['goals'].append({
|
||||||
|
'id': goal.id,
|
||||||
|
'user_id': goal.user_id.id,
|
||||||
|
'name': goal.user_id.name,
|
||||||
|
'rank': ranking,
|
||||||
|
'current': goal.current,
|
||||||
|
'completeness': goal.completeness,
|
||||||
|
'state': goal.state,
|
||||||
|
})
|
||||||
|
if len(goals) < 3:
|
||||||
|
# display at least the top 3 in the results
|
||||||
|
missing = 3 - len(goals)
|
||||||
|
for ranking, mock_goal in enumerate([{'id': False,
|
||||||
|
'user_id': False,
|
||||||
|
'name': '',
|
||||||
|
'current': 0,
|
||||||
|
'completeness': 0,
|
||||||
|
'state': False}] * missing,
|
||||||
|
start=len(goals)):
|
||||||
|
mock_goal['rank'] = ranking
|
||||||
|
line_data['goals'].append(mock_goal)
|
||||||
|
|
||||||
|
res_lines.append(line_data)
|
||||||
|
return res_lines
|
||||||
|
|
||||||
|
##### Reporting #####
|
||||||
|
|
||||||
|
def report_progress(self, users=(), subset_goals=False):
|
||||||
|
"""Post report about the progress of the goals
|
||||||
|
|
||||||
|
:param users: users that are concerned by the report. If False, will
|
||||||
|
send the report to every user concerned (goal users and
|
||||||
|
group that receive a copy). Only used for challenge with
|
||||||
|
a visibility mode set to 'personal'.
|
||||||
|
:param subset_goals: goals to restrict the report
|
||||||
|
"""
|
||||||
|
|
||||||
|
challenge = self
|
||||||
|
|
||||||
|
if challenge.visibility_mode == 'ranking':
|
||||||
|
lines_boards = challenge._get_serialized_challenge_lines(restrict_goals=subset_goals)
|
||||||
|
|
||||||
|
body_html = challenge.report_template_id.with_context(challenge_lines=lines_boards)._render_field('body_html', challenge.ids)[challenge.id]
|
||||||
|
|
||||||
|
# send to every follower and participant of the challenge
|
||||||
|
challenge.message_post(
|
||||||
|
body=body_html,
|
||||||
|
partner_ids=challenge.mapped('user_ids.partner_id.id'),
|
||||||
|
subtype_xmlid='mail.mt_comment',
|
||||||
|
email_layout_xmlid='mail.mail_notification_light',
|
||||||
|
)
|
||||||
|
if challenge.report_message_group_id:
|
||||||
|
challenge.report_message_group_id.message_post(
|
||||||
|
body=body_html,
|
||||||
|
subtype_xmlid='mail.mt_comment')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# generate individual reports
|
||||||
|
for user in (users or challenge.user_ids):
|
||||||
|
lines = challenge._get_serialized_challenge_lines(user, restrict_goals=subset_goals)
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
|
||||||
|
body_html = challenge.report_template_id.with_user(user).with_context(challenge_lines=lines)._render_field('body_html', challenge.ids)[challenge.id]
|
||||||
|
|
||||||
|
# notify message only to users, do not post on the challenge
|
||||||
|
challenge.message_notify(
|
||||||
|
body=body_html,
|
||||||
|
partner_ids=[user.partner_id.id],
|
||||||
|
subtype_xmlid='mail.mt_comment',
|
||||||
|
email_layout_xmlid='mail.mail_notification_light',
|
||||||
|
)
|
||||||
|
if challenge.report_message_group_id:
|
||||||
|
challenge.report_message_group_id.message_post(
|
||||||
|
body=body_html,
|
||||||
|
subtype_xmlid='mail.mt_comment',
|
||||||
|
email_layout_xmlid='mail.mail_notification_light',
|
||||||
|
)
|
||||||
|
return challenge.write({'last_report_date': fields.Date.today()})
|
||||||
|
|
||||||
|
##### Challenges #####
|
||||||
|
def accept_challenge(self):
|
||||||
|
user = self.env.user
|
||||||
|
sudoed = self.sudo()
|
||||||
|
sudoed.message_post(body=_("%s has joined the challenge", user.name))
|
||||||
|
sudoed.write({'invited_user_ids': [(3, user.id)], 'user_ids': [(4, user.id)]})
|
||||||
|
return sudoed._generate_goals_from_challenge()
|
||||||
|
|
||||||
|
def discard_challenge(self):
|
||||||
|
"""The user discard the suggested challenge"""
|
||||||
|
user = self.env.user
|
||||||
|
sudoed = self.sudo()
|
||||||
|
sudoed.message_post(body=_("%s has refused the challenge", user.name))
|
||||||
|
return sudoed.write({'invited_user_ids': (3, user.id)})
|
||||||
|
|
||||||
|
def _check_challenge_reward(self, force=False):
|
||||||
|
"""Actions for the end of a challenge
|
||||||
|
|
||||||
|
If a reward was selected, grant it to the correct users.
|
||||||
|
Rewards granted at:
|
||||||
|
- the end date for a challenge with no periodicity
|
||||||
|
- the end of a period for challenge with periodicity
|
||||||
|
- when a challenge is manually closed
|
||||||
|
(if no end date, a running challenge is never rewarded)
|
||||||
|
"""
|
||||||
|
commit = self.env.context.get('commit_gamification') and self.env.cr.commit
|
||||||
|
|
||||||
|
for challenge in self:
|
||||||
|
(start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
|
||||||
|
rewarded_users = self.env['res.users']
|
||||||
|
challenge_ended = force or end_date == fields.Date.to_string(yesterday)
|
||||||
|
if challenge.reward_id and (challenge_ended or challenge.reward_realtime):
|
||||||
|
# not using start_date as intemportal goals have a start date but no end_date
|
||||||
|
reached_goals = self.env['gamification.goal']._read_group([
|
||||||
|
('challenge_id', '=', challenge.id),
|
||||||
|
('end_date', '=', end_date),
|
||||||
|
('state', '=', 'reached')
|
||||||
|
], groupby=['user_id'], aggregates=['__count'])
|
||||||
|
for user, count in reached_goals:
|
||||||
|
if count == len(challenge.line_ids):
|
||||||
|
# the user has succeeded every assigned goal
|
||||||
|
if challenge.reward_realtime:
|
||||||
|
badges = self.env['gamification.badge.user'].search_count([
|
||||||
|
('challenge_id', '=', challenge.id),
|
||||||
|
('badge_id', '=', challenge.reward_id.id),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
])
|
||||||
|
if badges > 0:
|
||||||
|
# has already recieved the badge for this challenge
|
||||||
|
continue
|
||||||
|
challenge._reward_user(user, challenge.reward_id)
|
||||||
|
rewarded_users |= user
|
||||||
|
if commit:
|
||||||
|
commit()
|
||||||
|
|
||||||
|
if challenge_ended:
|
||||||
|
# open chatter message
|
||||||
|
message_body = _("The challenge %s is finished.", challenge.name)
|
||||||
|
|
||||||
|
if rewarded_users:
|
||||||
|
message_body += Markup("<br/>") + _(
|
||||||
|
"Reward (badge %(badge_name)s) for every succeeding user was sent to %(users)s.",
|
||||||
|
badge_name=challenge.reward_id.name,
|
||||||
|
users=", ".join(rewarded_users.mapped('display_name'))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message_body += Markup("<br/>") + _("Nobody has succeeded to reach every goal, no badge is rewarded for this challenge.")
|
||||||
|
|
||||||
|
# reward bests
|
||||||
|
reward_message = Markup("<br/> %(rank)d. %(user_name)s - %(reward_name)s")
|
||||||
|
if challenge.reward_first_id:
|
||||||
|
(first_user, second_user, third_user) = challenge._get_topN_users(MAX_VISIBILITY_RANKING)
|
||||||
|
if first_user:
|
||||||
|
challenge._reward_user(first_user, challenge.reward_first_id)
|
||||||
|
message_body += Markup("<br/>") + _("Special rewards were sent to the top competing users. The ranking for this challenge is:")
|
||||||
|
message_body += reward_message % {
|
||||||
|
'rank': 1,
|
||||||
|
'user_name': first_user.name,
|
||||||
|
'reward_name': challenge.reward_first_id.name,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
message_body += _("Nobody reached the required conditions to receive special badges.")
|
||||||
|
|
||||||
|
if second_user and challenge.reward_second_id:
|
||||||
|
challenge._reward_user(second_user, challenge.reward_second_id)
|
||||||
|
message_body += reward_message % {
|
||||||
|
'rank': 2,
|
||||||
|
'user_name': second_user.name,
|
||||||
|
'reward_name': challenge.reward_second_id.name,
|
||||||
|
}
|
||||||
|
if third_user and challenge.reward_third_id:
|
||||||
|
challenge._reward_user(third_user, challenge.reward_third_id)
|
||||||
|
message_body += reward_message % {
|
||||||
|
'rank': 3,
|
||||||
|
'user_name': third_user.name,
|
||||||
|
'reward_name': challenge.reward_third_id.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge.message_post(
|
||||||
|
partner_ids=[user.partner_id.id for user in challenge.user_ids],
|
||||||
|
body=message_body)
|
||||||
|
if commit:
|
||||||
|
commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_topN_users(self, n):
|
||||||
|
"""Get the top N users for a defined challenge
|
||||||
|
|
||||||
|
Ranking criterias:
|
||||||
|
1. succeed every goal of the challenge
|
||||||
|
2. total completeness of each goal (can be over 100)
|
||||||
|
|
||||||
|
Only users having reached every goal of the challenge will be returned
|
||||||
|
unless the challenge ``reward_failure`` is set, in which case any user
|
||||||
|
may be considered.
|
||||||
|
|
||||||
|
:returns: an iterable of exactly N records, either User objects or
|
||||||
|
False if there was no user for the rank. There can be no
|
||||||
|
False between two users (if users[k] = False then
|
||||||
|
users[k+1] = False
|
||||||
|
"""
|
||||||
|
Goals = self.env['gamification.goal']
|
||||||
|
(start_date, end_date) = start_end_date_for_period(self.period, self.start_date, self.end_date)
|
||||||
|
challengers = []
|
||||||
|
for user in self.user_ids:
|
||||||
|
all_reached = True
|
||||||
|
total_completeness = 0
|
||||||
|
# every goal of the user for the running period
|
||||||
|
goal_ids = Goals.search([
|
||||||
|
('challenge_id', '=', self.id),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('start_date', '=', start_date),
|
||||||
|
('end_date', '=', end_date)
|
||||||
|
])
|
||||||
|
for goal in goal_ids:
|
||||||
|
if goal.state != 'reached':
|
||||||
|
all_reached = False
|
||||||
|
if goal.definition_condition == 'higher':
|
||||||
|
# can be over 100
|
||||||
|
total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0
|
||||||
|
elif goal.state == 'reached':
|
||||||
|
# for lower goals, can not get percentage so 0 or 100
|
||||||
|
total_completeness += 100
|
||||||
|
|
||||||
|
challengers.append({'user': user, 'all_reached': all_reached, 'total_completeness': total_completeness})
|
||||||
|
|
||||||
|
challengers.sort(key=lambda k: (k['all_reached'], k['total_completeness']), reverse=True)
|
||||||
|
if not self.reward_failure:
|
||||||
|
# only keep the fully successful challengers at the front, could
|
||||||
|
# probably use filter since the successful ones are at the front
|
||||||
|
challengers = itertools.takewhile(lambda c: c['all_reached'], challengers)
|
||||||
|
|
||||||
|
# append a tail of False, then keep the first N
|
||||||
|
challengers = itertools.islice(
|
||||||
|
itertools.chain(
|
||||||
|
(c['user'] for c in challengers),
|
||||||
|
itertools.repeat(False),
|
||||||
|
), 0, n
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(challengers)
|
||||||
|
|
||||||
|
def _reward_user(self, user, badge):
|
||||||
|
"""Create a badge user and send the badge to him
|
||||||
|
|
||||||
|
:param user: the user to reward
|
||||||
|
:param badge: the concerned badge
|
||||||
|
"""
|
||||||
|
return self.env['gamification.badge.user'].create({
|
||||||
|
'user_id': user.id,
|
||||||
|
'badge_id': badge.id,
|
||||||
|
'challenge_id': self.id
|
||||||
|
})._send_badge()
|
28
models/gamification_challenge_line.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeLine(models.Model):
|
||||||
|
"""Gamification challenge line
|
||||||
|
|
||||||
|
Predefined goal for 'gamification_challenge'
|
||||||
|
These are generic list of goals with only the target goal defined
|
||||||
|
Should only be created for the gamification.challenge object
|
||||||
|
"""
|
||||||
|
_name = 'gamification.challenge.line'
|
||||||
|
_description = 'Gamification generic goal for challenge'
|
||||||
|
_order = "sequence, id"
|
||||||
|
|
||||||
|
challenge_id = fields.Many2one('gamification.challenge', string='Challenge', required=True, ondelete="cascade")
|
||||||
|
definition_id = fields.Many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade")
|
||||||
|
|
||||||
|
sequence = fields.Integer('Sequence', default=1)
|
||||||
|
target_goal = fields.Float('Target Value to Reach', required=True)
|
||||||
|
|
||||||
|
name = fields.Char("Name", related='definition_id.name', readonly=False)
|
||||||
|
condition = fields.Selection(string="Condition", related='definition_id.condition', readonly=True)
|
||||||
|
definition_suffix = fields.Char("Unit", related='definition_id.suffix', readonly=True)
|
||||||
|
definition_monetary = fields.Boolean("Monetary", related='definition_id.monetary', readonly=True)
|
||||||
|
definition_full_suffix = fields.Char("Suffix", related='definition_id.full_suffix', readonly=True)
|
322
models/gamification_goal.py
Normal file
|
@ -0,0 +1,322 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _, exceptions
|
||||||
|
from odoo.tools.safe_eval import safe_eval, time
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(models.Model):
|
||||||
|
"""Goal instance for a user
|
||||||
|
|
||||||
|
An individual goal for a user on a specified time period"""
|
||||||
|
|
||||||
|
_name = 'gamification.goal'
|
||||||
|
_description = 'Gamification Goal'
|
||||||
|
_rec_name = 'definition_id'
|
||||||
|
_order = 'start_date desc, end_date desc, definition_id, id'
|
||||||
|
|
||||||
|
definition_id = fields.Many2one('gamification.goal.definition', string="Goal Definition", required=True, ondelete="cascade")
|
||||||
|
user_id = fields.Many2one('res.users', string="User", required=True, auto_join=True, ondelete="cascade")
|
||||||
|
line_id = fields.Many2one('gamification.challenge.line', string="Challenge Line", ondelete="cascade")
|
||||||
|
challenge_id = fields.Many2one(
|
||||||
|
related='line_id.challenge_id', store=True, readonly=True, index=True,
|
||||||
|
help="Challenge that generated the goal, assign challenge to users "
|
||||||
|
"to generate goals with a value in this field.")
|
||||||
|
start_date = fields.Date("Start Date", default=fields.Date.today)
|
||||||
|
end_date = fields.Date("End Date") # no start and end = always active
|
||||||
|
target_goal = fields.Float('To Reach', required=True)
|
||||||
|
# no goal = global index
|
||||||
|
current = fields.Float("Current Value", required=True, default=0)
|
||||||
|
completeness = fields.Float("Completeness", compute='_get_completion')
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', "Draft"),
|
||||||
|
('inprogress', "In progress"),
|
||||||
|
('reached', "Reached"),
|
||||||
|
('failed', "Failed"),
|
||||||
|
('canceled', "Canceled"),
|
||||||
|
], default='draft', string='State', required=True)
|
||||||
|
to_update = fields.Boolean('To update')
|
||||||
|
closed = fields.Boolean('Closed goal')
|
||||||
|
|
||||||
|
computation_mode = fields.Selection(related='definition_id.computation_mode', readonly=False)
|
||||||
|
remind_update_delay = fields.Integer(
|
||||||
|
"Remind delay", help="The number of days after which the user "
|
||||||
|
"assigned to a manual goal will be reminded. "
|
||||||
|
"Never reminded if no value is specified.")
|
||||||
|
last_update = fields.Date(
|
||||||
|
"Last Update",
|
||||||
|
help="In case of manual goal, reminders are sent if the goal as not "
|
||||||
|
"been updated for a while (defined in challenge). Ignored in "
|
||||||
|
"case of non-manual goal or goal not linked to a challenge.")
|
||||||
|
|
||||||
|
definition_description = fields.Text("Definition Description", related='definition_id.description', readonly=True)
|
||||||
|
definition_condition = fields.Selection(string="Definition Condition", related='definition_id.condition', readonly=True)
|
||||||
|
definition_suffix = fields.Char("Suffix", related='definition_id.full_suffix', readonly=True)
|
||||||
|
definition_display = fields.Selection(string="Display Mode", related='definition_id.display_mode', readonly=True)
|
||||||
|
|
||||||
|
@api.depends('current', 'target_goal', 'definition_id.condition')
|
||||||
|
def _get_completion(self):
|
||||||
|
"""Return the percentage of completeness of the goal, between 0 and 100"""
|
||||||
|
for goal in self:
|
||||||
|
if goal.definition_condition == 'higher':
|
||||||
|
if goal.current >= goal.target_goal:
|
||||||
|
goal.completeness = 100.0
|
||||||
|
else:
|
||||||
|
goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) if goal.target_goal else 0
|
||||||
|
elif goal.current < goal.target_goal:
|
||||||
|
# a goal 'lower than' has only two values possible: 0 or 100%
|
||||||
|
goal.completeness = 100.0
|
||||||
|
else:
|
||||||
|
goal.completeness = 0.0
|
||||||
|
|
||||||
|
def _check_remind_delay(self):
|
||||||
|
"""Verify if a goal has not been updated for some time and send a
|
||||||
|
reminder message of needed.
|
||||||
|
|
||||||
|
:return: data to write on the goal object
|
||||||
|
"""
|
||||||
|
if not (self.remind_update_delay and self.last_update):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
delta_max = timedelta(days=self.remind_update_delay)
|
||||||
|
last_update = fields.Date.from_string(self.last_update)
|
||||||
|
if date.today() - last_update < delta_max:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# generate a reminder report
|
||||||
|
body_html = self.env.ref('gamification.email_template_goal_reminder')._render_field('body_html', self.ids, compute_lang=True)[self.id]
|
||||||
|
self.message_notify(
|
||||||
|
body=body_html,
|
||||||
|
partner_ids=[self.user_id.partner_id.id],
|
||||||
|
subtype_xmlid='mail.mt_comment',
|
||||||
|
email_layout_xmlid='mail.mail_notification_light',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'to_update': True}
|
||||||
|
|
||||||
|
def _get_write_values(self, new_value):
|
||||||
|
"""Generate values to write after recomputation of a goal score"""
|
||||||
|
if new_value == self.current:
|
||||||
|
# avoid useless write if the new value is the same as the old one
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = {'current': new_value}
|
||||||
|
if (self.definition_id.condition == 'higher' and new_value >= self.target_goal) \
|
||||||
|
or (self.definition_id.condition == 'lower' and new_value <= self.target_goal):
|
||||||
|
# success, do no set closed as can still change
|
||||||
|
result['state'] = 'reached'
|
||||||
|
|
||||||
|
elif self.end_date and fields.Date.today() > self.end_date:
|
||||||
|
# check goal failure
|
||||||
|
result['state'] = 'failed'
|
||||||
|
result['closed'] = True
|
||||||
|
|
||||||
|
return {self: result}
|
||||||
|
|
||||||
|
def update_goal(self):
|
||||||
|
"""Update the goals to recomputes values and change of states
|
||||||
|
|
||||||
|
If a manual goal is not updated for enough time, the user will be
|
||||||
|
reminded to do so (done only once, in 'inprogress' state).
|
||||||
|
If a goal reaches the target value, the status is set to reached
|
||||||
|
If the end date is passed (at least +1 day, time not considered) without
|
||||||
|
the target value being reached, the goal is set as failed."""
|
||||||
|
goals_by_definition = {}
|
||||||
|
for goal in self.with_context(prefetch_fields=False):
|
||||||
|
goals_by_definition.setdefault(goal.definition_id, []).append(goal)
|
||||||
|
|
||||||
|
for definition, goals in goals_by_definition.items():
|
||||||
|
goals_to_write = {}
|
||||||
|
if definition.computation_mode == 'manually':
|
||||||
|
for goal in goals:
|
||||||
|
goals_to_write[goal] = goal._check_remind_delay()
|
||||||
|
elif definition.computation_mode == 'python':
|
||||||
|
# TODO batch execution
|
||||||
|
for goal in goals:
|
||||||
|
# execute the chosen method
|
||||||
|
cxt = {
|
||||||
|
'object': goal,
|
||||||
|
'env': self.env,
|
||||||
|
|
||||||
|
'date': date,
|
||||||
|
'datetime': datetime,
|
||||||
|
'timedelta': timedelta,
|
||||||
|
'time': time,
|
||||||
|
}
|
||||||
|
code = definition.compute_code.strip()
|
||||||
|
safe_eval(code, cxt, mode="exec", nocopy=True)
|
||||||
|
# the result of the evaluated codeis put in the 'result' local variable, propagated to the context
|
||||||
|
result = cxt.get('result')
|
||||||
|
if isinstance(result, (float, int)):
|
||||||
|
goals_to_write.update(goal._get_write_values(result))
|
||||||
|
else:
|
||||||
|
_logger.error(
|
||||||
|
"Invalid return content '%r' from the evaluation "
|
||||||
|
"of code for definition %s, expected a number",
|
||||||
|
result, definition.name)
|
||||||
|
|
||||||
|
elif definition.computation_mode in ('count', 'sum'): # count or sum
|
||||||
|
Obj = self.env[definition.model_id.model]
|
||||||
|
|
||||||
|
field_date_name = definition.field_date_id.name
|
||||||
|
if definition.batch_mode:
|
||||||
|
# batch mode, trying to do as much as possible in one request
|
||||||
|
general_domain = ast.literal_eval(definition.domain)
|
||||||
|
field_name = definition.batch_distinctive_field.name
|
||||||
|
subqueries = {}
|
||||||
|
for goal in goals:
|
||||||
|
start_date = field_date_name and goal.start_date or False
|
||||||
|
end_date = field_date_name and goal.end_date or False
|
||||||
|
subqueries.setdefault((start_date, end_date), {}).update({goal.id:safe_eval(definition.batch_user_expression, {'user': goal.user_id})})
|
||||||
|
|
||||||
|
# the global query should be split by time periods (especially for recurrent goals)
|
||||||
|
for (start_date, end_date), query_goals in subqueries.items():
|
||||||
|
subquery_domain = list(general_domain)
|
||||||
|
subquery_domain.append((field_name, 'in', list(set(query_goals.values()))))
|
||||||
|
if start_date:
|
||||||
|
subquery_domain.append((field_date_name, '>=', start_date))
|
||||||
|
if end_date:
|
||||||
|
subquery_domain.append((field_date_name, '<=', end_date))
|
||||||
|
|
||||||
|
if definition.computation_mode == 'count':
|
||||||
|
user_values = Obj._read_group(subquery_domain, groupby=[field_name], aggregates=['__count'])
|
||||||
|
|
||||||
|
else: # sum
|
||||||
|
value_field_name = definition.field_id.name
|
||||||
|
user_values = Obj._read_group(subquery_domain, groupby=[field_name], aggregates=[f'{value_field_name}:sum'])
|
||||||
|
|
||||||
|
# user_values has format of _read_group: [(<partner>, <aggregate>), ...]
|
||||||
|
for goal in [g for g in goals if g.id in query_goals]:
|
||||||
|
for field_value, aggregate in user_values:
|
||||||
|
queried_value = field_value.id if isinstance(field_value, models.Model) else field_value
|
||||||
|
if queried_value == query_goals[goal.id]:
|
||||||
|
goals_to_write.update(goal._get_write_values(aggregate))
|
||||||
|
|
||||||
|
else:
|
||||||
|
for goal in goals:
|
||||||
|
# eval the domain with user replaced by goal user object
|
||||||
|
domain = safe_eval(definition.domain, {'user': goal.user_id})
|
||||||
|
|
||||||
|
# add temporal clause(s) to the domain if fields are filled on the goal
|
||||||
|
if goal.start_date and field_date_name:
|
||||||
|
domain.append((field_date_name, '>=', goal.start_date))
|
||||||
|
if goal.end_date and field_date_name:
|
||||||
|
domain.append((field_date_name, '<=', goal.end_date))
|
||||||
|
|
||||||
|
if definition.computation_mode == 'sum':
|
||||||
|
field_name = definition.field_id.name
|
||||||
|
res = Obj._read_group(domain, [], [field_name])
|
||||||
|
new_value = res[0][0] or 0.0
|
||||||
|
|
||||||
|
else: # computation mode = count
|
||||||
|
new_value = Obj.search_count(domain)
|
||||||
|
|
||||||
|
goals_to_write.update(goal._get_write_values(new_value))
|
||||||
|
|
||||||
|
else:
|
||||||
|
_logger.error(
|
||||||
|
"Invalid computation mode '%s' in definition %s",
|
||||||
|
definition.computation_mode, definition.name)
|
||||||
|
|
||||||
|
for goal, values in goals_to_write.items():
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
goal.write(values)
|
||||||
|
if self.env.context.get('commit_gamification'):
|
||||||
|
self.env.cr.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_start(self):
|
||||||
|
"""Mark a goal as started.
|
||||||
|
|
||||||
|
This should only be used when creating goals manually (in draft state)"""
|
||||||
|
self.write({'state': 'inprogress'})
|
||||||
|
return self.update_goal()
|
||||||
|
|
||||||
|
def action_reach(self):
|
||||||
|
"""Mark a goal as reached.
|
||||||
|
|
||||||
|
If the target goal condition is not met, the state will be reset to In
|
||||||
|
Progress at the next goal update until the end date."""
|
||||||
|
return self.write({'state': 'reached'})
|
||||||
|
|
||||||
|
def action_fail(self):
|
||||||
|
"""Set the state of the goal to failed.
|
||||||
|
|
||||||
|
A failed goal will be ignored in future checks."""
|
||||||
|
return self.write({'state': 'failed'})
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
"""Reset the completion after setting a goal as reached or failed.
|
||||||
|
|
||||||
|
This is only the current state, if the date and/or target criteria
|
||||||
|
match the conditions for a change of state, this will be applied at the
|
||||||
|
next goal update."""
|
||||||
|
return self.write({'state': 'inprogress'})
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
return super(Goal, self.with_context(no_remind_goal=True)).create(vals_list)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
"""Overwrite the write method to update the last_update field to today
|
||||||
|
|
||||||
|
If the current value is changed and the report frequency is set to On
|
||||||
|
change, a report is generated
|
||||||
|
"""
|
||||||
|
vals['last_update'] = fields.Date.context_today(self)
|
||||||
|
result = super(Goal, self).write(vals)
|
||||||
|
for goal in self:
|
||||||
|
if goal.state != "draft" and ('definition_id' in vals or 'user_id' in vals):
|
||||||
|
# avoid drag&drop in kanban view
|
||||||
|
raise exceptions.UserError(_('Can not modify the configuration of a started goal'))
|
||||||
|
|
||||||
|
if vals.get('current') and 'no_remind_goal' not in self.env.context:
|
||||||
|
if goal.challenge_id.report_message_frequency == 'onchange':
|
||||||
|
goal.challenge_id.sudo().report_progress(users=goal.user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_action(self):
|
||||||
|
"""Get the ir.action related to update the goal
|
||||||
|
|
||||||
|
In case of a manual goal, should return a wizard to update the value
|
||||||
|
:return: action description in a dictionary
|
||||||
|
"""
|
||||||
|
if self.definition_id.action_id:
|
||||||
|
# open a the action linked to the goal
|
||||||
|
action = self.definition_id.action_id.read()[0]
|
||||||
|
|
||||||
|
if self.definition_id.res_id_field:
|
||||||
|
current_user = self.env.user.with_user(self.env.user)
|
||||||
|
action['res_id'] = safe_eval(self.definition_id.res_id_field, {
|
||||||
|
'user': current_user
|
||||||
|
})
|
||||||
|
|
||||||
|
# if one element to display, should see it in form mode if possible
|
||||||
|
action['views'] = [
|
||||||
|
(view_id, mode)
|
||||||
|
for (view_id, mode) in action['views']
|
||||||
|
if mode == 'form'
|
||||||
|
] or action['views']
|
||||||
|
return action
|
||||||
|
|
||||||
|
if self.computation_mode == 'manually':
|
||||||
|
# open a wizard window to update the value manually
|
||||||
|
action = {
|
||||||
|
'name': _("Update %s", self.definition_id.name),
|
||||||
|
'id': self.id,
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'views': [[False, 'form']],
|
||||||
|
'target': 'new',
|
||||||
|
'context': {'default_goal_id': self.id, 'default_current': self.current},
|
||||||
|
'res_model': 'gamification.goal.wizard'
|
||||||
|
}
|
||||||
|
return action
|
||||||
|
|
||||||
|
return False
|
136
models/gamification_goal_definition.py
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _, exceptions
|
||||||
|
from odoo.tools.safe_eval import safe_eval
|
||||||
|
|
||||||
|
DOMAIN_TEMPLATE = "[('store', '=', True), '|', ('model_id', '=', model_id), ('model_id', 'in', model_inherited_ids)%s]"
|
||||||
|
|
||||||
|
|
||||||
|
class GoalDefinition(models.Model):
|
||||||
|
"""Goal definition
|
||||||
|
|
||||||
|
A goal definition contains the way to evaluate an objective
|
||||||
|
Each module wanting to be able to set goals to the users needs to create
|
||||||
|
a new gamification_goal_definition
|
||||||
|
"""
|
||||||
|
_name = 'gamification.goal.definition'
|
||||||
|
_description = 'Gamification Goal Definition'
|
||||||
|
|
||||||
|
name = fields.Char("Goal Definition", required=True, translate=True)
|
||||||
|
description = fields.Text("Goal Description")
|
||||||
|
monetary = fields.Boolean("Monetary Value", default=False, help="The target and current value are defined in the company currency.")
|
||||||
|
suffix = fields.Char("Suffix", help="The unit of the target and current values", translate=True)
|
||||||
|
full_suffix = fields.Char("Full Suffix", compute='_compute_full_suffix', help="The currency and suffix field")
|
||||||
|
computation_mode = fields.Selection([
|
||||||
|
('manually', "Recorded manually"),
|
||||||
|
('count', "Automatic: number of records"),
|
||||||
|
('sum', "Automatic: sum on a field"),
|
||||||
|
('python', "Automatic: execute a specific Python code"),
|
||||||
|
], default='manually', string="Computation Mode", required=True,
|
||||||
|
help="Define how the goals will be computed. The result of the operation will be stored in the field 'Current'.")
|
||||||
|
display_mode = fields.Selection([
|
||||||
|
('progress', "Progressive (using numerical values)"),
|
||||||
|
('boolean', "Exclusive (done or not-done)"),
|
||||||
|
], default='progress', string="Displayed as", required=True)
|
||||||
|
model_id = fields.Many2one('ir.model', string='Model')
|
||||||
|
model_inherited_ids = fields.Many2many('ir.model', related='model_id.inherited_model_ids')
|
||||||
|
field_id = fields.Many2one(
|
||||||
|
'ir.model.fields', string='Field to Sum',
|
||||||
|
domain=DOMAIN_TEMPLATE % ''
|
||||||
|
)
|
||||||
|
field_date_id = fields.Many2one(
|
||||||
|
'ir.model.fields', string='Date Field', help='The date to use for the time period evaluated',
|
||||||
|
domain=DOMAIN_TEMPLATE % ", ('ttype', 'in', ('date', 'datetime'))"
|
||||||
|
)
|
||||||
|
domain = fields.Char(
|
||||||
|
"Filter Domain", required=True, default="[]",
|
||||||
|
help="Domain for filtering records. General rule, not user depending,"
|
||||||
|
" e.g. [('state', '=', 'done')]. The expression can contain"
|
||||||
|
" reference to 'user' which is a browse record of the current"
|
||||||
|
" user if not in batch mode.")
|
||||||
|
|
||||||
|
batch_mode = fields.Boolean("Batch Mode", help="Evaluate the expression in batch instead of once for each user")
|
||||||
|
batch_distinctive_field = fields.Many2one('ir.model.fields', string="Distinctive field for batch user", help="In batch mode, this indicates which field distinguishes one user from the other, e.g. user_id, partner_id...")
|
||||||
|
batch_user_expression = fields.Char("Evaluated expression for batch mode", help="The value to compare with the distinctive field. The expression can contain reference to 'user' which is a browse record of the current user, e.g. user.id, user.partner_id.id...")
|
||||||
|
compute_code = fields.Text("Python Code", help="Python code to be executed for each user. 'result' should contains the new current value. Evaluated user can be access through object.user_id.")
|
||||||
|
condition = fields.Selection([
|
||||||
|
('higher', "The higher the better"),
|
||||||
|
('lower', "The lower the better")
|
||||||
|
], default='higher', required=True, string="Goal Performance",
|
||||||
|
help="A goal is considered as completed when the current value is compared to the value to reach")
|
||||||
|
action_id = fields.Many2one('ir.actions.act_window', string="Action", help="The action that will be called to update the goal value.")
|
||||||
|
res_id_field = fields.Char("ID Field of user", help="The field name on the user profile (res.users) containing the value for res_id for action.")
|
||||||
|
|
||||||
|
@api.depends('suffix', 'monetary') # also depends of user...
|
||||||
|
def _compute_full_suffix(self):
|
||||||
|
for goal in self:
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if goal.monetary:
|
||||||
|
items.append(self.env.company.currency_id.symbol or u'¤')
|
||||||
|
if goal.suffix:
|
||||||
|
items.append(goal.suffix)
|
||||||
|
|
||||||
|
goal.full_suffix = u' '.join(items)
|
||||||
|
|
||||||
|
def _check_domain_validity(self):
|
||||||
|
# take admin as should always be present
|
||||||
|
for definition in self:
|
||||||
|
if definition.computation_mode not in ('count', 'sum'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
Obj = self.env[definition.model_id.model]
|
||||||
|
try:
|
||||||
|
domain = safe_eval(definition.domain, {
|
||||||
|
'user': self.env.user.with_user(self.env.user)
|
||||||
|
})
|
||||||
|
# dummy search to make sure the domain is valid
|
||||||
|
Obj.search_count(domain)
|
||||||
|
except (ValueError, SyntaxError) as e:
|
||||||
|
msg = e
|
||||||
|
if isinstance(e, SyntaxError):
|
||||||
|
msg = (e.msg + '\n' + e.text)
|
||||||
|
raise exceptions.UserError(_("The domain for the definition %s seems incorrect, please check it.\n\n%s", definition.name, msg))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _check_model_validity(self):
|
||||||
|
""" make sure the selected field and model are usable"""
|
||||||
|
for definition in self:
|
||||||
|
try:
|
||||||
|
if not (definition.model_id and definition.field_id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
Model = self.env[definition.model_id.model]
|
||||||
|
field = Model._fields.get(definition.field_id.name)
|
||||||
|
if not (field and field.store):
|
||||||
|
raise exceptions.UserError(_(
|
||||||
|
"The model configuration for the definition %(name)s seems incorrect, please check it.\n\n%(field_name)s not stored",
|
||||||
|
name=definition.name,
|
||||||
|
field_name=definition.field_id.name
|
||||||
|
))
|
||||||
|
except KeyError as e:
|
||||||
|
raise exceptions.UserError(_(
|
||||||
|
"The model configuration for the definition %(name)s seems incorrect, please check it.\n\n%(error)s not found",
|
||||||
|
name=definition.name,
|
||||||
|
error=e
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
definitions = super(GoalDefinition, self).create(vals_list)
|
||||||
|
definitions.filtered_domain([
|
||||||
|
('computation_mode', 'in', ['count', 'sum']),
|
||||||
|
])._check_domain_validity()
|
||||||
|
definitions.filtered_domain([
|
||||||
|
('field_id', '=', 'True'),
|
||||||
|
])._check_model_validity()
|
||||||
|
return definitions
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super(GoalDefinition, self).write(vals)
|
||||||
|
if vals.get('computation_mode', 'count') in ('count', 'sum') and (vals.get('domain') or vals.get('model_id')):
|
||||||
|
self._check_domain_validity()
|
||||||
|
if vals.get('field_id') or vals.get('model_id') or vals.get('batch_mode'):
|
||||||
|
self._check_model_validity()
|
||||||
|
return res
|
58
models/gamification_karma_rank.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.tools.translate import html_translate
|
||||||
|
|
||||||
|
|
||||||
|
class KarmaRank(models.Model):
|
||||||
|
_name = 'gamification.karma.rank'
|
||||||
|
_description = 'Rank based on karma'
|
||||||
|
_inherit = 'image.mixin'
|
||||||
|
_order = 'karma_min'
|
||||||
|
|
||||||
|
name = fields.Text(string='Rank Name', translate=True, required=True)
|
||||||
|
description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False,)
|
||||||
|
description_motivational = fields.Html(
|
||||||
|
string='Motivational', translate=html_translate, sanitize_attributes=False,
|
||||||
|
help="Motivational phrase to reach this rank on your profile page")
|
||||||
|
karma_min = fields.Integer(
|
||||||
|
string='Required Karma', required=True, default=1)
|
||||||
|
user_ids = fields.One2many('res.users', 'rank_id', string='Users')
|
||||||
|
rank_users_count = fields.Integer("# Users", compute="_compute_rank_users_count")
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('karma_min_check', "CHECK( karma_min > 0 )", 'The required karma has to be above 0.')
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('user_ids')
|
||||||
|
def _compute_rank_users_count(self):
|
||||||
|
requests_data = self.env['res.users']._read_group([('rank_id', '!=', False)], ['rank_id'], ['__count'])
|
||||||
|
requests_mapped_data = {rank.id: count for rank, count in requests_data}
|
||||||
|
for rank in self:
|
||||||
|
rank.rank_users_count = requests_mapped_data.get(rank.id, 0)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, values_list):
|
||||||
|
res = super(KarmaRank, self).create(values_list)
|
||||||
|
if any(res.mapped('karma_min')) > 0:
|
||||||
|
users = self.env['res.users'].sudo().search([('karma', '>=', max(min(res.mapped('karma_min')), 1))])
|
||||||
|
if users:
|
||||||
|
users._recompute_rank()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if 'karma_min' in vals:
|
||||||
|
previous_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids
|
||||||
|
low = min(vals['karma_min'], min(self.mapped('karma_min')))
|
||||||
|
high = max(vals['karma_min'], max(self.mapped('karma_min')))
|
||||||
|
|
||||||
|
res = super(KarmaRank, self).write(vals)
|
||||||
|
|
||||||
|
if 'karma_min' in vals:
|
||||||
|
after_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids
|
||||||
|
if previous_ranks != after_ranks:
|
||||||
|
users = self.env['res.users'].sudo().search([('karma', '>=', max(low, 1))])
|
||||||
|
else:
|
||||||
|
users = self.env['res.users'].sudo().search([('karma', '>=', max(low, 1)), ('karma', '<=', high)])
|
||||||
|
users._recompute_rank()
|
||||||
|
return res
|
139
models/gamification_karma_tracking.py
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.tools import date_utils
|
||||||
|
|
||||||
|
|
||||||
|
class KarmaTracking(models.Model):
|
||||||
|
_name = 'gamification.karma.tracking'
|
||||||
|
_description = 'Track Karma Changes'
|
||||||
|
_rec_name = 'user_id'
|
||||||
|
_order = 'tracking_date desc, id desc'
|
||||||
|
|
||||||
|
def _get_origin_selection_values(self):
|
||||||
|
return [('res.users', _('User'))]
|
||||||
|
|
||||||
|
user_id = fields.Many2one('res.users', 'User', index=True, required=True, ondelete='cascade')
|
||||||
|
old_value = fields.Integer('Old Karma Value', readonly=True)
|
||||||
|
new_value = fields.Integer('New Karma Value', required=True)
|
||||||
|
gain = fields.Integer('Gain', compute='_compute_gain', readonly=False)
|
||||||
|
consolidated = fields.Boolean('Consolidated')
|
||||||
|
|
||||||
|
tracking_date = fields.Datetime(default=fields.Datetime.now, readonly=True, index=True)
|
||||||
|
reason = fields.Text(default=lambda self: _('Add Manually'), string='Description')
|
||||||
|
origin_ref = fields.Reference(
|
||||||
|
string='Source',
|
||||||
|
selection=lambda self: self._get_origin_selection_values(),
|
||||||
|
default=lambda self: f'res.users,{self.env.user.id}',
|
||||||
|
)
|
||||||
|
origin_ref_model_name = fields.Selection(
|
||||||
|
string='Source Type', selection=lambda self: self._get_origin_selection_values(),
|
||||||
|
compute='_compute_origin_ref_model_name', store=True)
|
||||||
|
|
||||||
|
@api.depends('old_value', 'new_value')
|
||||||
|
def _compute_gain(self):
|
||||||
|
for karma in self:
|
||||||
|
karma.gain = karma.new_value - (karma.old_value or 0)
|
||||||
|
|
||||||
|
@api.depends('origin_ref')
|
||||||
|
def _compute_origin_ref_model_name(self):
|
||||||
|
for karma in self:
|
||||||
|
if not karma.origin_ref:
|
||||||
|
karma.origin_ref_model_name = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
karma.origin_ref_model_name = karma.origin_ref._name
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, values_list):
|
||||||
|
# fill missing old value with current user karma
|
||||||
|
users = self.env['res.users'].browse([
|
||||||
|
values['user_id']
|
||||||
|
for values in values_list
|
||||||
|
if 'old_value' not in values and values.get('user_id')
|
||||||
|
])
|
||||||
|
karma_per_users = {user.id: user.karma for user in users}
|
||||||
|
|
||||||
|
for values in values_list:
|
||||||
|
if 'old_value' not in values and values.get('user_id'):
|
||||||
|
values['old_value'] = karma_per_users[values['user_id']]
|
||||||
|
|
||||||
|
if 'gain' in values and 'old_value' in values:
|
||||||
|
values['new_value'] = values['old_value'] + values['gain']
|
||||||
|
del values['gain']
|
||||||
|
|
||||||
|
return super().create(values_list)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _consolidate_cron(self):
|
||||||
|
"""Consolidate the trackings 2 months ago. Used by a cron to cleanup tracking records."""
|
||||||
|
from_date = date_utils.start_of(fields.Datetime.today(), 'month') - relativedelta(months=2)
|
||||||
|
return self._process_consolidate(from_date)
|
||||||
|
|
||||||
|
def _process_consolidate(self, from_date, end_date=None):
|
||||||
|
"""Consolidate the karma trackings.
|
||||||
|
|
||||||
|
The consolidation keeps, for each user, the oldest "old_value" and the most recent
|
||||||
|
"new_value", creates a new karma tracking with those values and removes all karma
|
||||||
|
trackings between those dates. The origin / reason is changed on the consolidated
|
||||||
|
records, so this information is lost in the process.
|
||||||
|
"""
|
||||||
|
self.env['gamification.karma.tracking'].flush_model()
|
||||||
|
|
||||||
|
if not end_date:
|
||||||
|
end_date = date_utils.end_of(date_utils.end_of(from_date, 'month'), 'day')
|
||||||
|
|
||||||
|
select_query = """
|
||||||
|
WITH old_tracking AS (
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, old_value, tracking_date
|
||||||
|
FROM gamification_karma_tracking
|
||||||
|
WHERE tracking_date BETWEEN %(from_date)s
|
||||||
|
AND %(end_date)s
|
||||||
|
AND consolidated IS NOT TRUE
|
||||||
|
ORDER BY user_id, tracking_date ASC, id ASC
|
||||||
|
)
|
||||||
|
INSERT INTO gamification_karma_tracking (
|
||||||
|
user_id,
|
||||||
|
old_value,
|
||||||
|
new_value,
|
||||||
|
tracking_date,
|
||||||
|
origin_ref,
|
||||||
|
consolidated,
|
||||||
|
reason)
|
||||||
|
SELECT DISTINCT ON (nt.user_id)
|
||||||
|
nt.user_id,
|
||||||
|
ot.old_value AS old_value,
|
||||||
|
nt.new_value AS new_value,
|
||||||
|
ot.tracking_date AS from_tracking_date,
|
||||||
|
%(origin_ref)s AS origin_ref,
|
||||||
|
TRUE,
|
||||||
|
%(reason)s
|
||||||
|
FROM gamification_karma_tracking AS nt
|
||||||
|
JOIN old_tracking AS ot
|
||||||
|
ON ot.user_id = nt.user_id
|
||||||
|
WHERE nt.tracking_date BETWEEN %(from_date)s
|
||||||
|
AND %(end_date)s
|
||||||
|
AND nt.consolidated IS NOT TRUE
|
||||||
|
ORDER BY nt.user_id, nt.tracking_date DESC, id DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.env.cr.execute(select_query, {
|
||||||
|
'from_date': from_date,
|
||||||
|
'end_date': end_date,
|
||||||
|
'origin_ref': f'res.users,{self.env.user.id}',
|
||||||
|
'reason': _('Consolidation from %s to %s', from_date.date(), end_date.date()),
|
||||||
|
})
|
||||||
|
|
||||||
|
trackings = self.search([
|
||||||
|
('tracking_date', '>=', from_date),
|
||||||
|
('tracking_date', '<=', end_date),
|
||||||
|
('consolidated', '!=', True)]
|
||||||
|
)
|
||||||
|
# HACK: the unlink() AND the flush_all() must have that key in their context!
|
||||||
|
trackings = trackings.with_context(skip_karma_computation=True)
|
||||||
|
trackings.unlink()
|
||||||
|
trackings.env.flush_all()
|
||||||
|
return True
|
369
models/res_users.py
Normal file
|
@ -0,0 +1,369 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class Users(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
karma = fields.Integer('Karma', compute='_compute_karma', store=True, readonly=False)
|
||||||
|
karma_tracking_ids = fields.One2many('gamification.karma.tracking', 'user_id', string='Karma Changes', groups="base.group_system")
|
||||||
|
badge_ids = fields.One2many('gamification.badge.user', 'user_id', string='Badges', copy=False)
|
||||||
|
gold_badge = fields.Integer('Gold badges count', compute="_get_user_badge_level")
|
||||||
|
silver_badge = fields.Integer('Silver badges count', compute="_get_user_badge_level")
|
||||||
|
bronze_badge = fields.Integer('Bronze badges count', compute="_get_user_badge_level")
|
||||||
|
rank_id = fields.Many2one('gamification.karma.rank', 'Rank')
|
||||||
|
next_rank_id = fields.Many2one('gamification.karma.rank', 'Next Rank')
|
||||||
|
|
||||||
|
@api.depends('karma_tracking_ids.new_value')
|
||||||
|
def _compute_karma(self):
|
||||||
|
if self.env.context.get('skip_karma_computation'):
|
||||||
|
# do not need to update the user karma
|
||||||
|
# e.g. during the tracking consolidation
|
||||||
|
return
|
||||||
|
|
||||||
|
self.env['gamification.karma.tracking'].flush_model()
|
||||||
|
|
||||||
|
select_query = """
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, new_value
|
||||||
|
FROM gamification_karma_tracking
|
||||||
|
WHERE user_id = ANY(%(user_ids)s)
|
||||||
|
ORDER BY user_id, tracking_date DESC, id DESC
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(select_query, {'user_ids': self.ids})
|
||||||
|
|
||||||
|
user_karma_map = {
|
||||||
|
values['user_id']: values['new_value']
|
||||||
|
for values in self.env.cr.dictfetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in self:
|
||||||
|
user.karma = user_karma_map.get(user.id, 0)
|
||||||
|
|
||||||
|
self.sudo()._recompute_rank()
|
||||||
|
|
||||||
|
@api.depends('badge_ids')
|
||||||
|
def _get_user_badge_level(self):
|
||||||
|
""" Return total badge per level of users
|
||||||
|
TDE CLEANME: shouldn't check type is forum ? """
|
||||||
|
for user in self:
|
||||||
|
user.gold_badge = 0
|
||||||
|
user.silver_badge = 0
|
||||||
|
user.bronze_badge = 0
|
||||||
|
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT bu.user_id, b.level, count(1)
|
||||||
|
FROM gamification_badge_user bu, gamification_badge b
|
||||||
|
WHERE bu.user_id IN %s
|
||||||
|
AND bu.badge_id = b.id
|
||||||
|
AND b.level IS NOT NULL
|
||||||
|
GROUP BY bu.user_id, b.level
|
||||||
|
ORDER BY bu.user_id;
|
||||||
|
""", [tuple(self.ids)])
|
||||||
|
|
||||||
|
for (user_id, level, count) in self.env.cr.fetchall():
|
||||||
|
# levels are gold, silver, bronze but fields have _badge postfix
|
||||||
|
self.browse(user_id)['{}_badge'.format(level)] = count
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, values_list):
|
||||||
|
res = super(Users, self).create(values_list)
|
||||||
|
|
||||||
|
self._add_karma_batch({
|
||||||
|
user: {
|
||||||
|
'gain': int(vals['karma']),
|
||||||
|
'old_value': 0,
|
||||||
|
'origin_ref': f'res.users,{self.env.uid}',
|
||||||
|
'reason': _('User Creation'),
|
||||||
|
}
|
||||||
|
for user, vals in zip(res, values_list)
|
||||||
|
if vals.get('karma')
|
||||||
|
})
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def write(self, values):
|
||||||
|
if 'karma' in values:
|
||||||
|
self._add_karma_batch({
|
||||||
|
user: {
|
||||||
|
'gain': int(values['karma']) - user.karma,
|
||||||
|
'origin_ref': f'res.users,{self.env.uid}',
|
||||||
|
}
|
||||||
|
for user in self
|
||||||
|
if int(values['karma']) != user.karma
|
||||||
|
})
|
||||||
|
return super().write(values)
|
||||||
|
|
||||||
|
def _add_karma(self, gain, source=None, reason=None):
|
||||||
|
self.ensure_one()
|
||||||
|
values = {'gain': gain, 'source': source, 'reason': reason}
|
||||||
|
return self._add_karma_batch({self: values})
|
||||||
|
|
||||||
|
def _add_karma_batch(self, values_per_user):
|
||||||
|
if not values_per_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
create_values = []
|
||||||
|
for user, values in values_per_user.items():
|
||||||
|
origin = values.get('source') or self.env.user
|
||||||
|
reason = values.get('reason') or _('Add Manually')
|
||||||
|
origin_description = f'{origin.display_name} #{origin.id}'
|
||||||
|
old_value = values.get('old_value', user.karma)
|
||||||
|
|
||||||
|
create_values.append({
|
||||||
|
'new_value': old_value + values['gain'],
|
||||||
|
'old_value': old_value,
|
||||||
|
'origin_ref': f'{origin._name},{origin.id}',
|
||||||
|
'reason': f'{reason} ({origin_description})',
|
||||||
|
'user_id': user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.env['gamification.karma.tracking'].sudo().create(create_values)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_tracking_karma_gain_position(self, user_domain, from_date=None, to_date=None):
|
||||||
|
""" Get absolute position in term of gained karma for users. First a ranking
|
||||||
|
of all users is done given a user_domain; then the position of each user
|
||||||
|
belonging to the current record set is extracted.
|
||||||
|
|
||||||
|
Example: in website profile, search users with name containing Norbert. Their
|
||||||
|
positions should not be 1 to 4 (assuming 4 results), but their actual position
|
||||||
|
in the karma gain ranking (with example user_domain being karma > 1,
|
||||||
|
website published True).
|
||||||
|
|
||||||
|
:param user_domain: general domain (i.e. active, karma > 1, website, ...)
|
||||||
|
to compute the absolute position of the current record set
|
||||||
|
:param from_date: compute karma gained after this date (included) or from
|
||||||
|
beginning of time;
|
||||||
|
:param to_date: compute karma gained before this date (included) or until
|
||||||
|
end of time;
|
||||||
|
|
||||||
|
:return list: [{
|
||||||
|
'user_id': user_id (belonging to current record set),
|
||||||
|
'karma_gain_total': integer, karma gained in the given timeframe,
|
||||||
|
'karma_position': integer, ranking position
|
||||||
|
}, {..}] ordered by karma_position desc
|
||||||
|
"""
|
||||||
|
if not self:
|
||||||
|
return []
|
||||||
|
|
||||||
|
where_query = self.env['res.users']._where_calc(user_domain)
|
||||||
|
user_from_clause, user_where_clause, where_clause_params = where_query.get_sql()
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if from_date:
|
||||||
|
date_from_condition = 'AND tracking.tracking_date::DATE >= %s::DATE'
|
||||||
|
params.append(from_date)
|
||||||
|
if to_date:
|
||||||
|
date_to_condition = 'AND tracking.tracking_date::DATE <= %s::DATE'
|
||||||
|
params.append(to_date)
|
||||||
|
params.append(tuple(self.ids))
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT final.user_id, final.karma_gain_total, final.karma_position
|
||||||
|
FROM (
|
||||||
|
SELECT intermediate.user_id, intermediate.karma_gain_total, row_number() OVER (ORDER BY intermediate.karma_gain_total DESC) AS karma_position
|
||||||
|
FROM (
|
||||||
|
SELECT "res_users".id as user_id, COALESCE(SUM("tracking".new_value - "tracking".old_value), 0) as karma_gain_total
|
||||||
|
FROM %(user_from_clause)s
|
||||||
|
LEFT JOIN "gamification_karma_tracking" as "tracking"
|
||||||
|
ON "res_users".id = "tracking".user_id AND "res_users"."active" = TRUE
|
||||||
|
WHERE %(user_where_clause)s %(date_from_condition)s %(date_to_condition)s
|
||||||
|
GROUP BY "res_users".id
|
||||||
|
ORDER BY karma_gain_total DESC
|
||||||
|
) intermediate
|
||||||
|
) final
|
||||||
|
WHERE final.user_id IN %%s""" % {
|
||||||
|
'user_from_clause': user_from_clause,
|
||||||
|
'user_where_clause': user_where_clause or (not from_date and not to_date and 'TRUE') or '',
|
||||||
|
'date_from_condition': date_from_condition if from_date else '',
|
||||||
|
'date_to_condition': date_to_condition if to_date else ''
|
||||||
|
}
|
||||||
|
|
||||||
|
self.env.cr.execute(query, tuple(where_clause_params + params))
|
||||||
|
return self.env.cr.dictfetchall()
|
||||||
|
|
||||||
|
def _get_karma_position(self, user_domain):
|
||||||
|
""" Get absolute position in term of total karma for users. First a ranking
|
||||||
|
of all users is done given a user_domain; then the position of each user
|
||||||
|
belonging to the current record set is extracted.
|
||||||
|
|
||||||
|
Example: in website profile, search users with name containing Norbert. Their
|
||||||
|
positions should not be 1 to 4 (assuming 4 results), but their actual position
|
||||||
|
in the total karma ranking (with example user_domain being karma > 1,
|
||||||
|
website published True).
|
||||||
|
|
||||||
|
:param user_domain: general domain (i.e. active, karma > 1, website, ...)
|
||||||
|
to compute the absolute position of the current record set
|
||||||
|
|
||||||
|
:return list: [{
|
||||||
|
'user_id': user_id (belonging to current record set),
|
||||||
|
'karma_position': integer, ranking position
|
||||||
|
}, {..}] ordered by karma_position desc
|
||||||
|
"""
|
||||||
|
if not self:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
where_query = self.env['res.users']._where_calc(user_domain)
|
||||||
|
user_from_clause, user_where_clause, where_clause_params = where_query.get_sql()
|
||||||
|
|
||||||
|
# we search on every user in the DB to get the real positioning (not the one inside the subset)
|
||||||
|
# then, we filter to get only the subset.
|
||||||
|
query = """
|
||||||
|
SELECT sub.user_id, sub.karma_position
|
||||||
|
FROM (
|
||||||
|
SELECT "res_users"."id" as user_id, row_number() OVER (ORDER BY res_users.karma DESC) AS karma_position
|
||||||
|
FROM %(user_from_clause)s
|
||||||
|
WHERE %(user_where_clause)s
|
||||||
|
) sub
|
||||||
|
WHERE sub.user_id IN %%s""" % {
|
||||||
|
'user_from_clause': user_from_clause,
|
||||||
|
'user_where_clause': user_where_clause or 'TRUE',
|
||||||
|
}
|
||||||
|
|
||||||
|
self.env.cr.execute(query, tuple(where_clause_params + [tuple(self.ids)]))
|
||||||
|
return self.env.cr.dictfetchall()
|
||||||
|
|
||||||
|
def _rank_changed(self):
|
||||||
|
"""
|
||||||
|
Method that can be called on a batch of users with the same new rank
|
||||||
|
"""
|
||||||
|
if self.env.context.get('install_mode', False):
|
||||||
|
# avoid sending emails in install mode (prevents spamming users when creating data ranks)
|
||||||
|
return
|
||||||
|
|
||||||
|
template = self.env.ref('gamification.mail_template_data_new_rank_reached', raise_if_not_found=False)
|
||||||
|
if template:
|
||||||
|
for u in self:
|
||||||
|
if u.rank_id.karma_min > 0:
|
||||||
|
template.send_mail(u.id, force_send=False, email_layout_xmlid='mail.mail_notification_light')
|
||||||
|
|
||||||
|
def _recompute_rank(self):
|
||||||
|
"""
|
||||||
|
The caller should filter the users on karma > 0 before calling this method
|
||||||
|
to avoid looping on every single users
|
||||||
|
|
||||||
|
Compute rank of each user by user.
|
||||||
|
For each user, check the rank of this user
|
||||||
|
"""
|
||||||
|
|
||||||
|
ranks = [{'rank': rank, 'karma_min': rank.karma_min} for rank in
|
||||||
|
self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
|
||||||
|
|
||||||
|
# 3 is the number of search/requests used by rank in _recompute_rank_bulk()
|
||||||
|
if len(self) > len(ranks) * 3:
|
||||||
|
self._recompute_rank_bulk()
|
||||||
|
return
|
||||||
|
|
||||||
|
for user in self:
|
||||||
|
old_rank = user.rank_id
|
||||||
|
if user.karma == 0 and ranks:
|
||||||
|
user.write({'next_rank_id': ranks[-1]['rank'].id})
|
||||||
|
else:
|
||||||
|
for i in range(0, len(ranks)):
|
||||||
|
if user.karma >= ranks[i]['karma_min']:
|
||||||
|
user.write({
|
||||||
|
'rank_id': ranks[i]['rank'].id,
|
||||||
|
'next_rank_id': ranks[i - 1]['rank'].id if 0 < i else False
|
||||||
|
})
|
||||||
|
break
|
||||||
|
if old_rank != user.rank_id:
|
||||||
|
user._rank_changed()
|
||||||
|
|
||||||
|
def _recompute_rank_bulk(self):
|
||||||
|
"""
|
||||||
|
Compute rank of each user by rank.
|
||||||
|
For each rank, check which users need to be ranked
|
||||||
|
|
||||||
|
"""
|
||||||
|
ranks = [{'rank': rank, 'karma_min': rank.karma_min} for rank in
|
||||||
|
self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
|
||||||
|
|
||||||
|
users_todo = self
|
||||||
|
|
||||||
|
next_rank_id = False
|
||||||
|
# wtf, next_rank_id should be a related on rank_id.next_rank_id and life might get easier.
|
||||||
|
# And we only need to recompute next_rank_id on write with min_karma or in the create on rank model.
|
||||||
|
for r in ranks:
|
||||||
|
rank_id = r['rank'].id
|
||||||
|
dom = [
|
||||||
|
('karma', '>=', r['karma_min']),
|
||||||
|
('id', 'in', users_todo.ids),
|
||||||
|
'|', # noqa
|
||||||
|
'|', ('rank_id', '!=', rank_id), ('rank_id', '=', False),
|
||||||
|
'|', ('next_rank_id', '!=', next_rank_id), ('next_rank_id', '=', False if next_rank_id else -1),
|
||||||
|
]
|
||||||
|
users = self.env['res.users'].search(dom)
|
||||||
|
if users:
|
||||||
|
users_to_notify = self.env['res.users'].search([
|
||||||
|
('karma', '>=', r['karma_min']),
|
||||||
|
'|', ('rank_id', '!=', rank_id), ('rank_id', '=', False),
|
||||||
|
('id', 'in', users.ids),
|
||||||
|
])
|
||||||
|
users.write({
|
||||||
|
'rank_id': rank_id,
|
||||||
|
'next_rank_id': next_rank_id,
|
||||||
|
})
|
||||||
|
users_to_notify._rank_changed()
|
||||||
|
users_todo -= users
|
||||||
|
|
||||||
|
nothing_to_do_users = self.env['res.users'].search([
|
||||||
|
('karma', '>=', r['karma_min']),
|
||||||
|
'|', ('rank_id', '=', rank_id), ('next_rank_id', '=', next_rank_id),
|
||||||
|
('id', 'in', users_todo.ids),
|
||||||
|
])
|
||||||
|
users_todo -= nothing_to_do_users
|
||||||
|
next_rank_id = r['rank'].id
|
||||||
|
|
||||||
|
if ranks:
|
||||||
|
lower_rank = ranks[-1]['rank']
|
||||||
|
users = self.env['res.users'].search([
|
||||||
|
('karma', '>=', 0),
|
||||||
|
('karma', '<', lower_rank.karma_min),
|
||||||
|
'|', ('rank_id', '!=', False), ('next_rank_id', '!=', lower_rank.id),
|
||||||
|
('id', 'in', users_todo.ids),
|
||||||
|
])
|
||||||
|
if users:
|
||||||
|
users.write({
|
||||||
|
'rank_id': False,
|
||||||
|
'next_rank_id': lower_rank.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _get_next_rank(self):
|
||||||
|
""" For fresh users with 0 karma that don't have a rank_id and next_rank_id yet
|
||||||
|
this method returns the first karma rank (by karma ascending). This acts as a
|
||||||
|
default value in related views.
|
||||||
|
|
||||||
|
TDE FIXME in post-12.4: make next_rank_id a non-stored computed field correctly computed """
|
||||||
|
|
||||||
|
if self.next_rank_id:
|
||||||
|
return self.next_rank_id
|
||||||
|
elif not self.rank_id:
|
||||||
|
return self.env['gamification.karma.rank'].search([], order="karma_min ASC", limit=1)
|
||||||
|
else:
|
||||||
|
return self.env['gamification.karma.rank']
|
||||||
|
|
||||||
|
def get_gamification_redirection_data(self):
|
||||||
|
"""
|
||||||
|
Hook for other modules to add redirect button(s) in new rank reached mail
|
||||||
|
Must return a list of dictionnary including url and label.
|
||||||
|
E.g. return [{'url': '/forum', label: 'Go to Forum'}]
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def action_karma_report(self):
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': _('Karma Updates'),
|
||||||
|
'res_model': 'gamification.karma.tracking',
|
||||||
|
'target': 'current',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'view_mode': 'tree',
|
||||||
|
'context': {
|
||||||
|
'default_user_id': self.id,
|
||||||
|
'search_default_user_id': self.id,
|
||||||
|
},
|
||||||
|
}
|
36
security/gamification_security.xml
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="goal_user_visibility" model="ir.rule">
|
||||||
|
<field name="name">User can only see his/her goals or goal from the same challenge in board visibility</field>
|
||||||
|
<field name="model_id" ref="model_gamification_goal"/>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|',
|
||||||
|
('user_id','=',user.id),
|
||||||
|
'&',
|
||||||
|
('challenge_id.user_ids','in',user.id),
|
||||||
|
('challenge_id.visibility_mode','=','ranking')]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="goal_gamification_manager_visibility" model="ir.rule">
|
||||||
|
<field name="name">Manager can see any goal</field>
|
||||||
|
<field name="model_id" ref="model_gamification_goal"/>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_erp_manager'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="goal_global_multicompany" model="ir.rule">
|
||||||
|
<field name="name">Multicompany rule on challenges</field>
|
||||||
|
<field name="model_id" ref="model_gamification_goal"/>
|
||||||
|
<field name="domain_force">[('user_id.company_id', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
37
security/ir.model.access.csv
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
|
||||||
|
goal_employee,"Goal Employee",model_gamification_goal,base.group_user,1,1,0,0
|
||||||
|
goal_manager,"Goal Manager",model_gamification_goal,base.group_erp_manager,1,1,1,1
|
||||||
|
goal_portal,"Goal Portal",gamification.model_gamification_goal,base.group_portal,1,1,0,0
|
||||||
|
|
||||||
|
goal_definition_employee,"Goal Definition Employee",model_gamification_goal_definition,base.group_user,1,0,0,0
|
||||||
|
goal_definition_manager,"Goal Definition Manager",model_gamification_goal_definition,base.group_erp_manager,1,1,1,1
|
||||||
|
goal_definition_portal,"Goal Definition Portal",gamification.model_gamification_goal_definition,base.group_portal,1,0,0,0
|
||||||
|
|
||||||
|
challenge_employee,"Goal Challenge Employee",model_gamification_challenge,base.group_user,1,0,0,0
|
||||||
|
challenge_manager,"Goal Challenge Manager",model_gamification_challenge,base.group_erp_manager,1,1,1,1
|
||||||
|
challenge_portal,"Goal Challenge Portal",gamification.model_gamification_challenge,base.group_portal,1,0,0,0
|
||||||
|
|
||||||
|
challenge_line_employee,"Challenge Line Employee",model_gamification_challenge_line,base.group_user,1,0,0,0
|
||||||
|
challenge_line_manager,"Challenge Line Manager",model_gamification_challenge_line,base.group_erp_manager,1,1,1,1
|
||||||
|
challenge_line_portal,"Challenge Line Portal",gamification.model_gamification_challenge_line,base.group_portal,1,0,0,0
|
||||||
|
|
||||||
|
badge_employee,"Badge Employee",model_gamification_badge,base.group_user,1,0,0,0
|
||||||
|
badge_manager,"Badge Manager",model_gamification_badge,base.group_erp_manager,1,1,1,1
|
||||||
|
badge_portal,"Badge Portal",gamification.model_gamification_badge,base.group_portal,1,0,0,0
|
||||||
|
badge_public,"Badge Public",gamification.model_gamification_badge,base.group_public,1,0,0,0
|
||||||
|
|
||||||
|
badge_user_employee,"Badge-user Employee",model_gamification_badge_user,base.group_user,1,1,1,0
|
||||||
|
badge_user_manager,"Badge-user Manager",model_gamification_badge_user,base.group_erp_manager,1,1,1,1
|
||||||
|
badge_user_portal,"Badge-user Portal",gamification.model_gamification_badge_user,base.group_portal,1,1,1,0
|
||||||
|
badge_user_public,"Badge-user Public",gamification.model_gamification_badge_user,base.group_public,1,0,0,0
|
||||||
|
|
||||||
|
gamification_karma_rank_access_public,gamification.karma.rank.access.all,gamification.model_gamification_karma_rank,base.group_public,1,0,0,0
|
||||||
|
gamification_karma_rank_access_portal,gamification.karma.rank.access.all,gamification.model_gamification_karma_rank,base.group_portal,1,0,0,0
|
||||||
|
gamification_karma_rank_access_employee,gamification.karma.rank.access.all,gamification.model_gamification_karma_rank,base.group_user,1,0,0,0
|
||||||
|
gamification_karma_rank_access_user_manager,gamification.karma.rank.access.user.manager,gamification.model_gamification_karma_rank,base.group_system,1,1,1,1
|
||||||
|
gamification_karma_tracking_access_all,gamification.karma.tracking.access.all,gamification.model_gamification_karma_tracking,,0,0,0,0
|
||||||
|
gamification_karma_tracking_access_user_manager,gamification.karma.tracking.access.user.manager,gamification.model_gamification_karma_tracking,base.group_system,1,1,1,1
|
||||||
|
|
||||||
|
access_gamification_goal_wizard,access.gamification.goal.wizard,model_gamification_goal_wizard,base.group_user,1,1,1,0
|
||||||
|
access_gamification_badge_user_wizard,access.gamification.badge.user.wizard,model_gamification_badge_user_wizard,base.group_user,1,1,1,0
|
|
BIN
static/description/icon.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
1
static/description/icon.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#o_icon_gamification__a)"><path d="M42 33c0 9.389-7.611 17-17 17S8 42.39 8 33s7.611-17 17-17 17 7.611 17 17Z" fill="#FBB945"/><path d="M33.497 18.272 25.001 33l-8.497-14.728A16.922 16.922 0 0 1 25 16c3.095 0 5.997.827 8.496 2.272Z" fill="#F78613"/><path d="M8 33c0 9.389 7.611 17 17 17V16c-9.389 0-17 7.611-17 17Z" fill="#F78613"/><path d="M16.62 0h8.381v16c-3.095 0-5.997.827-8.497 2.272L11.207 9.09a4 4 0 0 1 .08-4.131l1.95-3.092A4 4 0 0 1 16.62 0Z" fill="#962B48"/><path d="M25 16c-3.095 0-5.996.827-8.496 2.272L25 33V16Z" fill="#FBB945"/><path d="M25 16c3.095 0 5.997.827 8.496 2.272l5.298-9.182a4 4 0 0 0-.08-4.131l-1.949-3.092A4 4 0 0 0 33.381 0H25v16Z" fill="#985184"/></g><defs><clipPath id="o_icon_gamification__a"><path fill="#fff" d="M0 0h50v50H0z"/></clipPath></defs></svg>
|
After Width: | Height: | Size: 887 B |
BIN
static/img/badge_good_job-image.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
static/img/badge_hidden-image.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
static/img/badge_idea-image.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/img/badge_problem_solver-image.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
static/img/rank_bachelor.png
Normal file
After Width: | Height: | Size: 13 KiB |
1
static/img/rank_bachelor_badge.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><g fill="none"><circle cx="150" cy="150" r="150" fill="#FFF"/><path fill="#965E3F" d="M150 300C67.157 300 0 232.843 0 150S67.157 0 150 0s150 67.157 150 150-67.157 150-150 150zm0-9.375c77.665 0 140.625-62.96 140.625-140.625 0-77.665-62.96-140.625-140.625-140.625C72.335 9.375 9.375 72.335 9.375 150c0 77.665 62.96 140.625 140.625 140.625zm0-14.063C80.101 276.563 23.437 219.9 23.437 150S80.102 23.437 150 23.437 276.563 80.102 276.563 150 219.899 276.563 150 276.563z"/><path fill="#FFF" d="M169.435 140.543c12 1.957 21.195 6.424 27.587 13.403 6.391 6.978 9.587 15.62 9.587 25.924 0 7.956-2.087 15.293-6.261 22.01-4.174 6.718-10.533 12.098-19.076 16.142-8.544 4.043-19.011 6.065-31.402 6.065-9.653 0-19.142-1.272-28.468-3.815-9.326-2.544-17.25-6.163-23.772-10.859l12.327-24.26c5.217 3.912 11.25 6.945 18.097 9.097a69.746 69.746 0 0 0 21.033 3.228c7.957 0 14.217-1.532 18.783-4.598 4.565-3.065 6.847-7.402 6.847-13.01 0-11.218-8.543-16.827-25.63-16.827h-14.478V142.11l28.174-31.892h-58.305V84.783h95.87v20.543l-30.913 35.217z"/></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/img/rank_doctor.png
Normal file
After Width: | Height: | Size: 14 KiB |