summaryrefslogtreecommitdiff
path: root/addons/gamification/models/goal.py
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/gamification/models/goal.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/gamification/models/goal.py')
-rw-r--r--addons/gamification/models/goal.py462
1 files changed, 462 insertions, 0 deletions
diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py
new file mode 100644
index 00000000..0e5d1be8
--- /dev/null
+++ b/addons/gamification/models/goal.py
@@ -0,0 +1,462 @@
+# -*- 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.osv import expression
+from odoo.tools.safe_eval import safe_eval, time
+
+_logger = logging.getLogger(__name__)
+
+
+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', help='The model object for the field to evaluate')
+ model_inherited_ids = fields.Many2many('ir.model', related='model_id.inherited_model_ids')
+ field_id = fields.Many2one(
+ 'ir.model.fields', string='Field to Sum', help='The field containing the value to evaluate',
+ 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
+ def create(self, vals):
+ definition = super(GoalDefinition, self).create(vals)
+ if definition.computation_mode in ('count', 'sum'):
+ definition._check_domain_validity()
+ if vals.get('field_id'):
+ definition._check_model_validity()
+ return definition
+
+ 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
+
+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', help="These goals will not be recomputed.")
+
+ 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':
+ value_field_name = field_name + '_count'
+ if field_name == 'id':
+ # grouping on id does not work and is similar to search anyway
+ users = Obj.search(subquery_domain)
+ user_values = [{'id': user.id, value_field_name: 1} for user in users]
+ else:
+ user_values = Obj.read_group(subquery_domain, fields=[field_name], groupby=[field_name])
+
+ else: # sum
+ value_field_name = definition.field_id.name
+ if field_name == 'id':
+ user_values = Obj.search_read(subquery_domain, fields=['id', value_field_name])
+ else:
+ user_values = Obj.read_group(subquery_domain, fields=[field_name, "%s:sum" % value_field_name], groupby=[field_name])
+
+ # user_values has format of read_group: [{'partner_id': 42, 'partner_id_count': 3},...]
+ for goal in [g for g in goals if g.id in query_goals]:
+ for user_value in user_values:
+ queried_value = field_name in user_value and user_value[field_name] or False
+ if isinstance(queried_value, tuple) and len(queried_value) == 2 and isinstance(queried_value[0], int):
+ queried_value = queried_value[0]
+ if queried_value == query_goals[goal.id]:
+ new_value = user_value.get(value_field_name, goal.current)
+ goals_to_write.update(goal._get_write_values(new_value))
+
+ 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 and res[0][field_name] 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
+ def create(self, vals):
+ return super(Goal, self.with_context(no_remind_goal=True)).create(vals)
+
+ 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.today()
+ 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