summaryrefslogtreecommitdiff
path: root/addons/gamification/models
diff options
context:
space:
mode:
authorThibault Delavallée <tde@openerp.com>2014-01-06 14:57:00 +0100
committerThibault Delavallée <tde@openerp.com>2014-01-06 14:57:00 +0100
commit49c0b7a82003a889edc19cb189d0d6d0ebda2c5f (patch)
treed24c9d3fe3baad3ba06f11d4bf334e2504fb6092 /addons/gamification/models
parentc4c268c902a134efdf0f097053c9b28d6b14417c (diff)
parentced63415b7319e12fb9d31a3e370850278f697c1 (diff)
[MERGE] Sync with trunk
bzr revid: tde@openerp.com-20140106135700-rt0g44w5xmpbtxiu
Diffstat (limited to 'addons/gamification/models')
-rw-r--r--addons/gamification/models/__init__.py25
-rw-r--r--addons/gamification/models/badge.py276
-rw-r--r--addons/gamification/models/challenge.py821
-rw-r--r--addons/gamification/models/goal.py394
-rw-r--r--addons/gamification/models/res_users.py132
5 files changed, 1648 insertions, 0 deletions
diff --git a/addons/gamification/models/__init__.py b/addons/gamification/models/__init__.py
new file mode 100644
index 00000000000..812a08b1e19
--- /dev/null
+++ b/addons/gamification/models/__init__.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013 OpenERP SA (<http://openerp.com>).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+
+import goal
+import challenge
+import res_users
+import badge
diff --git a/addons/gamification/models/badge.py b/addons/gamification/models/badge.py
new file mode 100644
index 00000000000..1d879393ed2
--- /dev/null
+++ b/addons/gamification/models/badge.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013 OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+from openerp import SUPERUSER_ID
+from openerp.osv import fields, osv
+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DF
+from openerp.tools.translate import _
+
+from datetime import date
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class gamification_badge_user(osv.Model):
+ """User having received a badge"""
+
+ _name = 'gamification.badge.user'
+ _description = 'Gamification user badge'
+ _order = "create_date desc"
+
+ _columns = {
+ 'user_id': fields.many2one('res.users', string="User", required=True),
+ 'sender_id': fields.many2one('res.users', string="Sender", help="The user who has send the badge"),
+ 'badge_id': fields.many2one('gamification.badge', string='Badge', required=True),
+ 'comment': fields.text('Comment'),
+ 'badge_name': fields.related('badge_id', 'name', type="char", string="Badge Name"),
+ 'create_date': fields.datetime('Created', readonly=True),
+ 'create_uid': fields.many2one('res.users', string='Creator', readonly=True),
+ }
+
+
+ def _send_badge(self, cr, uid, ids, context=None):
+ """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
+ """
+ res = True
+ temp_obj = self.pool.get('email.template')
+ user_obj = self.pool.get('res.users')
+ template_id = self.pool['ir.model.data'].get_object(cr, uid, 'gamification', 'email_template_badge_received', context)
+ for badge_user in self.browse(cr, uid, ids, context=context):
+ body_html = temp_obj.render_template(cr, uid, template_id.body_html, 'gamification.badge.user', badge_user.id, context=context)
+ res = user_obj.message_post(cr, uid, badge_user.user_id.id, body=body_html, context=context)
+ return res
+
+ def create(self, cr, uid, vals, context=None):
+ self.pool.get('gamification.badge').check_granting(cr, uid, badge_id=vals.get('badge_id'), context=context)
+ return super(gamification_badge_user, self).create(cr, uid, vals, context=context)
+
+
+class gamification_badge(osv.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']
+
+ def _get_owners_info(self, cr, uid, ids, name, args, context=None):
+ """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
+ """
+ result = dict.fromkeys(ids, False)
+ for obj in self.browse(cr, uid, ids, context=context):
+ res = list(set(owner.user_id.id for owner in obj.owner_ids))
+ result[obj.id] = {
+ 'unique_owner_ids': res,
+ 'stat_count': len(obj.owner_ids),
+ 'stat_count_distinct': len(res)
+ }
+ return result
+
+ def _get_badge_user_stats(self, cr, uid, ids, name, args, context=None):
+ """Return stats related to badge users"""
+ result = dict.fromkeys(ids, False)
+ badge_user_obj = self.pool.get('gamification.badge.user')
+ first_month_day = date.today().replace(day=1).strftime(DF)
+ for bid in ids:
+ result[bid] = {
+ 'stat_my': badge_user_obj.search(cr, uid, [('badge_id', '=', bid), ('user_id', '=', uid)], context=context, count=True),
+ 'stat_this_month': badge_user_obj.search(cr, uid, [('badge_id', '=', bid), ('create_date', '>=', first_month_day)], context=context, count=True),
+ 'stat_my_this_month': badge_user_obj.search(cr, uid, [('badge_id', '=', bid), ('user_id', '=', uid), ('create_date', '>=', first_month_day)], context=context, count=True),
+ 'stat_my_monthly_sending': badge_user_obj.search(cr, uid, [('badge_id', '=', bid), ('create_uid', '=', uid), ('create_date', '>=', first_month_day)], context=context, count=True)
+ }
+ return result
+
+ def _remaining_sending_calc(self, cr, uid, ids, name, args, context=None):
+ """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)
+ """
+ result = dict.fromkeys(ids, False)
+ for badge in self.browse(cr, uid, ids, context=context):
+ if self._can_grant_badge(cr, uid, badge.id, context) != 1:
+ # if the user cannot grant this badge at all, result is 0
+ result[badge.id] = 0
+ elif not badge.rule_max:
+ # if there is no limitation, -1 is returned which means 'infinite'
+ result[badge.id] = -1
+ else:
+ result[badge.id] = badge.rule_max_number - badge.stat_my_monthly_sending
+ return result
+
+ _columns = {
+ 'name': fields.char('Badge', required=True, translate=True),
+ 'description': fields.text('Description'),
+ 'image': fields.binary("Image", help="This field holds the image used for the badge, limited to 256x256"),
+ 'rule_auth': fields.selection([
+ ('everyone', 'Everyone'),
+ ('users', 'A selected list of users'),
+ ('having', 'People having some badges'),
+ ('nobody', 'No one, assigned through challenges'),
+ ],
+ 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',
+ 'rel_badge_badge', '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."),
+ 'stat_my_monthly_sending': fields.function(_get_badge_user_stats,
+ type="integer",
+ string='My Monthly Sending Total',
+ multi='badge_users',
+ help="The number of time the current user has sent this badge this month."),
+ 'remaining_sending': fields.function(_remaining_sending_calc, type='integer',
+ string='Remaining Sending Allowed', help="If a maxium is set"),
+
+ '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 theses 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'),
+ 'active': fields.boolean('Active'),
+ 'unique_owner_ids': fields.function(_get_owners_info,
+ string='Unique Owners',
+ help="The list of unique users having received this badge.",
+ multi='unique_users',
+ type="many2many", relation="res.users"),
+
+ 'stat_count': fields.function(_get_owners_info, string='Total',
+ type="integer",
+ multi='unique_users',
+ help="The number of time this badge has been received."),
+ 'stat_count_distinct': fields.function(_get_owners_info,
+ type="integer",
+ string='Number of users',
+ multi='unique_users',
+ help="The number of time this badge has been received by unique users."),
+ 'stat_this_month': fields.function(_get_badge_user_stats,
+ type="integer",
+ string='Monthly total',
+ multi='badge_users',
+ help="The number of time this badge has been received this month."),
+ 'stat_my': fields.function(_get_badge_user_stats, string='My Total',
+ type="integer",
+ multi='badge_users',
+ help="The number of time the current user has received this badge."),
+ 'stat_my_this_month': fields.function(_get_badge_user_stats,
+ type="integer",
+ string='My Monthly Total',
+ multi='badge_users',
+ help="The number of time the current user has received this badge this month."),
+ }
+
+ _defaults = {
+ 'rule_auth': 'everyone',
+ 'active': True,
+ }
+
+ def check_granting(self, cr, uid, badge_id, context=None):
+ """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(cr, uid, badge_id, context=context)
+ if status_code == self.CAN_GRANT:
+ return True
+ elif status_code == self.NOBODY_CAN_GRANT:
+ raise osv.except_osv(_('Warning!'), _('This badge can not be sent by users.'))
+ elif status_code == self.USER_NOT_VIP:
+ raise osv.except_osv(_('Warning!'), _('You are not in the user allowed list.'))
+ elif status_code == self.BADGE_REQUIRED:
+ raise osv.except_osv(_('Warning!'), _('You do not have the required badges.'))
+ elif status_code == self.TOO_MANY:
+ raise osv.except_osv(_('Warning!'), _('You have already sent this badge too many time this month.'))
+ else:
+ _logger.exception("Unknown badge status code: %d" % int(status_code))
+ return False
+
+ def _can_grant_badge(self, cr, uid, badge_id, context=None):
+ """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 uid == SUPERUSER_ID:
+ return self.CAN_GRANT
+
+ badge = self.browse(cr, uid, badge_id, context=context)
+
+ if badge.rule_auth == 'nobody':
+ return self.NOBODY_CAN_GRANT
+
+ elif badge.rule_auth == 'users' and uid not in [user.id for user in badge.rule_auth_user_ids]:
+ return self.USER_NOT_VIP
+
+ elif badge.rule_auth == 'having':
+ all_user_badges = self.pool.get('gamification.badge.user').search(cr, uid, [('user_id', '=', uid)], context=context)
+ for required_badge in badge.rule_auth_badge_ids:
+ if required_badge.id not in all_user_badges:
+ return self.BADGE_REQUIRED
+
+ if badge.rule_max and badge.stat_my_monthly_sending >= badge.rule_max_number:
+ return self.TOO_MANY
+
+ # badge.rule_auth == 'everyone' -> no check
+ return self.CAN_GRANT
+
+ def check_progress(self, cr, uid, context=None):
+ try:
+ model, res_id = template_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'badge_hidden')
+ except ValueError:
+ return True
+ badge_user_obj = self.pool.get('gamification.badge.user')
+ if not badge_user_obj.search(cr, uid, [('user_id', '=', uid), ('badge_id', '=', res_id)], context=context):
+ values = {
+ 'user_id': uid,
+ 'badge_id': res_id,
+ }
+ badge_user_obj.create(cr, SUPERUSER_ID, values, context=context)
+ return True \ No newline at end of file
diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py
new file mode 100644
index 00000000000..ae27aa96719
--- /dev/null
+++ b/addons/gamification/models/challenge.py
@@ -0,0 +1,821 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013 OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+from openerp import SUPERUSER_ID
+from openerp.osv import fields, osv
+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DF
+from openerp.tools.translate import _
+
+from datetime import date, datetime, timedelta
+import calendar
+import logging
+_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
+
+ :return: (start_date, end_date), datetime.date objects, 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':
+ delta = timedelta(days=today.weekday())
+ start_date = today - delta
+ end_date = start_date + timedelta(days=7)
+ elif period == 'monthly':
+ month_range = calendar.monthrange(today.year, today.month)
+ start_date = today.replace(day=1)
+ end_date = today.replace(day=month_range[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
+
+ if start_date and end_date:
+ return (start_date.strftime(DF), end_date.strftime(DF))
+ else:
+ return (start_date, end_date)
+
+
+class gamification_challenge(osv.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'
+
+ def _get_next_report_date(self, cr, uid, ids, field_name, arg, context=None):
+ """Return the next report date based on the last report date and report
+ period.
+
+ :return: a string in DEFAULT_SERVER_DATE_FORMAT representing the date"""
+ res = {}
+ for challenge in self.browse(cr, uid, ids, context):
+ last = datetime.strptime(challenge.last_report_date, DF).date()
+ if challenge.report_message_frequency == 'daily':
+ next = last + timedelta(days=1)
+ res[challenge.id] = next.strftime(DF)
+ elif challenge.report_message_frequency == 'weekly':
+ next = last + timedelta(days=7)
+ res[challenge.id] = next.strftime(DF)
+ elif challenge.report_message_frequency == 'monthly':
+ month_range = calendar.monthrange(last.year, last.month)
+ next = last.replace(day=month_range[1]) + timedelta(days=1)
+ res[challenge.id] = next.strftime(DF)
+ elif challenge.report_message_frequency == 'yearly':
+ res[challenge.id] = last.replace(year=last.year + 1).strftime(DF)
+ # frequency == 'once', reported when closed only
+ else:
+ res[challenge.id] = False
+
+ return res
+
+ def _get_categories(self, cr, uid, context=None):
+ return [
+ ('hr', 'Human Ressources / Engagement'),
+ ('other', 'Settings / Gamification Tools'),
+ ]
+
+ def _get_report_template(self, cr, uid, context=None):
+ try:
+ return self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'simple_report_template')[1]
+ except ValueError:
+ return False
+
+ _order = 'end_date, start_date, name, id'
+ _columns = {
+ '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'),
+ ],
+ string='State', required=True, track_visibility='onchange'),
+ 'manager_id': fields.many2one('res.users',
+ string='Responsible', help="The user responsible for the challenge."),
+
+ 'user_ids': fields.many2many('res.users', 'user_ids',
+ string='Users',
+ help="List of users participating to the challenge"),
+ 'autojoin_group_id': fields.many2one('res.groups',
+ string='Auto-subscription Group',
+ help='Group of users whose members will be automatically added to user_ids once the challenge is started'),
+
+ 'period': fields.selection([
+ ('once', 'Non recurring'),
+ ('daily', 'Daily'),
+ ('weekly', 'Weekly'),
+ ('monthly', 'Monthly'),
+ ('yearly', 'Yearly')
+ ],
+ string='Periodicity',
+ help='Period of automatic goal assigment. 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', 'invited_user_ids',
+ 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),
+
+ 'reward_id': fields.many2one('gamification.badge', string="For Every Succeding 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?'),
+
+ 'visibility_mode': fields.selection([
+ ('personal', 'Individual Goals'),
+ ('ranking', 'Leader Board (Group Ranking)'),
+ ],
+ string="Display Mode", required=True),
+
+ 'report_message_frequency': fields.selection([
+ ('never', 'Never'),
+ ('onchange', 'On change'),
+ ('daily', 'Daily'),
+ ('weekly', 'Weekly'),
+ ('monthly', 'Monthly'),
+ ('yearly', 'Yearly')
+ ],
+ string="Report Frequency", required=True),
+ 'report_message_group_id': fields.many2one('mail.group',
+ 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('email.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'),
+ 'next_report_date': fields.function(_get_next_report_date,
+ type='date', string='Next Report Date', store=True),
+
+ 'category': fields.selection(lambda s, *a, **k: s._get_categories(*a, **k),
+ string="Appears in", help="Define the visibility of the challenge through menus", required=True),
+ }
+
+ _defaults = {
+ 'period': 'once',
+ 'state': 'draft',
+ 'visibility_mode': 'personal',
+ 'report_message_frequency': 'never',
+ 'last_report_date': fields.date.today,
+ 'start_date': fields.date.today,
+ 'manager_id': lambda s, cr, uid, c: uid,
+ 'category': 'hr',
+ 'reward_failure': False,
+ 'report_template_id': lambda s, *a, **k: s._get_report_template(*a, **k),
+ }
+
+
+ def create(self, cr, uid, vals, context=None):
+ """Overwrite the create method to add the user of groups"""
+
+ # add users when change the group auto-subscription
+ if vals.get('autojoin_group_id'):
+ new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
+
+ if not vals.get('user_ids'):
+ vals['user_ids'] = []
+ vals['user_ids'] += [(4, user.id) for user in new_group.users]
+
+ create_res = super(gamification_challenge, self).create(cr, uid, vals, context=context)
+
+ # subscribe new users to the challenge
+ if vals.get('user_ids'):
+ # done with browse after super to be sure catch all after orm process
+ challenge = self.browse(cr, uid, create_res, context=context)
+ self.message_subscribe_users(cr, uid, [challenge.id], [user.id for user in challenge.user_ids], context=context)
+
+ return create_res
+
+ def write(self, cr, uid, ids, vals, context=None):
+ if isinstance(ids, (int,long)):
+ ids = [ids]
+
+ # add users when change the group auto-subscription
+ if vals.get('autojoin_group_id'):
+ new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
+
+ if not vals.get('user_ids'):
+ vals['user_ids'] = []
+ vals['user_ids'] += [(4, user.id) for user in new_group.users]
+
+ if vals.get('state') == 'inprogress':
+ # starting a challenge
+ if not vals.get('autojoin_group_id'):
+ # starting challenge, add users in autojoin group
+ if not vals.get('user_ids'):
+ vals['user_ids'] = []
+ for challenge in self.browse(cr, uid, ids, context=context):
+ if challenge.autojoin_group_id:
+ vals['user_ids'] += [(4, user.id) for user in challenge.autojoin_group_id.users]
+
+ self.generate_goals_from_challenge(cr, uid, ids, context=context)
+
+ elif vals.get('state') == 'done':
+ self.check_challenge_reward(cr, uid, ids, force=True, context=context)
+
+ elif vals.get('state') == 'draft':
+ # resetting progress
+ if self.pool.get('gamification.goal').search(cr, uid, [('challenge_id', 'in', ids), ('state', 'in', ['inprogress', 'inprogress_update'])], context=context):
+ raise osv.except_osv("Error", "You can not reset a challenge with unfinished goals.")
+
+ write_res = super(gamification_challenge, self).write(cr, uid, ids, vals, context=context)
+
+ # subscribe new users to the challenge
+ if vals.get('user_ids'):
+ # done with browse after super if changes in groups
+ for challenge in self.browse(cr, uid, ids, context=context):
+ self.message_subscribe_users(cr, uid, [challenge.id], [user.id for user in challenge.user_ids], context=context)
+
+ return write_res
+
+
+ ##### Update #####
+
+ def _cron_update(self, cr, uid, context=None, ids=False):
+ """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
+ """
+ # start planned challenges
+ planned_challenge_ids = self.search(cr, uid, [
+ ('state', '=', 'draft'),
+ ('start_date', '<=', fields.date.today())])
+ self.write(cr, uid, planned_challenge_ids, {'state': 'inprogress'}, context=context)
+
+ # close planned challenges
+ planned_challenge_ids = self.search(cr, uid, [
+ ('state', '=', 'inprogress'),
+ ('end_date', '>=', fields.date.today())])
+ self.write(cr, uid, planned_challenge_ids, {'state': 'done'}, context=context)
+
+ if not ids:
+ ids = self.search(cr, uid, [('state', '=', 'inprogress')], context=context)
+
+ return self._update_all(cr, uid, ids, context=context)
+
+ def _update_all(self, cr, uid, ids, context=None):
+ """Update the challenges and related goals
+
+ :param list(int) ids: the ids of the challenges to update, if False will
+ update only challenges in progress."""
+ if isinstance(ids, (int,long)):
+ ids = [ids]
+
+ goal_obj = self.pool.get('gamification.goal')
+
+ # we use yesterday to update the goals that just ended
+ yesterday = date.today() - timedelta(days=1)
+ goal_ids = goal_obj.search(cr, uid, [
+ ('challenge_id', 'in', ids),
+ '|',
+ ('state', 'in', ('inprogress', 'inprogress_update')),
+ '&',
+ ('state', 'in', ('reached', 'failed')),
+ '|',
+ ('end_date', '>=', yesterday.strftime(DF)),
+ ('end_date', '=', False)
+ ], context=context)
+ # update every running goal already generated linked to selected challenges
+ goal_obj.update(cr, uid, goal_ids, context=context)
+
+ for challenge in self.browse(cr, uid, ids, context=context):
+ if challenge.autojoin_group_id:
+ # check in case of new users in challenge, this happens if manager removed users in challenge manually
+ self.write(cr, uid, [challenge.id], {'user_ids': [(4, user.id) for user in challenge.autojoin_group_id.users]}, context=context)
+ self.generate_goals_from_challenge(cr, uid, [challenge.id], context=context)
+
+ # goals closed but still opened at the last report date
+ closed_goals_to_report = goal_obj.search(cr, uid, [
+ ('challenge_id', '=', challenge.id),
+ ('start_date', '>=', challenge.last_report_date),
+ ('end_date', '<=', challenge.last_report_date)
+ ])
+
+ if len(closed_goals_to_report) > 0:
+ # some goals need a final report
+ self.report_progress(cr, uid, challenge, subset_goal_ids=closed_goals_to_report, context=context)
+
+ if fields.date.today() == challenge.next_report_date:
+ self.report_progress(cr, uid, challenge, context=context)
+
+ self.check_challenge_reward(cr, uid, ids, context=context)
+ return True
+
+ def quick_update(self, cr, uid, challenge_id, context=None):
+ """Update all the goals of a challenge, no generation of new goals"""
+ goal_ids = self.pool.get('gamification.goal').search(cr, uid, [('challenge_id', '=', challenge_id)], context=context)
+ self.pool.get('gamification.goal').update(cr, uid, goal_ids, context=context)
+ return True
+
+
+ def action_check(self, cr, uid, ids, context=None):
+ """Check a challenge
+
+ Create goals that haven't been created yet (eg: if added users)
+ Recompute the current value for each goal related"""
+ return self._update_all(cr, uid, ids=ids, context=context)
+
+ def action_report_progress(self, cr, uid, ids, context=None):
+ """Manual report of a goal, does not influence automatic report frequency"""
+ if isinstance(ids, (int,long)):
+ ids = [ids]
+ for challenge in self.browse(cr, uid, ids, context):
+ self.report_progress(cr, uid, challenge, context=context)
+ return True
+
+
+ ##### Automatic actions #####
+
+ def generate_goals_from_challenge(self, cr, uid, ids, context=None):
+ """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"""
+
+ for challenge in self.browse(cr, uid, ids, context):
+ (start_date, end_date) = start_end_date_for_period(challenge.period)
+
+ # if no periodicity, use challenge dates
+ if not start_date and challenge.start_date:
+ start_date = challenge.start_date
+ if not end_date and challenge.end_date:
+ end_date = challenge.end_date
+
+ for line in challenge.line_ids:
+ for user in challenge.user_ids:
+
+ goal_obj = self.pool.get('gamification.goal')
+ domain = [('line_id', '=', line.id), ('user_id', '=', user.id)]
+ if start_date:
+ domain.append(('start_date', '=', start_date))
+
+ # goal already existing for this line ?
+ if len(goal_obj.search(cr, uid, domain, context=context)) > 0:
+
+ # resume canceled goals
+ domain.append(('state', '=', 'canceled'))
+ canceled_goal_ids = goal_obj.search(cr, uid, domain, context=context)
+ goal_obj.write(cr, uid, canceled_goal_ids, {'state': 'inprogress'}, context=context)
+ goal_obj.update(cr, uid, canceled_goal_ids, context=context)
+
+ # skip to next user
+ continue
+
+ values = {
+ 'definition_id': line.definition_id.id,
+ 'line_id': line.id,
+ 'user_id': user.id,
+ 'target_goal': line.target_goal,
+ 'state': 'inprogress',
+ }
+
+ if start_date:
+ values['start_date'] = start_date
+ if end_date:
+ values['end_date'] = end_date
+
+ if challenge.remind_update_delay:
+ values['remind_update_delay'] = challenge.remind_update_delay
+
+ new_goal_id = goal_obj.create(cr, uid, values, context)
+
+ goal_obj.update(cr, uid, [new_goal_id], context=context)
+
+ return True
+
+ ##### JS utilities #####
+
+ def _get_serialized_challenge_lines(self, cr, uid, challenge, user_id=False, restrict_goal_ids=False, restrict_top=False, context=None):
+ """Return a serialised version of the goals information
+
+ :challenge: browse record of challenge to compute
+ :user_id: res.users id of the user retrieving progress (False if no distinction, only for ranking challenges)
+ :restrict_goal_ids: <list(int)> compute only the results for this subset if gamification.goal ids, if False retrieve every goal of current running challenge
+ :restrict_top: <int> for challenge lines where visibility_mode == 'ranking', retrieve only these bests results and itself, if False 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,inprogress_update,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,inprogress_update,reached,failed,canceled}>,
+ 'completeness': <percentage>,
+ 'current': <current value>,
+ }
+ """
+ goal_obj = self.pool.get('gamification.goal')
+ (start_date, end_date) = start_end_date_for_period(challenge.period)
+
+ res_lines = []
+ for line in challenge.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_goal_ids:
+ domain.append(('ids', 'in', restrict_goal_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 challenge.visibility_mode == 'personal':
+ if not user_id:
+ raise osv.except_osv(_('Error!'),_("Retrieving progress for personal challenge without user information"))
+ domain.append(('user_id', '=', user_id))
+ sorting = goal_obj._order
+ limit = 1
+ # initialise in case search returns no results
+ line_data.update({
+ 'id': 0,
+ 'current': 0,
+ 'completeness': 0,
+ 'state': 'draft',
+ })
+ else:
+ line_data.update({
+ 'own_goal_id': False,
+ 'goals': [],
+ })
+ sorting = "completeness desc, current desc"
+ limit = False
+
+ goal_ids = goal_obj.search(cr, uid, domain, order=sorting, limit=limit, context=context)
+ ranking = 0
+ for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
+ if challenge.visibility_mode == 'personal':
+ # limit=1 so only one result
+ line_data.update({
+ 'id': goal.id,
+ 'current': goal.current,
+ 'completeness': goal.completeness,
+ 'state': goal.state,
+ })
+ else:
+ ranking += 1
+ if user_id and goal.user_id.id == user_id:
+ line_data['own_goal_id'] = goal.id
+ elif restrict_top and ranking > restrict_top:
+ # not own goal, over top, skipping
+ 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,
+ })
+ res_lines.append(line_data)
+ return res_lines
+
+ ##### Reporting #####
+
+ def report_progress(self, cr, uid, challenge, context=None, users=False, subset_goal_ids=False):
+ """Post report about the progress of the goals
+
+ :param challenge: the challenge object that need to be reported
+ :param users: the list(res.users) of 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 goal_ids: the list(int) of goal ids linked to the challenge for
+ the report. If not specified, use the goals for the current challenge
+ period. This parameter can be used to produce report for previous challenge
+ periods.
+ :param subset_goal_ids: a list(int) of goal ids to restrict the report
+ """
+ if context is None:
+ context = {}
+
+ temp_obj = self.pool.get('email.template')
+ ctx = context.copy()
+ if challenge.visibility_mode == 'ranking':
+ lines_boards = self._get_serialized_challenge_lines(cr, uid, challenge, user_id=False, restrict_goal_ids=subset_goal_ids, restrict_top=False, context=context)
+
+ ctx.update({'challenge_lines': lines_boards})
+ body_html = temp_obj.render_template(cr, uid, challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
+
+ # send to every follower of the challenge
+ self.message_post(cr, uid, challenge.id,
+ body=body_html,
+ context=context,
+ subtype='mail.mt_comment')
+ if challenge.report_message_group_id:
+ self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
+ body=body_html,
+ context=context,
+ subtype='mail.mt_comment')
+
+ else:
+ # generate individual reports
+ for user in users or challenge.user_ids:
+ goals = self._get_serialized_challenge_lines(cr, uid, challenge, user.id, restrict_goal_ids=subset_goal_ids, context=context)
+ if not goals:
+ continue
+
+ ctx.update({'challenge_lines': goals})
+ body_html = temp_obj.render_template(cr, user.id, challenge.report_template_id.body_html, 'gamification.challenge', challenge.id, context=ctx)
+
+ # send message only to users, not on the challenge
+ self.message_post(cr, uid, 0,
+ body=body_html,
+ partner_ids=[(4, user.partner_id.id)],
+ context=context,
+ subtype='mail.mt_comment')
+ if challenge.report_message_group_id:
+ self.pool.get('mail.group').message_post(cr, uid, challenge.report_message_group_id.id,
+ body=body_html,
+ context=context,
+ subtype='mail.mt_comment')
+ return self.write(cr, uid, challenge.id, {'last_report_date': fields.date.today()}, context=context)
+
+ ##### Challenges #####
+ def accept_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
+ """The user accept the suggested challenge"""
+ user_id = user_id or uid
+ user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
+ message = "%s has joined the challenge" % user.name
+ self.message_post(cr, uid, challenge_ids, body=message, context=context)
+ self.write(cr, SUPERUSER_ID, challenge_ids, {'invited_user_ids': [(3, user_id)], 'user_ids': [(4, user_id)]}, context=context)
+ return self.generate_goals_from_challenge(cr, uid, challenge_ids, context=context)
+
+ def discard_challenge(self, cr, uid, challenge_ids, context=None, user_id=None):
+ """The user discard the suggested challenge"""
+ user_id = user_id or uid
+ user = self.pool.get('res.users').browse(cr, uid, user_id, context=context)
+ message = "%s has refused the challenge" % user.name
+ self.message_post(cr, SUPERUSER_ID, challenge_ids, body=message, context=context)
+ return self.write(cr, uid, challenge_ids, {'invited_user_ids': (3, user_id)}, context=context)
+
+ def reply_challenge_wizard(self, cr, uid, challenge_id, context=None):
+ result = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'gamification', 'challenge_wizard')
+ id = result and result[1] or False
+ result = self.pool.get('ir.actions.act_window').read(cr, uid, [id], context=context)[0]
+ result['res_id'] = challenge_id
+ return result
+
+ def check_challenge_reward(self, cr, uid, ids, force=False, context=None):
+ """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)
+ """
+ if isinstance(ids, (int,long)):
+ ids = [ids]
+ context = context or {}
+ for challenge in self.browse(cr, uid, ids, context=context):
+ (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
+ yesterday = date.today() - timedelta(days=1)
+ if end_date == yesterday.strftime(DF) or force:
+ # open chatter message
+ message_body = _("The challenge %s is finished." % challenge.name)
+
+ # reward for everybody succeeding
+ rewarded_users = []
+ if challenge.reward_id:
+ for user in challenge.user_ids:
+ reached_goal_ids = self.pool.get('gamification.goal').search(cr, uid, [
+ ('challenge_id', '=', challenge.id),
+ ('user_id', '=', user.id),
+ ('start_date', '=', start_date),
+ ('end_date', '=', end_date),
+ ('state', '=', 'reached')
+ ], context=context)
+ if len(reached_goal_ids) == len(challenge.line_ids):
+ self.reward_user(cr, uid, user.id, challenge.reward_id.id, context)
+ rewarded_users.append(user)
+
+ if rewarded_users:
+ message_body += _("<br/>Reward (badge %s) for every succeeding user was sent to %s." % (challenge.reward_id.name, ", ".join([user.name for user in rewarded_users])))
+ else:
+ message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewared for this challenge.")
+
+ # reward bests
+ if challenge.reward_first_id:
+ (first_user, second_user, third_user) = self.get_top3_users(cr, uid, challenge, context)
+ if first_user:
+ self.reward_user(cr, uid, first_user.id, challenge.reward_first_id.id, context)
+ message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :")
+ message_body += "<br/> 1. %s - %s" % (first_user.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:
+ self.reward_user(cr, uid, second_user.id, challenge.reward_second_id.id, context)
+ message_body += "<br/> 2. %s - %s" % (second_user.name, challenge.reward_second_id.name)
+ if third_user and challenge.reward_third_id:
+ self.reward_user(cr, uid, third_user.id, challenge.reward_second_id.id, context)
+ message_body += "<br/> 3. %s - %s" % (third_user.name, challenge.reward_third_id.name)
+
+ self.message_post(cr, uid, challenge.id, body=message_body, context=context)
+ return True
+
+ def get_top3_users(self, cr, uid, challenge, context=None):
+ """Get the top 3 users for a defined challenge
+
+ Ranking criterias:
+ 1. succeed every goal of the challenge
+ 2. total completeness of each goal (can be over 100)
+ Top 3 is computed only for users succeeding every goal of the challenge,
+ except if reward_failure is True, in which case every user is
+ considered.
+ :return: ('first', 'second', 'third'), tuple containing the res.users
+ objects of the top 3 users. If no user meets the criterias for a rank,
+ it is set to False. Nobody can receive a rank is noone receives the
+ higher one (eg: if 'second' == False, 'third' will be False)
+ """
+ goal_obj = self.pool.get('gamification.goal')
+ (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date)
+ challengers = []
+ for user in challenge.user_ids:
+ all_reached = True
+ total_completness = 0
+ # every goal of the user for the running period
+ goal_ids = goal_obj.search(cr, uid, [
+ ('challenge_id', '=', challenge.id),
+ ('user_id', '=', user.id),
+ ('start_date', '=', start_date),
+ ('end_date', '=', end_date)
+ ], context=context)
+ for goal in goal_obj.browse(cr, uid, goal_ids, context=context):
+ if goal.state != 'reached':
+ all_reached = False
+ if goal.definition_condition == 'higher':
+ # can be over 100
+ total_completness += 100.0 * goal.current / goal.target_goal
+ elif goal.state == 'reached':
+ # for lower goals, can not get percentage so 0 or 100
+ total_completness += 100
+
+ challengers.append({'user': user, 'all_reached': all_reached, 'total_completness': total_completness})
+ sorted_challengers = sorted(challengers, key=lambda k: (k['all_reached'], k['total_completness']), reverse=True)
+
+ if len(sorted_challengers) == 0 or (not challenge.reward_failure and not sorted_challengers[0]['all_reached']):
+ # nobody succeeded
+ return (False, False, False)
+ if len(sorted_challengers) == 1 or (not challenge.reward_failure and not sorted_challengers[1]['all_reached']):
+ # only one user succeeded
+ return (sorted_challengers[0]['user'], False, False)
+ if len(sorted_challengers) == 2 or (not challenge.reward_failure and not sorted_challengers[2]['all_reached']):
+ # only one user succeeded
+ return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], False)
+ return (sorted_challengers[0]['user'], sorted_challengers[1]['user'], sorted_challengers[2]['user'])
+
+ def reward_user(self, cr, uid, user_id, badge_id, context=None):
+ """Create a badge user and send the badge to him
+
+ :param user_id: the user to reward
+ :param badge_id: the concerned badge
+ """
+ badge_user_obj = self.pool.get('gamification.badge.user')
+ user_badge_id = badge_user_obj.create(cr, uid, {'user_id': user_id, 'badge_id': badge_id}, context=context)
+ return badge_user_obj._send_badge(cr, uid, [user_badge_id], context=context)
+
+
+class gamification_challenge_line(osv.Model):
+ """Gamification challenge line
+
+ Predifined 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"
+
+ def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
+ goal_definition = self.pool.get('gamification.goal.definition')
+ if not definition_id:
+ return {'value': {'definition_id': False}}
+ goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
+ ret = {
+ 'value': {
+ 'condition': goal_definition.condition,
+ 'definition_full_suffix': goal_definition.full_suffix
+ }
+ }
+ return ret
+
+ _columns = {
+ 'name': fields.related('definition_id', 'name', string="Name"),
+ '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"),
+ 'target_goal': fields.float('Target Value to Reach',
+ required=True),
+ 'sequence': fields.integer('Sequence',
+ help='Sequence number for ordering'),
+ 'condition': fields.related('definition_id', 'condition', type="selection",
+ readonly=True, string="Condition", selection=[('lower', '<='), ('higher', '>=')]),
+ 'definition_suffix': fields.related('definition_id', 'suffix', type="char", readonly=True, string="Unit"),
+ 'definition_monetary': fields.related('definition_id', 'monetary', type="boolean", readonly=True, string="Monetary"),
+ 'definition_full_suffix': fields.related('definition_id', 'full_suffix', type="char", readonly=True, string="Suffix"),
+ }
+
+ _default = {
+ 'sequence': 1,
+ }
diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py
new file mode 100644
index 00000000000..8792e219d71
--- /dev/null
+++ b/addons/gamification/models/goal.py
@@ -0,0 +1,394 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013 OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+from openerp import SUPERUSER_ID
+from openerp.osv import fields, osv
+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DF
+from openerp.tools.safe_eval import safe_eval
+from openerp.tools.translate import _
+
+import logging
+import time
+from datetime import date, datetime, timedelta
+
+_logger = logging.getLogger(__name__)
+
+
+class gamification_goal_definition(osv.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'
+
+ def _get_suffix(self, cr, uid, ids, field_name, arg, context=None):
+ res = dict.fromkeys(ids, '')
+ for goal in self.browse(cr, uid, ids, context=context):
+ if goal.suffix and not goal.monetary:
+ res[goal.id] = goal.suffix
+ elif goal.monetary:
+ # use the current user's company currency
+ user = self.pool.get('res.users').browse(cr, uid, uid, context)
+ if goal.suffix:
+ res[goal.id] = "%s %s" % (user.company_id.currency_id.symbol, goal.suffix)
+ else:
+ res[goal.id] = user.company_id.currency_id.symbol
+ else:
+ res[goal.id] = ""
+ return res
+
+ _columns = {
+ 'name': fields.char('Goal Definition', required=True, translate=True),
+ 'description': fields.text('Goal Description'),
+ 'monetary': fields.boolean('Monetary Value', 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.function(_get_suffix, type="char", string="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'),
+ ],
+ string="Computation Mode",
+ help="Defined how will be computed the goals. The result of the operation will be stored in the field 'Current'.",
+ required=True),
+ 'display_mode': fields.selection([
+ ('progress', 'Progressive (using numerical values)'),
+ ('boolean', 'Exclusive (done or not-done)'),
+ ],
+ 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",
+ help="Domain for filtering records. The rule can contain reference to 'user' that is a browse record of the current user, e.g. [('user_id', '=', user.id)].",
+ required=True),
+ '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')
+ ],
+ string='Goal Performance',
+ help='A goal is considered as completed when the current value is compared to the value to reach',
+ required=True),
+ '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.")
+ }
+
+ _defaults = {
+ 'condition': 'higher',
+ 'computation_mode': 'manually',
+ 'domain': "[]",
+ 'monetary': False,
+ 'display_mode': 'progress',
+ }
+
+ def number_following(self, cr, uid, model_name="mail.thread", context=None):
+ """Return the number of 'model_name' objects the user is following
+
+ The model specified in 'model_name' must inherit from mail.thread
+ """
+ user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
+ return self.pool.get('mail.followers').search(cr, uid, [('res_model', '=', model_name), ('partner_id', '=', user.partner_id.id)], count=True, context=context)
+
+
+
+class gamification_goal(osv.Model):
+ """Goal instance for a user
+
+ An individual goal for a user on a specified time period"""
+
+ _name = 'gamification.goal'
+ _description = 'Gamification goal instance'
+ _inherit = 'mail.thread'
+
+ def _get_completion(self, cr, uid, ids, field_name, arg, context=None):
+ """Return the percentage of completeness of the goal, between 0 and 100"""
+ res = dict.fromkeys(ids, 0.0)
+ for goal in self.browse(cr, uid, ids, context=context):
+ if goal.definition_condition == 'higher':
+ if goal.current >= goal.target_goal:
+ res[goal.id] = 100.0
+ else:
+ res[goal.id] = 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%
+ res[goal.id] = 100.0
+ else:
+ res[goal.id] = 0.0
+ return res
+
+ def on_change_definition_id(self, cr, uid, ids, definition_id=False, context=None):
+ goal_definition = self.pool.get('gamification.goal.definition')
+ if not definition_id:
+ return {'value': {'definition_id': False}}
+ goal_definition = goal_definition.browse(cr, uid, definition_id, context=context)
+ return {'value': {'computation_mode': goal_definition.computation_mode, 'definition_condition': goal_definition.condition}}
+
+ _columns = {
+ 'definition_id': fields.many2one('gamification.goal.definition', string='Goal Definition', required=True, ondelete="cascade"),
+ 'user_id': fields.many2one('res.users', string='User', required=True),
+ 'line_id': fields.many2one('gamification.challenge.line', string='Goal Line', ondelete="cascade"),
+ 'challenge_id': fields.related('line_id', 'challenge_id',
+ string="Challenge",
+ type='many2one',
+ relation='gamification.challenge',
+ store=True),
+ 'start_date': fields.date('Start Date'),
+ '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, track_visibility='always'),
+ 'completeness': fields.function(_get_completion, type='float', string='Completeness'),
+ 'state': fields.selection([
+ ('draft', 'Draft'),
+ ('inprogress', 'In progress'),
+ ('inprogress_update', 'In progress (to update)'),
+ ('reached', 'Reached'),
+ ('failed', 'Failed'),
+ ('canceled', 'Canceled'),
+ ],
+ string='State',
+ required=True,
+ track_visibility='always'),
+
+ 'computation_mode': fields.related('definition_id', 'computation_mode', type='char', string="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.related('definition_id', 'description', type='char', string='Definition Description', readonly=True),
+ 'definition_condition': fields.related('definition_id', 'condition', type='char', string='Definition Condition', readonly=True),
+ 'definition_suffix': fields.related('definition_id', 'full_suffix', type="char", string="Suffix", readonly=True),
+ 'definition_display': fields.related('definition_id', 'display_mode', type="char", string="Display Mode", readonly=True),
+ }
+
+ _defaults = {
+ 'current': 0,
+ 'state': 'draft',
+ 'start_date': fields.date.today,
+ }
+ _order = 'create_date desc, end_date desc, definition_id, id'
+
+ def _check_remind_delay(self, cr, uid, goal, context=None):
+ """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 goal.remind_update_delay and goal.last_update:
+ delta_max = timedelta(days=goal.remind_update_delay)
+ last_update = datetime.strptime(goal.last_update, DF).date()
+ if date.today() - last_update > delta_max and goal.state == 'inprogress':
+ # generate a remind report
+ temp_obj = self.pool.get('email.template')
+ template_id = self.pool['ir.model.data'].get_object(cr, uid, 'gamification', 'email_template_goal_reminder', context)
+ body_html = temp_obj.render_template(cr, uid, template_id.body_html, 'gamification.goal', goal.id, context=context)
+
+ self.message_post(cr, uid, goal.id, body=body_html, partner_ids=[goal.user_id.partner_id.id], context=context, subtype='mail.mt_comment')
+ return {'state': 'inprogress_update'}
+ return {}
+
+ def update(self, cr, uid, ids, context=None):
+ """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."""
+ if context is None:
+ context = {}
+
+ for goal in self.browse(cr, uid, ids, context=context):
+ towrite = {}
+ if goal.state in ('draft', 'canceled'):
+ # skip if goal draft or canceled
+ continue
+
+ if goal.definition_id.computation_mode == 'manually':
+ towrite.update(self._check_remind_delay(cr, uid, goal, context))
+
+ elif goal.definition_id.computation_mode == 'python':
+ # execute the chosen method
+ cxt = {
+ 'self': self.pool.get('gamification.goal'),
+ 'object': goal,
+ 'pool': self.pool,
+ 'cr': cr,
+ 'context': dict(context), # copy context to prevent side-effects of eval
+ 'uid': uid,
+ 'result': False,
+ 'date': date, 'datetime': datetime, 'timedelta': timedelta, 'time': time
+ }
+ code = goal.definition_id.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', False)
+ if result and type(result) in (float, int, long):
+ if result != goal.current:
+ towrite['current'] = result
+ else:
+ _logger.exception(_('Invalid return content from the evaluation of %s' % code))
+
+ else: # count or sum
+ obj = self.pool.get(goal.definition_id.model_id.model)
+ field_date_name = goal.definition_id.field_date_id.name
+
+ # eval the domain with user replaced by goal user object
+ domain = safe_eval(goal.definition_id.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 goal.definition_id.computation_mode == 'sum':
+ field_name = goal.definition_id.field_id.name
+ res = obj.read_group(cr, uid, domain, [field_name], [''], context=context)
+ new_value = res and res[0][field_name] or 0.0
+
+ else: # computation mode = count
+ new_value = obj.search(cr, uid, domain, context=context, count=True)
+
+ # avoid useless write if the new value is the same as the old one
+ if new_value != goal.current:
+ towrite['current'] = new_value
+
+ # check goal target reached
+ if (goal.definition_condition == 'higher' and towrite.get('current', goal.current) >= goal.target_goal) or (goal.definition_condition == 'lower' and towrite.get('current', goal.current) <= goal.target_goal):
+ towrite['state'] = 'reached'
+
+ # check goal failure
+ elif goal.end_date and fields.date.today() > goal.end_date:
+ towrite['state'] = 'failed'
+ if towrite:
+ self.write(cr, uid, [goal.id], towrite, context=context)
+ return True
+
+ def action_start(self, cr, uid, ids, context=None):
+ """Mark a goal as started.
+
+ This should only be used when creating goals manually (in draft state)"""
+ self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
+ return self.update(cr, uid, ids, context=context)
+
+ def action_reach(self, cr, uid, ids, context=None):
+ """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(cr, uid, ids, {'state': 'reached'}, context=context)
+
+ def action_fail(self, cr, uid, ids, context=None):
+ """Set the state of the goal to failed.
+
+ A failed goal will be ignored in future checks."""
+ return self.write(cr, uid, ids, {'state': 'failed'}, context=context)
+
+ def action_cancel(self, cr, uid, ids, context=None):
+ """Reset the completion after setting a goal as reached or failed.
+
+ This is only the current state, if the date and/or target criterias
+ match the conditions for a change of state, this will be applied at the
+ next goal update."""
+ return self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
+
+ def create(self, cr, uid, vals, context=None):
+ """Overwrite the create method to add a 'no_remind_goal' field to True"""
+ if context is None:
+ context = {}
+ context['no_remind_goal'] = True
+ return super(gamification_goal, self).create(cr, uid, vals, context=context)
+
+ def write(self, cr, uid, ids, vals, context=None):
+ """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
+ """
+ if context is None:
+ context = {}
+ vals['last_update'] = fields.date.today()
+ result = super(gamification_goal, self).write(cr, uid, ids, vals, context=context)
+ for goal in self.browse(cr, uid, ids, context=context):
+ if goal.state != "draft" and ('definition_id' in vals or 'user_id' in vals):
+ # avoid drag&drop in kanban view
+ raise osv.except_osv(_('Error!'), _('Can not modify the configuration of a started goal'))
+
+ if vals.get('current'):
+ if 'no_remind_goal' in context:
+ # new goals should not be reported
+ continue
+
+ if goal.challenge_id and goal.challenge_id.report_message_frequency == 'onchange':
+ self.pool.get('gamification.challenge').report_progress(cr, SUPERUSER_ID, goal.challenge_id, users=[goal.user_id], context=context)
+ return result
+
+ def get_action(self, cr, uid, goal_id, context=None):
+ """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 dictionnary
+ """
+ goal = self.browse(cr, uid, goal_id, context=context)
+
+ if goal.definition_id.action_id:
+ # open a the action linked to the goal
+ action = goal.definition_id.action_id.read()[0]
+
+ if goal.definition_id.res_id_field:
+ current_user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
+ action['res_id'] = safe_eval(goal.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 goal.computation_mode == 'manually':
+ # open a wizard window to update the value manually
+ action = {
+ 'name': _("Update %s") % goal.definition_id.name,
+ 'id': goal_id,
+ 'type': 'ir.actions.act_window',
+ 'views': [[False, 'form']],
+ 'target': 'new',
+ 'context': {'default_goal_id': goal_id, 'default_current': goal.current},
+ 'res_model': 'gamification.goal.wizard'
+ }
+ return action
+
+ return False
diff --git a/addons/gamification/models/res_users.py b/addons/gamification/models/res_users.py
new file mode 100644
index 00000000000..408ddddf082
--- /dev/null
+++ b/addons/gamification/models/res_users.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2013 OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+from openerp.osv import osv
+from challenge import MAX_VISIBILITY_RANKING
+
+class res_users_gamification_group(osv.Model):
+ """ Update of res.users class
+ - if adding groups to an user, check gamification.challenge linked to
+ this group, and the user. This is done by overriding the write method.
+ """
+ _name = 'res.users'
+ _inherit = ['res.users']
+
+ def write(self, cr, uid, ids, vals, context=None):
+ """Overwrite to autosubscribe users if added to a group marked as autojoin, user will be added to challenge"""
+ write_res = super(res_users_gamification_group, self).write(cr, uid, ids, vals, context=context)
+ if vals.get('groups_id'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_group_ids = [command[1] for command in vals['groups_id'] if command[0] == 4]
+ user_group_ids += [id for command in vals['groups_id'] if command[0] == 6 for id in command[2]]
+
+ challenge_obj = self.pool.get('gamification.challenge')
+ challenge_ids = challenge_obj.search(cr, uid, [('autojoin_group_id', 'in', user_group_ids)], context=context)
+ if challenge_ids:
+ challenge_obj.write(cr, uid, challenge_ids, {'user_ids': [(4, user_id) for user_id in ids]}, context=context)
+ return write_res
+
+ def create(self, cr, uid, vals, context=None):
+ """Overwrite to autosubscribe users if added to a group marked as autojoin, user will be added to challenge"""
+ write_res = super(res_users_gamification_group, self).create(cr, uid, vals, context=context)
+ if vals.get('groups_id'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_group_ids = [command[1] for command in vals['groups_id'] if command[0] == 4]
+ user_group_ids += [id for command in vals['groups_id'] if command[0] == 6 for id in command[2]]
+
+ challenge_obj = self.pool.get('gamification.challenge')
+ challenge_ids = challenge_obj.search(cr, uid, [('autojoin_group_id', 'in', user_group_ids)], context=context)
+ if challenge_ids:
+ challenge_obj.write(cr, uid, challenge_ids, {'user_ids': [(4, write_res)]}, context=context)
+ return write_res
+
+ # def get_goals_todo_info(self, cr, uid, context=None):
+
+ def get_serialised_gamification_summary(self, cr, uid, context=None):
+ return self._serialised_goals_summary(cr, uid, user_id=uid, context=context)
+
+ def _serialised_goals_summary(self, cr, uid, user_id, context=None):
+ """Return a serialised list of goals assigned to the user, grouped by challenge
+
+ [
+ {
+ 'id': <gamification.challenge id>,
+ 'name': <gamification.challenge name>,
+ 'visibility_mode': <visibility {ranking,personal}>,
+ 'currency': <res.currency id>,
+ 'lines': [(see gamification_challenge._get_serialized_challenge_lines() format)]
+ },
+ ]
+ """
+ all_goals_info = []
+ challenge_obj = self.pool.get('gamification.challenge')
+
+ user = self.browse(cr, uid, uid, context=context)
+ challenge_ids = challenge_obj.search(cr, uid, [('user_ids', 'in', uid), ('state', '=', 'inprogress')], context=context)
+ for challenge in challenge_obj.browse(cr, uid, challenge_ids, context=context):
+ # serialize goals info to be able to use it in javascript
+ all_goals_info.append({
+ 'id': challenge.id,
+ 'name': challenge.name,
+ 'visibility_mode': challenge.visibility_mode,
+ 'currency': user.company_id.currency_id.id,
+ 'lines': challenge_obj._get_serialized_challenge_lines(cr, uid, challenge, user_id, restrict_top=MAX_VISIBILITY_RANKING, context=context),
+ })
+
+ return all_goals_info
+
+ def get_challenge_suggestions(self, cr, uid, context=None):
+ """Return the list of challenges suggested to the user"""
+ challenge_info = []
+ challenge_obj = self.pool.get('gamification.challenge')
+ challenge_ids = challenge_obj.search(cr, uid, [('invited_user_ids', 'in', uid), ('state', '=', 'inprogress')], context=context)
+ for challenge in challenge_obj.browse(cr, uid, challenge_ids, context=context):
+ values = {
+ 'id': challenge.id,
+ 'name': challenge.name,
+ 'description': challenge.description,
+ }
+ challenge_info.append(values)
+ return challenge_info
+
+
+class res_groups_gamification_group(osv.Model):
+ """ Update of res.groups class
+ - if adding users from a group, check gamification.challenge linked to
+ this group, and the user. This is done by overriding the write method.
+ """
+ _name = 'res.groups'
+ _inherit = 'res.groups'
+
+ # No need to overwrite create as very unlikely to be the value in the autojoin_group_id field
+ def write(self, cr, uid, ids, vals, context=None):
+ """Overwrite to autosubscribe users if add users to a group marked as autojoin, these will be added to the challenge"""
+ write_res = super(res_groups_gamification_group, self).write(cr, uid, ids, vals, context=context)
+ if vals.get('users'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_ids = [command[1] for command in vals['users'] if command[0] == 4]
+ user_ids += [id for command in vals['users'] if command[0] == 6 for id in command[2]]
+
+ challenge_obj = self.pool.get('gamification.challenge')
+ challenge_ids = challenge_obj.search(cr, uid, [('autojoin_group_id', 'in', ids)], context=context)
+ if challenge_ids:
+ challenge_obj.write(cr, uid, challenge_ids, {'user_ids': [(4, user_id) for user_id in user_ids]}, context=context)
+ return write_res