odoo/addons/gamification/plan.py

666 lines
29 KiB
Python

# -*- coding: utf-8 -*-
##############################################################################
#
# OpenERP, Open Source Management Solution
# Copyright (C) 2010-Today 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 fields, osv
from openerp.tools.translate import _
from templates import TemplateHelper
from datetime import date, datetime, timedelta
import calendar
import logging
_logger = logging.getLogger(__name__)
def start_end_date_for_period(period):
"""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 = False # for manual goal, start each time
end_date = False
if start_date and end_date:
return (start_date.isoformat(), end_date.isoformat())
else:
return (start_date, end_date)
class gamification_goal_plan(osv.Model):
"""Gamification goal plan
Set of predifined goals to be able to automate goal settings or
quickly apply several goals manually to a group of users
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.goal.plan'
_description = 'Gamification goal plan'
_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 isoformat representing the date"""
res = {}
for plan in self.browse(cr, uid, ids, context):
last = datetime.strptime(plan.last_report_date, '%Y-%m-%d').date()
if plan.report_message_frequency == 'daily':
next = last + timedelta(days=1)
res[plan.id] = next.isoformat()
elif plan.report_message_frequency == 'weekly':
next = last + timedelta(days=7)
res[plan.id] = next.isoformat()
elif plan.report_message_frequency == 'monthly':
month_range = calendar.monthrange(last.year, last.month)
next = last.replace(day=month_range[1]) + timedelta(days=1)
res[plan.id] = next.isoformat()
elif plan.report_message_frequency == 'yearly':
res[plan.id] = last.replace(year=last.year + 1).isoformat()
else: # frequency == 'once':
res[plan.id] = False
return res
def _planline_count(self, cr, uid, ids, field_name, arg, context=None):
res = dict.fromkeys(ids, 0)
for plan in self.browse(cr, uid, ids, context):
res[plan.id] = len(plan.planline_ids)
return res
_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),
'manager_id': fields.many2one('res.users',
string='Responsible', help="The user responsible for the challenge."),
'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."),
'user_ids': fields.many2many('res.users', 'user_ids',
string='Users',
help="List of users to which the goal will be set"),
'autojoin_group_id': fields.many2one('res.groups',
string='Auto-subscription Group',
help='Group of users whose members will automatically be added to the users'),
'proposed_user_ids': fields.many2many('res.users', 'proposed_user_ids',
string="Propose to users"),
'planline_ids': fields.one2many('gamification.goal.planline', 'plan_id',
string='Planline',
help="List of goals that will be set",
required=True),
'planline_count': fields.function(_planline_count, type='integer', string="Planlines"),
'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 Top 3 even if not succeed'),
'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),
'visibility_mode': fields.selection([
('progressbar', 'Individual Goals'),
('board', '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_header': fields.text('Report Header'),
'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'),
'category': fields.selection([
('hr', 'Human Ressources / Engagement'),
('other', 'Settings / Gamification Tools'),
],
string="Appears in", help="Define the visibility of the challenge through menus", required=True),
}
_defaults = {
'period': 'once',
'state': 'draft',
'visibility_mode' : 'progressbar',
'report_message_frequency' : 'onchange',
'last_report_date': fields.date.today,
'start_date': fields.date.today,
'manager_id': lambda s, cr, uid, c: uid,
'category': 'hr',
'reward_failure': False,
}
_sort = 'end_date start_date name'
def write(self, cr, uid, ids, vals, context=None):
"""Overwrite the write method to add the user of groups"""
context = context or {}
if not ids:
return True
write_res = super(gamification_goal_plan, self).write(cr, uid, ids, vals, context=context)
# add users when change the group auto-subscription
if 'autojoin_group_id' in vals:
new_group = self.pool.get('res.groups').browse(cr, uid, vals['autojoin_group_id'], context=context)
if new_group:
self.plan_subscribe_users(cr, uid, ids, [user.id for user in new_group.users], context=context)
if 'proposed_user_ids' in vals:
for plan in self.browse(cr, uid, ids, context=context):
puser_ids = [puser.id for puser in plan.proposed_user_ids]
if len([user for user in plan.user_ids if user.id in puser_ids]) > 0:
raise osv.except_osv(_('Error!'), _('Can not propose a challenge to an user already assigned to it'))
return write_res
##### Update #####
def _cron_update(self, cr, uid, context=None, ids=False):
"""Daily cron check.
Start planned plans (in draft and with start_date = today)
Create the goals for planlines not linked to goals (eg: modified the
plan to add planlines)
Update every plan running
"""
if not context: context = {}
# start planned plans
planned_plan_ids = self.search(cr, uid, [
('state', '=', 'draft'),
('start_date', '<=', fields.date.today())])
self.action_start(cr, uid, planned_plan_ids, context=context)
# close planned plans
planned_plan_ids = self.search(cr, uid, [
('state', '=', 'inprogress'),
('end_date', '>=', fields.date.today())])
self.action_close(cr, uid, planned_plan_ids, context=context)
if not ids:
ids = self.search(cr, uid, [('state', '=', 'inprogress')])
return self._update_all(cr, uid, ids, context=context)
def _update_all(self, cr, uid, ids, context=None):
"""Update the plans and related goals
:param list(int) ids: the ids of the plans to update, if False will
update only plans in progress."""
if not context: context = {}
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, [
('plan_id', 'in', ids),
'|',
('state', 'in', ('inprogress', 'inprogress_update')),
'&',
('state', 'in', ('reached', 'failed')),
'|',
('end_date', '>=', yesterday.isoformat()),
('end_date', '=', False)
], context=context)
# update every running goal already generated linked to selected plans
goal_obj.update(cr, uid, goal_ids, context=context)
for plan in self.browse(cr, uid, ids, context=context):
if plan.autojoin_group_id:
self.plan_subscribe_users(cr, uid, [plan.id], [user.id for user in plan.autojoin_group_id.users], context=context)
self.generate_goals_from_plan(cr, uid, [plan.id], context=context)
# goals closed but still opened at the last report date
closed_goals_to_report = goal_obj.search(cr, uid, [
('plan_id', '=', plan.id),
('start_date', '>=', plan.last_report_date),
('end_date', '<=', plan.last_report_date)
])
if len(closed_goals_to_report) > 0:
# some goals need a final report
self.report_progress(cr, uid, plan, subset_goal_ids=closed_goals_to_report, context=context)
self.write(cr, uid, plan.id, {'last_report_date': fields.date.today}, context=context)
if fields.date.today() == plan.next_report_date:
self.report_progress(cr, uid, plan, context=context)
self.write(cr, uid, plan.id, {'last_report_date': fields.date.today}, context=context)
return True
def quick_update(self, cr, uid, plan_id, context=None):
"""Update all the goals of a plan, no generation of new goals"""
if not context: context = {}
plan = self.browse(cr, uid, plan_id, context=context)
goal_ids = self.pool.get('gamification.goal').search(cr, uid, [('plan_id', '=', plan_id)], context=context)
self.pool.get('gamification.goal').update(cr, uid, goal_ids, context=context)
return True
##### User actions #####
def action_start(self, cr, uid, ids, context=None):
"""Start a draft goal plan
Change the state of the plan to in progress and generate related goals
"""
# subscribe users if autojoin group
for plan in self.browse(cr, uid, ids, context=context):
if plan.autojoin_group_id:
self.plan_subscribe_users(cr, uid, ids, [user.id for user in plan.autojoin_group_id.users], context=context)
if len(plan.user_ids) > 0:
self.write(cr, uid, plan.id, {'state': 'inprogress'}, context=context)
else:
_logger.warning("Can not start planned plan, no subscribed users")
return self.generate_goals_from_plan(cr, uid, ids, context=context)
def action_check(self, cr, uid, ids, context=None):
"""Check a goal plan
Create goals that haven't been created yet (eg: if added users of planlines)
Recompute the current value for each goal related"""
return self._update_all(cr, uid, ids=ids, context=context)
def action_close(self, cr, uid, ids, context=None):
"""Close a plan in progress
Change the state of the plan to in done
Does NOT close the related goals, this is handled by the goal itself"""
return self.write(cr, uid, ids, {'state': 'done'}, context=context)
def action_reset(self, cr, uid, ids, context=None):
"""Reset a closed goal plan
Change the state of the plan to in progress
Closing a plan does not affect the goals so neither does reset"""
return self.write(cr, uid, ids, {'state': 'inprogress'}, context=context)
def action_cancel(self, cr, uid, ids, context=None):
"""Cancel a plan in progress
Change the state of the plan to draft
Cancel the related goals"""
self.write(cr, uid, ids, {'state': 'draft'}, context=context)
goal_ids = self.pool.get('gamification.goal').search(cr, uid, [('plan_id', 'in', ids)], context=context)
self.pool.get('gamification.goal').write(cr, uid, goal_ids, {'state': 'canceled'}, context=context)
return True
def action_report_progress(self, cr, uid, ids, context=None):
"""Manual report of a goal, does not influence automatic report frequency"""
for plan in self.browse(cr, uid, ids, context):
self.report_progress(cr, uid, plan, context=context)
return True
##### Automatic actions #####
def generate_goals_from_plan(self, cr, uid, ids, context=None):
"""Generate the list of goals linked to a plan.
If goals already exist for this planline, the planline is skipped. This
can be called after each change in the user or planline list.
:param list(int) ids: the list of plan concerned"""
for plan in self.browse(cr, uid, ids, context):
(start_date, end_date) = start_end_date_for_period(plan.period)
# if no periodicity, use plan dates
if not start_date and plan.start_date:
start_date = plan.start_date
if not end_date and plan.end_date:
end_date = plan.end_date
for planline in plan.planline_ids:
for user in plan.user_ids:
goal_obj = self.pool.get('gamification.goal')
domain = [('planline_id', '=', planline.id), ('user_id', '=', user.id)]
if start_date:
domain.append(('start_date', '=', start_date))
# goal already existing for this planline ?
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 = {
'type_id': planline.type_id.id,
'planline_id': planline.id,
'user_id': user.id,
'target_goal': planline.target_goal,
'state': 'inprogress',
}
if start_date:
values['start_date'] = start_date
if end_date:
values['end_date'] = end_date
if planline.plan_id.remind_update_delay:
values['remind_update_delay'] = planline.plan_id.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
def plan_subscribe_users(self, cr, uid, ids, new_user_ids, context=None):
""" Add the following users to plans
:param ids: ids of plans to which the users will be added
:param new_user_ids: ids of the users to add"""
for plan in self.browse(cr, uid, ids, context):
subscription = [user.id for user in plan.user_ids]
subscription.extend(new_user_ids)
# remove duplicates
unified_subscription = list(set(subscription))
self.write(cr, uid, [plan.id], {'user_ids': [(4, user) for user in unified_subscription]}, context=context)
return True
##### JS utilities #####
def get_board_goal_info(self, cr, uid, plan, subset_goal_ids=False, context=None):
"""Get the list of latest goals for a plan, sorted by user ranking for each planline"""
goal_obj = self.pool.get('gamification.goal')
planlines_boards = []
(start_date, end_date) = start_end_date_for_period(plan.period)
for planline in plan.planline_ids:
domain = [
('planline_id', '=', planline.id),
('state', 'in', ('inprogress', 'inprogress_update',
'reached', 'failed')),
]
if subset_goal_ids:
goal_ids = goal_obj.search(cr, uid, domain, context=context)
common_goal_ids = [goal for goal in goal_ids if goal in subset_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))
common_goal_ids = goal_obj.search(cr, uid, domain, context=context)
board_goals = [goal for goal in goal_obj.browse(cr, uid, common_goal_ids, context=context)]
if len(board_goals) == 0:
# planline has no generated goals
continue
# most complete first, current if same percentage (eg: if several 100%)
sorted_board = enumerate(sorted(board_goals, key=lambda k: (k.completeness, k.current), reverse=True))
planlines_boards.append({'goal_type': planline.type_id, 'board_goals': sorted_board, 'target_goal': planline.target_goal})
return planlines_boards
def get_indivual_goal_info(self, cr, uid, user_id, plan, subset_goal_ids=False, context=None):
"""Get the list of latest goals of a user for a plan"""
domain = [
('plan_id', '=', plan.id),
('user_id', '=', user_id),
('state', 'in', ('inprogress', 'inprogress_update',
'reached', 'failed')),
]
goal_obj = self.pool.get('gamification.goal')
(start_date, end_date) = start_end_date_for_period(plan.period)
if subset_goal_ids:
# use the domain for safety, don't want irrelevant report if wrong argument
goal_ids = goal_obj.search(cr, uid, domain, context=context)
related_goal_ids = [goal for goal in goal_ids if goal in subset_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))
related_goal_ids = goal_obj.search(cr, uid, domain, context=context)
if len(related_goal_ids) == 0:
return False
values = {'goals': []}
all_done = True
for goal in goal_obj.browse(cr, uid, related_goal_ids, context=context):
if goal.end_date:
if goal.end_date < fields.date.today():
# do not include goals of previous plan run
continue
else:
all_done = False
else:
if goal.state == 'inprogress' or goal.state == 'inprogress_update':
all_done = False
values['goals'].append(goal)
if all_done:
# skip plans where all goal are done or failed
return False
else:
return values
##### Reporting #####
def report_progress(self, cr, uid, plan, context=None, users=False, subset_goal_ids=False):
"""Post report about the progress of the goals
:param plan: the plan 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 plan with
a visibility mode set to 'personal'.
:param goal_ids: the list(int) of goal ids linked to the plan for
the report. If not specified, use the goals for the current plan
period. This parameter can be used to produce report for previous plan
periods.
:param subset_goal_ids: a list(int) of goal ids to restrict the report
"""
context = context or {}
goal_obj = self.pool.get('gamification.goal')
template_env = TemplateHelper()
if plan.visibility_mode == 'board':
planlines_boards = self.get_board_goal_info(cr, uid, plan, subset_goal_ids, context)
body_html = template_env.get_template('group_progress.mako').render({'object': plan, 'planlines_boards': planlines_boards})
self.message_post(cr, uid, plan.id,
body=body_html,
partner_ids=[(6, 0, [user.partner_id.id for user in plan.user_ids])],
context=context,
subtype='mail.mt_comment')
if plan.report_message_group_id:
self.pool.get('mail.group').message_post(cr, uid, plan.report_message_group_id.id,
body=body_html,
context=context,
subtype='mail.mt_comment')
else:
# generate individual reports
for user in users or plan.user_ids:
values = self.get_indivual_goal_info(cr, uid, user.id, plan, subset_goal_ids, context=context)
if not values:
continue
values['object'] = plan
values['user'] = user,
body_html = template_env.get_template('personal_progress.mako').render(values)
self.message_post(cr, uid, plan.id,
body=body_html,
partner_ids=[(4, user.partner_id.id)],
context=context,
subtype='mail.mt_comment')
if plan.report_message_group_id:
self.pool.get('mail.group').message_post(cr, uid, plan.report_message_group_id.id,
body=body_html,
context=context,
subtype='mail.mt_comment')
return True
##### Suggestions #####
def accept_challenge(self, cr, uid, plan_id, user_id=None, context=None):
"""The user accept the suggested challenge"""
context = context or {}
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, plan_id, body=message, context=context)
self.write(cr, uid, [plan_id], {'proposed_user_ids': (3, user_id), 'user_id': (4, user_id)}, context=context)
return self.generate_goals_from_plan(cr, uid, [plan_id], context=context)
def discard_challenge(self, cr, uid, plan_id, user_id=None, context=None):
"""The user discard the suggested challenge"""
context = context or {}
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, uid, plan_id, body=message, context=context)
return self.write(cr, uid, [plan_id], {'proposed_user_ids': (3, user_id)}, context=context)
def get_suggestions_info(self, cr, uid, context=None):
pass
class gamification_goal_planline(osv.Model):
"""Gamification goal planline
Predifined goal for 'gamification_goal_plan'
These are generic list of goals with only the target goal defined
Should only be created for the gamification_goal_plan object
"""
_name = 'gamification.goal.planline'
_description = 'Gamification generic goal for plan'
_order = "sequence, sequence_type, id"
def _get_planline_types(self, cr, uid, ids, context=None):
"""Return the ids of planline items related to the gamification.goal.type
objects in 'ids (used to update the value of 'sequence_type')'"""
result = {}
for goal_type in self.pool.get('gamification.goal.type').browse(cr, uid, ids, context=context):
domain = [('type_id', '=', goal_type.id)]
planline_ids = self.pool.get('gamification.goal.planline').search(cr, uid, domain, context=context)
for p_id in planline_ids:
result[p_id] = True
return result.keys()
_columns = {
'name': fields.related('type_id', 'name', string="Name"),
'plan_id': fields.many2one('gamification.goal.plan',
string='Plan',
required=True,
ondelete="cascade"),
'type_id': fields.many2one('gamification.goal.type',
string='Goal Type',
required=True,
ondelete="cascade"),
'target_goal': fields.float('Target Value to Reach',
required=True),
'sequence': fields.integer('Sequence',
help='Sequence number for ordering'),
'sequence_type': fields.related('type_id', 'sequence',
type='integer',
string='Sequence',
readonly=True,
store={
'gamification.goal.type': (_get_planline_types, ['sequence'], 10),
}),
'type_condition': fields.related('type_id', 'condition', type="selection",
readonly=True, string="Condition", selection=[('lower', '<='), ('higher', '>=')]),
'type_unit': fields.related('type_id', 'unit', type="char", readonly=True, string="Unit"),
'type_monetary': fields.related('type_id', 'monetary', type="boolean", readonly=True, string="Monetary"),
'type_full_suffix': fields.related('type_id', 'full_suffix', type="char", readonly=True, string="Suffix"),
}
_default = {
'sequence': 1,
}