# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import time from datetime import date, datetime, timedelta from odoo import api, fields, models, _, exceptions from odoo.osv import expression from odoo.tools import pycompat from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) 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="Defined how will be computed the goals. 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') field_id = fields.Many2one('ir.model.fields', string='Field to Sum', help='The field containing the value to evaluate') field_date_id = fields.Many2one('ir.model.fields', string='Date Field', help='The date to use for the time period evaluated') 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 distinct one user form 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.user.company_id.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.sudo(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 %s seems incorrect, please check it.\n\n%s not stored") % (definition.name, definition.field_id.name)) except KeyError as e: raise exceptions.UserError( _("The model configuration for the definition %s seems incorrect, please check it.\n\n%s not found") % (definition.name, 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 @api.multi 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 @api.onchange('model_id') def _change_model_id(self): """Force domain for the `field_id` and `field_date_id` fields""" if not self.model_id: return {'domain': {'field_id': expression.FALSE_DOMAIN, 'field_date_id': expression.FALSE_DOMAIN}} model_fields_domain = [ ('store', '=', True), '|', ('model_id', '=', self.model_id.id), ('model_id', 'in', self.model_id.inherited_model_ids.ids)] model_date_fields_domain = expression.AND([[('ttype', 'in', ('date', 'datetime'))], model_fields_domain]) return {'domain': {'field_id': model_fields_domain, 'field_date_id': model_date_fields_domain}} 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 instance' _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, 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, track_visibility='always') # no goal = global index current = fields.Float("Current Value", required=True, default=0, track_visibility='always') 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, track_visibility='always') 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') 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("Definition Condition", related='definition_id.condition', readonly=True) definition_suffix = fields.Char("Suffix", related='definition_id.full_suffix', readonly=True) definition_display = fields.Selection("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) 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 template = self.env.ref('gamification.email_template_goal_reminder')\ .get_email_template(self.id) body_html = self.env['mail.template'].with_context(template._context)\ .render_template(template.body_html, 'gamification.goal', self.id) self.env['mail.thread'].message_post( body=body_html, partner_ids=[ self.user_id.partner_id.id], subtype='mail.mt_comment' ) 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} @api.multi 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: 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 result is not None and isinstance(result, (float, pycompat.integer_types)): 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) else: # count or sum Obj = self.env[definition.model_id.model] field_date_name = definition.field_date_id.name if definition.computation_mode == 'count' and definition.batch_mode: # batch mode, trying to do as much as possible in one request general_domain = safe_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 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, 'id_count': 1} for user in users] else: user_values = Obj.read_group(subquery_domain, fields=[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], pycompat.integer_types): queried_value = queried_value[0] if queried_value == query_goals[goal.id]: new_value = user_value.get(field_name+'_count', 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 # TODO for master: group on user field in batch mode 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)) 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 @api.multi 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() @api.multi 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'}) @api.multi 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'}) @api.multi 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) @api.multi 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 @api.multi 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.sudo(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