[MERGE] Merge with dev-addons3 branch
[odoo/odoo.git] / addons / scrum / scrum.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21 import time
22 from mx import DateTime
23
24 import netsvc
25 from osv import fields, osv, orm
26 import re
27 import tools
28 from tools.translate import _
29
30 class scrum_project(osv.osv):
31     _inherit = 'project.project'
32     _columns = {
33         'product_owner_id': fields.many2one('res.users', 'Product Owner', help="The person who is responsible for the product"),
34         'sprint_size': fields.integer('Sprint Days', help="Number of days allocated for sprint"),
35         'scrum': fields.integer('Is a Scrum Project'),
36     }
37     _defaults = {
38         'product_owner_id': lambda self,cr,uid,context={}: uid,
39         'sprint_size': 15,
40         'scrum': 1
41     }
42 scrum_project()
43
44 class scrum_sprint(osv.osv):
45     _name = 'scrum.sprint'
46     _description = 'Scrum Sprint'
47
48     def _calc_progress(self, cr, uid, ids, name, args, context):
49         res = {}
50         for sprint in self.browse(cr, uid, ids):
51             tot = 0.0
52             prog = 0.0
53             for bl in sprint.backlog_ids:
54                 tot += bl.expected_hours
55                 prog += bl.expected_hours * bl.progress / 100.0
56             res.setdefault(sprint.id, 0.0)
57             if tot>0:
58                 res[sprint.id] = round(prog/tot*100)
59         return res
60
61     def _calc_effective(self, cr, uid, ids, name, args, context):
62         res = {}
63         for sprint in self.browse(cr, uid, ids):
64             res.setdefault(sprint.id, 0.0)
65             for bl in sprint.backlog_ids:
66                 res[sprint.id] += bl.effective_hours
67         return res
68
69     def _calc_planned(self, cr, uid, ids, name, args, context):
70         res = {}
71         for sprint in self.browse(cr, uid, ids):
72             res.setdefault(sprint.id, 0.0)
73             for bl in sprint.backlog_ids:
74                 res[sprint.id] += bl.expected_hours
75         return res
76
77     def _calc_expected(self, cr, uid, ids, name, args, context):
78         res = {}
79         for sprint in self.browse(cr, uid, ids):
80             res.setdefault(sprint.id, 0.0)
81             for bl in sprint.backlog_ids:
82                 res[sprint.id] += bl.expected_hours
83         return res
84
85     def button_cancel(self, cr, uid, ids, context={}):
86         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
87         return True
88
89     def button_draft(self, cr, uid, ids, context={}):
90         self.write(cr, uid, ids, {'state':'draft'}, context=context)
91         return True
92
93     def button_open(self, cr, uid, ids, context={}):
94         self.write(cr, uid, ids, {'state':'open'}, context=context)
95         return True
96
97     def button_close(self, cr, uid, ids, context={}):
98         self.write(cr, uid, ids, {'state':'done'}, context=context)
99         return True
100
101     def button_pending(self, cr, uid, ids, context={}):
102         self.write(cr, uid, ids, {'state':'pending'}, context=context)
103         return True
104
105     _columns = {
106         'name' : fields.char('Sprint Name', required=True, size=64),
107         'date_start': fields.date('Starting Date', required=True),
108         'date_stop': fields.date('Ending Date', required=True),
109         'project_id': fields.many2one('project.project', 'Project', required=True, domain=[('scrum','=',1)], help="If you have [?] in the project name, it means there are no analytic account linked to this project."),
110         'product_owner_id': fields.many2one('res.users', 'Product Owner', required=True,help="The person who is responsible for the product"),
111         'scrum_master_id': fields.many2one('res.users', 'Scrum Manager', required=True,help="The person who is maintains the processes for the product"),
112         'meeting_ids': fields.one2many('scrum.meeting', 'sprint_id', 'Daily Scrum'),
113         'review': fields.text('Sprint Review'),
114         'retrospective': fields.text('Sprint Retrospective'),
115         'backlog_ids': fields.one2many('scrum.product.backlog', 'sprint_id', 'Sprint Backlog'),
116         'progress': fields.function(_calc_progress, method=True, string='Progress (0-100)', help="Computed as: Time Spent / Total Time."),
117         'effective_hours': fields.function(_calc_effective, method=True, string='Effective hours', help="Computed using the sum of the task work done."),
118         'expected_hours': fields.function(_calc_expected, method=True, string='Planned Hours', help='Estimated time to do the task.'),
119         'state': fields.selection([('draft','Draft'),('open','Open'),('pending','Pending'),('cancel','Cancelled'),('done','Done')], 'State', required=True),
120     }
121     _defaults = {
122         'state': 'draft',
123         'date_start' : time.strftime('%Y-%m-%d'),
124     }
125
126     def copy(self, cr, uid, id, default=None, context=None):
127         """Overrides orm copy method
128         @param self: The object pointer
129         @param cr: the current row, from the database cursor,
130         @param uid: the current user’s ID for security checks,
131         @param ids: List of case’s IDs
132         @param context: A standard dictionary for contextual values
133         """
134         if context is None:
135             context = {}
136         if default is None:
137             default = {}
138         default.update({'backlog_ids': [], 'meeting_ids': []})
139         return super(scrum_sprint, self).copy(cr, uid, id, default=default, context=context)
140
141     def onchange_project_id(self, cr, uid, ids, project_id):
142         v = {}
143         if project_id:
144             proj = self.pool.get('project.project').browse(cr, uid, [project_id])[0]
145             v['product_owner_id']= proj.product_owner_id and proj.product_owner_id.id or False
146             v['scrum_master_id']= proj.user_id and proj.user_id.id or False
147             v['date_stop'] = (DateTime.now() + DateTime.RelativeDateTime(days=int(proj.sprint_size or 14))).strftime('%Y-%m-%d')
148         return {'value':v}
149
150 scrum_sprint()
151
152 class scrum_product_backlog(osv.osv):
153     _name = 'scrum.product.backlog'
154     _description = 'Product Backlog'
155
156     def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
157         if not args:
158             args=[]
159         if not context:
160             context={}
161         match = re.match('^S\(([0-9]+)\)$', name)
162         if match:
163             ids = self.search(cr, uid, [('sprint_id','=', int(match.group(1)))], limit=limit, context=context)
164             return self.name_get(cr, uid, ids, context=context)
165         return super(scrum_product_backlog, self).name_search(cr, uid, name, args, operator,context, limit=limit)
166
167     def _calc_progress(self, cr, uid, ids, name, args, context):
168         res = {}
169         for bl in self.browse(cr, uid, ids):
170             tot = 0.0
171             prog = 0.0
172             for task in bl.tasks_id:
173                 tot += task.planned_hours
174                 prog += task.planned_hours * task.progress / 100.0
175             res.setdefault(bl.id, 0.0)
176             if tot>0:
177                 res[bl.id] = round(prog/tot*100)
178         return res
179
180     def _calc_effective(self, cr, uid, ids, name, args, context):
181         res = {}
182         for bl in self.browse(cr, uid, ids):
183             res.setdefault(bl.id, 0.0)
184             for task in bl.tasks_id:
185                 res[bl.id] += task.effective_hours
186         return res
187
188     def _calc_task(self, cr, uid, ids, name, args, context):
189         res = {}
190         for bl in self.browse(cr, uid, ids):
191             res.setdefault(bl.id, 0.0)
192             for task in bl.tasks_id:
193                 res[bl.id] += task.total_hours
194         return res
195
196     def button_cancel(self, cr, uid, ids, context={}):
197         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
198         for backlog in self.browse(cr, uid, ids, context=context):
199             self.pool.get('project.task').write(cr, uid, [i.id for i in backlog.tasks_id], {'state': 'cancelled'})
200         return True
201
202     def button_draft(self, cr, uid, ids, context={}):
203         self.write(cr, uid, ids, {'state':'draft'}, context=context)
204         return True
205
206     def button_open(self, cr, uid, ids, context={}):
207         self.write(cr, uid, ids, {'state':'open'}, context=context)
208         return True
209
210     def button_close(self, cr, uid, ids, context={}):
211         self.write(cr, uid, ids, {'state':'done'}, context=context)
212         for backlog in self.browse(cr, uid, ids, context=context):
213             self.pool.get('project.task').write(cr, uid, [i.id for i in backlog.tasks_id], {'state': 'done'})
214         return True
215
216     def button_pending(self, cr, uid, ids, context={}):
217         self.write(cr, uid, ids, {'state':'pending'}, context=context)
218         return True
219
220     def button_postpone(self, cr, uid, ids, context=None):
221         for product in self.browse(cr, uid, ids, context=context):
222             tasks_id = []
223             for task in product.tasks_id:
224                 if task.state != 'done':
225                     tasks_id.append(task.id)
226
227             clone_id = self.copy(cr, uid, product.id, {
228                 'name': 'PARTIAL:'+ product.name ,
229                 'sprint_id':False,
230                 'tasks_id':[(6, 0, tasks_id)],
231                                 })
232         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
233         return True
234
235     _columns = {
236         'name' : fields.char('Feature', size=64, required=True),
237         'note' : fields.text('Note'),
238         'active' : fields.boolean('Active', help="If Active field is set to true, it will allow you to hide the product backlog without removing it."),
239         'project_id': fields.many2one('project.project', 'Project', required=True, domain=[('scrum','=',1)], help="If you have [?] in the project name, it means there are no analytic account linked to this project."),
240         'user_id': fields.many2one('res.users', 'Responsible'),
241         'sprint_id': fields.many2one('scrum.sprint', 'Sprint'),
242         'sequence' : fields.integer('Sequence', help="Gives the sequence order when displaying a list of product backlog."),
243         'tasks_id': fields.one2many('project.task', 'product_backlog_id', 'Tasks Details'),
244         'state': fields.selection([('draft','Draft'),('open','Open'),('pending','Pending'),('done','Done'),('cancel','Cancelled')], 'State', required=True),
245         'progress': fields.function(_calc_progress, method=True, string='Progress', help="Computed as: Time Spent / Total Time."),
246         'effective_hours': fields.function(_calc_effective, method=True, string='Effective hours', help="Computed using the sum of the task work done (Time spent on tasks)"),
247         'expected_hours': fields.float('Planned Hours', help='Estimated total time to do the Backlog'),
248         'create_date': fields.datetime("Creation Date", readonly=True),
249         'task_hours': fields.function(_calc_task, method=True, string='Task Hours', help='Estimated time of the total hours of the tasks')
250     }
251     _defaults = {
252         'state': 'draft',
253         'active':  1,
254         'user_id': lambda self, cr, uid, context: uid,
255     }
256     _order = "sequence"
257 scrum_product_backlog()
258
259 class scrum_task(osv.osv):
260     _name = 'project.task'
261     _inherit = 'project.task'
262
263     def _get_task(self, cr, uid, ids, context={}):
264         result = {}
265         for line in self.pool.get('scrum.product.backlog').browse(cr, uid, ids, context=context):
266             for task in line.tasks_id:
267                 result[task.id] = True
268         return result.keys()
269     _columns = {
270         'product_backlog_id': fields.many2one('scrum.product.backlog', 'Product Backlog',help="Related product backlog that contains this task. Used in SCRUM methodology"),
271         'sprint_id': fields.related('product_backlog_id','sprint_id', type='many2one', relation='scrum.sprint', string='Sprint',
272             store={
273                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['product_backlog_id'], 10),
274                 'scrum.product.backlog': (_get_task, ['sprint_id'], 10)
275             }),
276     }
277
278     def onchange_backlog_id(self, cr, uid, backlog_id):
279         if not backlog_id:
280             return {}
281         project_id = self.pool.get('scrum.product.backlog').browse(cr, uid, backlog_id).project_id.id
282         return {'value': {'project_id': project_id}}
283 scrum_task()
284
285 class scrum_meeting(osv.osv):
286     _name = 'scrum.meeting'
287     _description = 'Scrum Meeting'
288     _order = 'date desc'
289     _columns = {
290         'name' : fields.char('Meeting Name', size=64),
291         'date': fields.date('Meeting Date', required=True),
292         'sprint_id': fields.many2one('scrum.sprint', 'Sprint', required=True),
293         'project_id': fields.many2one('project.project', 'Project'),
294         'question_yesterday': fields.text('Tasks since yesterday'),
295         'question_today': fields.text('Tasks for today'),
296         'question_blocks': fields.text('Blocks encountered'),
297         'question_backlog': fields.text('Backlog Accurate'),
298         'task_ids': fields.many2many('project.task', 'meeting_task_rel', 'metting_id', 'task_id', 'Tasks')
299     }
300     #
301     # TODO: Find the right sprint thanks to users and date
302     #
303     _defaults = {
304         'date' : time.strftime('%Y-%m-%d'),
305     }
306
307     def button_send_to_master(self, cr, uid, ids, context=None):
308         meeting_id = self.browse(cr, uid, ids)[0]
309         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
310         if meeting_id and meeting_id.sprint_id.scrum_master_id.user_email:
311             self.email_send(cr, uid, ids, email)
312         else:
313             raise osv.except_osv(_('Error !'), _('Please provide email address for scrum master defined on sprint.'))
314         return True
315
316     def button_send_product_owner(self, cr, uid, ids, context=None):
317         meeting_id = self.browse(cr, uid, ids)[0]
318         if meeting_id.sprint_id.product_owner_id.user_email:
319             self.email_send(cr,uid,ids,email)
320         else:
321             raise osv.except_osv(_('Error !'), _('Please provide email address for product owner defined on sprint.'))
322         return True
323
324     def email_send(self, cr, uid, ids, email, context=None):
325         email_from = tools.config.get('email_from', False)
326         meeting_id = self.browse(cr,uid,ids)[0]
327         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
328         user_email = email_from or user.address_id.email  or email_from
329         body = "Hello " + meeting_id.sprint_id.scrum_master_id.name+",\n" +" \nI am sending you Daily Meeting Details of date %s for the Sprint  %s \n" % (meeting_id.date, meeting_id.sprint_id.name)
330         body += '\n*Tasks since yesterday: \n_______________________%s' % (meeting_id.question_yesterday) + '\n*Task for Today :\n_______________________ %s\n' % (meeting_id.question_today )+ '\n*Blocks encountered: \n_______________________ %s' % (meeting_id.question_blocks or 'No Blocks')
331         body += "\n\nThank you,\n"+ user.name
332         sub_name = meeting_id.name or 'Scrum Meeting of %s '%meeting_id.date
333         flag = tools.email_send(user_email , [email], sub_name, body, reply_to=None, openobject_id=str(meeting_id.id))
334         if not flag:
335             raise osv.except_osv(_('Error !'), _(' Email Not send to %s!' % meeting_id.sprint_id.product_owner_id.name))
336         return True
337
338 scrum_meeting()
339