[IMP]:scrum,mrp:added report header title.
[odoo/odoo.git] / addons / project_scrum / project_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 from osv import fields, osv
22 from tools.translate import _
23 import re
24 import time
25 import tools
26 from datetime import datetime
27 from dateutil.relativedelta import relativedelta
28
29 class project_scrum_project(osv.osv):
30     _inherit = 'project.project'
31     _columns = {
32         'product_owner_id': fields.many2one('res.users', 'Product Owner', help="The person who is responsible for the product"),
33         'sprint_size': fields.integer('Sprint Days', help="Number of days allocated for sprint"),
34         'scrum': fields.integer('Is a Scrum Project'),
35     }
36     _defaults = {
37         'product_owner_id': lambda self, cr, uid, context={}: uid,
38         'sprint_size': 15,
39         'scrum': 1
40     }
41 project_scrum_project()
42
43 class project_scrum_sprint(osv.osv):
44     _name = 'project.scrum.sprint'
45     _description = 'Project Scrum Sprint'
46     _order = 'date_start'
47     def _compute(self, cr, uid, ids, fields, arg, context=None):
48         res = {}.fromkeys(ids, 0.0)
49         progress = {}
50         if not ids:
51             return res
52         if context is None:
53             context = {}
54         for sprint in self.browse(cr, uid, ids, context=context):
55             tot = 0.0
56             prog = 0.0
57             effective = 0.0
58             progress = 0.0
59             for bl in sprint.backlog_ids:
60                 tot += bl.expected_hours
61                 effective += bl.effective_hours
62                 prog += bl.expected_hours * bl.progress / 100.0
63             if tot>0:
64                 progress = round(prog/tot*100)
65             res[sprint.id] = {
66                 'progress' : progress,
67                 'expected_hours' : tot,
68                 'effective_hours': effective,
69             }
70         return res
71
72     def button_cancel(self, cr, uid, ids, context=None):
73         if context is None:
74             context = {}
75         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
76         return True
77
78     def button_draft(self, cr, uid, ids, context=None):
79         if context is None:
80             context = {}
81         self.write(cr, uid, ids, {'state':'draft'}, context=context)
82         return True
83
84     def button_open(self, cr, uid, ids, context=None):
85         if context is None:
86             context = {}
87         self.write(cr, uid, ids, {'state':'open'}, context=context)
88         for (id, name) in self.name_get(cr, uid, ids):
89             message = _("The sprint '%s' has been opened.") % (name,)
90             self.log(cr, uid, id, message)
91         return True
92
93     def button_close(self, cr, uid, ids, context=None):
94         if context is None:
95             context = {}
96         self.write(cr, uid, ids, {'state':'done'}, context=context)
97         for (id, name) in self.name_get(cr, uid, ids):
98             message = _("The sprint '%s' has been closed.") % (name,)
99             self.log(cr, uid, id, message)
100         return True
101
102     def button_pending(self, cr, uid, ids, context=None):
103         if context is None:
104             context = {}
105         self.write(cr, uid, ids, {'state':'pending'}, context=context)
106         return True
107
108     _columns = {
109         'name' : fields.char('Sprint Name', required=True, size=64),
110         'date_start': fields.date('Starting Date', required=True),
111         'date_stop': fields.date('Ending Date', required=True),
112         '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."),
113         'product_owner_id': fields.many2one('res.users', 'Product Owner', required=True,help="The person who is responsible for the product"),
114         'scrum_master_id': fields.many2one('res.users', 'Scrum Master', required=True,help="The person who is maintains the processes for the product"),
115         'meeting_ids': fields.one2many('project.scrum.meeting', 'sprint_id', 'Daily Scrum'),
116         'review': fields.text('Sprint Review'),
117         'retrospective': fields.text('Sprint Retrospective'),
118         'backlog_ids': fields.one2many('project.scrum.product.backlog', 'sprint_id', 'Sprint Backlog'),
119         'progress': fields.function(_compute, group_operator="avg", type='float', multi="progress", method=True, string='Progress (0-100)', help="Computed as: Time Spent / Total Time."),
120         'effective_hours': fields.function(_compute, multi="effective_hours", method=True, string='Effective hours', help="Computed using the sum of the task work done."),
121         'expected_hours': fields.function(_compute, multi="expected_hours", method=True, string='Planned Hours', help='Estimated time to do the task.'),
122         'state': fields.selection([('draft','Draft'),('open','Open'),('pending','Pending'),('cancel','Cancelled'),('done','Done')], 'State', required=True),
123     }
124     _defaults = {
125         'state': 'draft',
126         'date_start' : time.strftime('%Y-%m-%d'),
127     }
128
129     def copy(self, cr, uid, id, default=None, context=None):
130         """Overrides orm copy method
131         @param self: The object pointer
132         @param cr: the current row, from the database cursor,
133         @param uid: the current user’s ID for security checks,
134         @param ids: List of case’s IDs
135         @param context: A standard dictionary for contextual values
136         """
137         if context is None:
138             context = {}
139         if default is None:
140             default = {}
141         default.update({'backlog_ids': [], 'meeting_ids': []})
142         return super(project_scrum_sprint, self).copy(cr, uid, id, default=default, context=context)
143
144     def onchange_project_id(self, cr, uid, ids, project_id=False):
145         v = {}
146         if project_id:
147             proj = self.pool.get('project.project').browse(cr, uid, [project_id])[0]
148             v['product_owner_id']= proj.product_owner_id and proj.product_owner_id.id or False
149             v['scrum_master_id']= proj.user_id and proj.user_id.id or False
150             v['date_stop'] = (datetime.now() + relativedelta(days=int(proj.sprint_size or 14))).strftime('%Y-%m-%d')
151         return {'value':v}
152
153 project_scrum_sprint()
154
155 class project_scrum_product_backlog(osv.osv):
156     _name = 'project.scrum.product.backlog'
157     _description = 'Product Backlog'
158
159     def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
160         if not args:
161             args=[]
162         if not context:
163             context={}
164         if name:
165             match = re.match('^S\(([0-9]+)\)$', name)
166             if match:
167                 ids = self.search(cr, uid, [('sprint_id','=', int(match.group(1)))], limit=limit, context=context)
168                 return self.name_get(cr, uid, ids, context=context)
169         return super(project_scrum_product_backlog, self).name_search(cr, uid, name, args, operator,context, limit=limit)
170
171     def _compute(self, cr, uid, ids, fields, arg, context=None):
172         res = {}.fromkeys(ids, 0.0)
173         progress = {}
174         if not ids:
175             return res
176         if context is None:
177             context = {}
178         for backlog in self.browse(cr, uid, ids, context=context):
179             tot = 0.0
180             prog = 0.0
181             effective = 0.0
182             task_hours = 0.0
183             progress = 0.0
184             for task in backlog.tasks_id:
185                 task_hours += task.total_hours
186                 effective += task.effective_hours
187                 tot += task.planned_hours
188                 prog += task.planned_hours * task.progress / 100.0
189             if tot>0:
190                 progress = round(prog/tot*100)
191             res[backlog.id] = {
192                 'progress' : progress,
193                 'effective_hours': effective,
194                 'task_hours' : task_hours
195             }
196         return res
197
198     def button_cancel(self, cr, uid, ids, context=None):
199         obj_project_task = self.pool.get('project.task')
200         if context is None:
201             context = {}
202         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
203         for backlog in self.browse(cr, uid, ids, context=context):
204             obj_project_task.write(cr, uid, [i.id for i in backlog.tasks_id], {'state': 'cancelled'})
205         return True
206
207     def button_draft(self, cr, uid, ids, context=None):
208         if context is None:
209             context = {}
210         self.write(cr, uid, ids, {'state':'draft'}, context=context)
211         return True
212
213     def button_open(self, cr, uid, ids, context=None):
214         if context is None:
215             context = {}
216         self.write(cr, uid, ids, {'state':'open'}, context=context)
217         return True
218
219     def button_close(self, cr, uid, ids, context=None):
220         if context is None:
221             context = {}
222         obj_project_task = self.pool.get('project.task')
223         self.write(cr, uid, ids, {'state':'done'}, context=context)
224         for backlog in self.browse(cr, uid, ids, context=context):
225             obj_project_task.write(cr, uid, [i.id for i in backlog.tasks_id], {'state': 'done'})
226         return True
227
228     def button_pending(self, cr, uid, ids, context=None):
229         if context is None:
230             context = {}
231         self.write(cr, uid, ids, {'state':'pending'}, context=context)
232         return True
233
234     def button_postpone(self, cr, uid, ids, context=None):
235         if context is None:
236             context = {}
237         for product in self.browse(cr, uid, ids, context=context):
238             tasks_id = []
239             for task in product.tasks_id:
240                 if task.state != 'done':
241                     tasks_id.append(task.id)
242
243             clone_id = self.copy(cr, uid, product.id, {
244                 'name': 'PARTIAL:'+ product.name ,
245                 'sprint_id':False,
246                 'tasks_id':[(6, 0, tasks_id)],
247                                 })
248         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
249         return True
250
251     _columns = {
252         'name' : fields.char('Feature', size=64, required=True),
253         'note' : fields.text('Note'),
254         'active' : fields.boolean('Active', help="If Active field is set to true, it will allow you to hide the product backlog without removing it."),
255         'project_id': fields.many2one('project.project', 'Project', required=True, domain=[('scrum','=',1)]),
256         'user_id': fields.many2one('res.users', 'Author'),
257         'sprint_id': fields.many2one('project.scrum.sprint', 'Sprint'),
258         'sequence' : fields.integer('Sequence', help="Gives the sequence order when displaying a list of product backlog."),
259         'tasks_id': fields.one2many('project.task', 'product_backlog_id', 'Tasks Details'),
260         'state': fields.selection([('draft','Draft'),('open','Open'),('pending','Pending'),('done','Done'),('cancel','Cancelled')], 'State', required=True),
261         'progress': fields.function(_compute, multi="progress", group_operator="avg", type='float', method=True, string='Progress', help="Computed as: Time Spent / Total Time."),
262         'effective_hours': fields.function(_compute, multi="effective_hours", method=True, string='Spent Hours', help="Computed using the sum of the time spent on every related tasks", store=True),
263         'expected_hours': fields.float('Planned Hours', help='Estimated total time to do the Backlog'),
264         'create_date': fields.datetime("Creation Date", readonly=True),
265         'task_hours': fields.function(_compute, multi="task_hours", method=True, string='Task Hours', help='Estimated time of the total hours of the tasks')
266     }
267     _defaults = {
268         'state': 'draft',
269         'active':  1,
270         'user_id': lambda self, cr, uid, context: uid,
271     }
272     _order = "sequence"
273 project_scrum_product_backlog()
274
275 class project_scrum_task(osv.osv):
276     _name = 'project.task'
277     _inherit = 'project.task'
278
279     def _get_task(self, cr, uid, ids, context=None):
280         result = {}
281         if context is None:
282             context = {}
283         for line in self.pool.get('project.scrum.product.backlog').browse(cr, uid, ids, context=context):
284             for task in line.tasks_id:
285                 result[task.id] = True
286         return result.keys()
287     _columns = {
288         'product_backlog_id': fields.many2one('project.scrum.product.backlog', 'Product Backlog',help="Related product backlog that contains this task. Used in SCRUM methodology"),
289         'sprint_id': fields.related('product_backlog_id','sprint_id', type='many2one', relation='project.scrum.sprint', string='Sprint',
290             store={
291                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['product_backlog_id'], 10),
292                 'project.scrum.product.backlog': (_get_task, ['sprint_id'], 10)
293             }),
294     }
295
296     def onchange_backlog_id(self, cr, uid, backlog_id=False):
297         if not backlog_id:
298             return {}
299         project_id = self.pool.get('project.scrum.product.backlog').browse(cr, uid, backlog_id).project_id.id
300         return {'value': {'project_id': project_id}}
301 project_scrum_task()
302
303 class project_scrum_meeting(osv.osv):
304     _name = 'project.scrum.meeting'
305     _description = 'Scrum Meeting'
306     _order = 'date desc'
307     _columns = {
308         'name' : fields.char('Meeting Name', size=64),
309         'date': fields.date('Meeting Date', required=True),
310         'sprint_id': fields.many2one('project.scrum.sprint', 'Sprint', required=True),
311         'project_id': fields.many2one('project.project', 'Project'),
312         'question_yesterday': fields.text('Tasks since yesterday'),
313         'question_today': fields.text('Tasks for today'),
314         'question_blocks': fields.text('Blocks encountered'),
315         'question_backlog': fields.text('Backlog Accurate'),
316         'task_ids': fields.many2many('project.task', 'meeting_task_rel', 'metting_id', 'task_id', 'Tasks'),
317         'user_id': fields.related('sprint_id', 'scrum_master_id', type='many2one', relation='res.users', string='Scrum Master', readonly=True),
318     }
319     #
320     # TODO: Find the right sprint thanks to users and date
321     #
322     _defaults = {
323         'date' : time.strftime('%Y-%m-%d'),
324     }
325
326     def button_send_to_master(self, cr, uid, ids, context=None):
327         if context is None:
328             context = {}
329         meeting_id = self.browse(cr, uid, ids)[0]
330         if meeting_id and meeting_id.sprint_id.scrum_master_id.user_email:
331             res = self.email_send(cr, uid, ids, meeting_id.sprint_id.scrum_master_id.user_email)
332             if not res:
333                 raise osv.except_osv(_('Error !'), _(' Email Not send to the scrum master %s!' % meeting_id.sprint_id.scrum_master_id.name))
334         else:
335             raise osv.except_osv(_('Error !'), _('Please provide email address for scrum master defined on sprint.'))
336         return True
337
338     def button_send_product_owner(self, cr, uid, ids, context=None):
339         if context is None:
340             context = {}
341         context.update({'button_send_product_owner': True})
342         meeting_id = self.browse(cr, uid, ids)[0]
343         if meeting_id.sprint_id.product_owner_id.user_email:
344             res = self.email_send(cr,uid,ids,meeting_id.sprint_id.product_owner_id.user_email)
345             if not res:
346                 raise osv.except_osv(_('Error !'), _(' Email Not send to the product owner %s!' % meeting_id.sprint_id.product_owner_id.name))
347         else:
348             raise osv.except_osv(_('Error !'), _('Please provide email address for product owner defined on sprint.'))
349         return True
350
351     def email_send(self, cr, uid, ids, email, context=None):
352         if context is None:
353             context = {}
354         email_from = tools.config.get('email_from', False)
355         meeting_id = self.browse(cr,uid,ids)[0]
356         user = self.pool.get('res.users').browse(cr, uid, uid, context=context)
357         user_email = email_from or user.address_id.email  or email_from
358         body = _('Hello ') + meeting_id.sprint_id.scrum_master_id.name + ",\n" + " \n" +_('I am sending you Daily Meeting Details of date')+ ' %s ' % (meeting_id.date)+ _('for the Sprint')+ ' %s\n' % (meeting_id.sprint_id.name)
359         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'))
360         body += "\n\n"+_('Thank you')+",\n"+ user.name
361         sub_name = meeting_id.name or _('Scrum Meeting of')+ "%s" %meeting_id.date
362         flag = tools.email_send(user_email , [email], sub_name, body, reply_to=None, openobject_id=str(meeting_id.id))
363         if not flag:
364             return False
365         return True
366
367 project_scrum_meeting()
368