[IMP]improve code and add tooltip on task_management
[odoo/odoo.git] / addons / project / project.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
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
22 from base_status.base_stage import base_stage
23 from datetime import datetime, date
24 from lxml import etree
25 from osv import fields, osv
26 from openerp.addons.resource.faces import task as Task
27 import time
28 from tools.translate import _
29
30 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
31
32 class project_task_type(osv.osv):
33     _name = 'project.task.type'
34     _description = 'Task Stage'
35     _order = 'sequence'
36     _columns = {
37         'name': fields.char('Stage Name', required=True, size=64, translate=True),
38         'description': fields.text('Description'),
39         'sequence': fields.integer('Sequence'),
40         'case_default': fields.boolean('Common to All Projects',
41                         help="If you check this field, this stage will be proposed by default on each new project. It will not assign this stage to existing projects."),
42         'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
43         'state': fields.selection(_TASK_STATE, 'State', required=True,
44                         help="The related state for the stage. The state of your document will automatically change regarding the selected stage. Example, a stage is related to the state 'Close', when your document reach this stage, it will be automatically closed."),
45         'fold': fields.boolean('Hide in views if empty',
46                         help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
47     }
48     _defaults = {
49         'sequence': 1,
50         'state': 'draft',
51         'fold': False,
52     }
53     _order = 'sequence'
54
55 class project(osv.osv):
56     _name = "project.project"
57     _description = "Project"
58     _inherits = {'account.analytic.account': "analytic_account_id"}
59     _inherit = ['ir.needaction_mixin', 'mail.thread']
60
61     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
62         if user == 1:
63             return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
64         if context and context.get('user_preference'):
65                 cr.execute("""SELECT project.id FROM project_project project
66                            LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
67                            LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
68                            WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
69                 return [(r[0]) for r in cr.fetchall()]
70         return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
71             context=context, count=count)
72
73     def _complete_name(self, cr, uid, ids, name, args, context=None):
74         res = {}
75         for m in self.browse(cr, uid, ids, context=context):
76             res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
77         return res
78
79     def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
80         partner_obj = self.pool.get('res.partner')
81         if not part:
82             return {'value':{}}
83         val = {}
84         if 'pricelist_id' in self.fields_get(cr, uid, context=context):
85             pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
86             pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
87             val['pricelist_id'] = pricelist_id
88         return {'value': val}
89
90     def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
91         tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
92         project_ids = [task.project_id.id for task in tasks if task.project_id]
93         return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
94
95     def _get_project_and_parents(self, cr, uid, ids, context=None):
96         """ return the project ids and all their parent projects """
97         res = set(ids)
98         while ids:
99             cr.execute("""
100                 SELECT DISTINCT parent.id
101                 FROM project_project project, project_project parent, account_analytic_account account
102                 WHERE project.analytic_account_id = account.id
103                 AND parent.analytic_account_id = account.parent_id
104                 AND project.id IN %s
105                 """, (tuple(ids),))
106             ids = [t[0] for t in cr.fetchall()]
107             res.update(ids)
108         return list(res)
109
110     def _get_project_and_children(self, cr, uid, ids, context=None):
111         """ retrieve all children projects of project ids;
112             return a dictionary mapping each project to its parent project (or None)
113         """
114         res = dict.fromkeys(ids, None)
115         while ids:
116             cr.execute("""
117                 SELECT project.id, parent.id
118                 FROM project_project project, project_project parent, account_analytic_account account
119                 WHERE project.analytic_account_id = account.id
120                 AND parent.analytic_account_id = account.parent_id
121                 AND parent.id IN %s
122                 """, (tuple(ids),))
123             dic = dict(cr.fetchall())
124             res.update(dic)
125             ids = dic.keys()
126         return res
127
128     def _progress_rate(self, cr, uid, ids, names, arg, context=None):
129         child_parent = self._get_project_and_children(cr, uid, ids, context)
130         # compute planned_hours, total_hours, effective_hours specific to each project
131         cr.execute("""
132             SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
133                 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
134             FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
135             GROUP BY project_id
136             """, (tuple(child_parent.keys()),))
137         # aggregate results into res
138         res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
139         for id, planned, total, effective in cr.fetchall():
140             # add the values specific to id to all parent projects of id in the result
141             while id:
142                 if id in ids:
143                     res[id]['planned_hours'] += planned
144                     res[id]['total_hours'] += total
145                     res[id]['effective_hours'] += effective
146                 id = child_parent[id]
147         # compute progress rates
148         for id in ids:
149             if res[id]['total_hours']:
150                 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
151             else:
152                 res[id]['progress_rate'] = 0.0
153         return res
154
155     def unlink(self, cr, uid, ids, *args, **kwargs):
156         for proj in self.browse(cr, uid, ids):
157             if proj.tasks:
158                 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
159         return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
160
161     def _task_count(self, cr, uid, ids, field_name, arg, context=None):
162         res = dict.fromkeys(ids, 0)
163         task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
164         for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
165             res[task.project_id.id] += 1
166         return res
167
168     def _get_followers(self, cr, uid, ids, name, arg, context=None):
169         '''
170         Functional field that computes the users that are 'following' a thread.
171         '''
172         res = {}
173         for project in self.browse(cr, uid, ids, context=context):
174             l = set()
175             for message in project.message_ids:
176                 l.add(message.user_id and message.user_id.id or False)
177             res[project.id] = list(filter(None, l))
178         return res
179
180     def _search_followers(self, cr, uid, obj, name, args, context=None):
181         project_obj = self.pool.get('project.project')
182         project_ids = project_obj.search(cr, uid, [('message_ids.user_id.id', 'in', args[0][2])], context=context)
183         return [('id', 'in', project_ids)]
184
185     _columns = {
186         'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
187         'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
188         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
189         'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account', help="Link this project to an analytic account if you need financial management on projects. It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True),
190         'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
191         'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
192             help="Project's members are users who can have an access to the tasks related to this project.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
193         'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
194         'planned_hours': fields.function(_progress_rate, multi="progress", string='Planned Time', help="Sum of planned hours of all tasks related to this project and its child projects.",
195             store = {
196                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
197                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
198             }),
199         'effective_hours': fields.function(_progress_rate, multi="progress", string='Time Spent', help="Sum of spent hours of all tasks related to this project and its child projects.",
200             store = {
201                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
202                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
203             }),
204         'total_hours': fields.function(_progress_rate, multi="progress", string='Total Time', help="Sum of total hours of all tasks related to this project and its child projects.",
205             store = {
206                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
207                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
208             }),
209         'progress_rate': fields.function(_progress_rate, multi="progress", string='Progress', type='float', group_operator="avg", help="Percent of tasks closed according to the total of tasks todo.",
210             store = {
211                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
212                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
213             }),
214         'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
215         'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
216         'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
217         'color': fields.integer('Color Index'),
218         'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility'),
219         'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
220         'followers': fields.function(_get_followers, method=True, fnct_search=_search_followers,
221                         type='many2many', relation='res.users', string='Followers'),
222      }
223     
224     def dummy(self, cr, uid, ids, context):
225         return True
226
227     def _get_type_common(self, cr, uid, context):
228         ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
229         return ids
230
231     _order = "sequence"
232     _defaults = {
233         'active': True,
234         'type': 'contract',
235         'state': 'open',
236         'priority': 1,
237         'sequence': 10,
238         'type_ids': _get_type_common,
239     }
240
241     # TODO: Why not using a SQL contraints ?
242     def _check_dates(self, cr, uid, ids, context=None):
243         for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
244             if leave['date_start'] and leave['date']:
245                 if leave['date_start'] > leave['date']:
246                     return False
247         return True
248
249     _constraints = [
250         (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
251     ]
252
253     def set_template(self, cr, uid, ids, context=None):
254         res = self.setActive(cr, uid, ids, value=False, context=context)
255         return res
256
257     def set_done(self, cr, uid, ids, context=None):
258         task_obj = self.pool.get('project.task')
259         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
260         task_obj.case_close(cr, uid, task_ids, context=context)
261         self.write(cr, uid, ids, {'state':'close'}, context=context)
262         self.set_close_send_note(cr, uid, ids, context=context)
263         return True
264
265     def set_cancel(self, cr, uid, ids, context=None):
266         task_obj = self.pool.get('project.task')
267         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
268         task_obj.case_cancel(cr, uid, task_ids, context=context)
269         self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
270         self.set_cancel_send_note(cr, uid, ids, context=context)
271         return True
272
273     def set_pending(self, cr, uid, ids, context=None):
274         self.write(cr, uid, ids, {'state':'pending'}, context=context)
275         self.set_pending_send_note(cr, uid, ids, context=context)
276         return True
277
278     def set_open(self, cr, uid, ids, context=None):
279         self.write(cr, uid, ids, {'state':'open'}, context=context)
280         self.set_open_send_note(cr, uid, ids, context=context)
281         return True
282
283     def reset_project(self, cr, uid, ids, context=None):
284         res = self.setActive(cr, uid, ids, value=True, context=context)
285         self.set_open_send_note(cr, uid, ids, context=context)
286         return res
287
288     def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
289         """ copy and map tasks from old to new project """
290         if context is None:
291             context = {}
292         map_task_id = {}
293         task_obj = self.pool.get('project.task')
294         proj = self.browse(cr, uid, old_project_id, context=context)
295         for task in proj.tasks:
296             map_task_id[task.id] =  task_obj.copy(cr, uid, task.id, {}, context=context)
297         self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
298         task_obj.duplicate_task(cr, uid, map_task_id, context=context)
299         return True
300
301     def copy(self, cr, uid, id, default={}, context=None):
302         if context is None:
303             context = {}
304
305         default = default or {}
306         context['active_test'] = False
307         default['state'] = 'open'
308         default['tasks'] = []
309         proj = self.browse(cr, uid, id, context=context)
310         if not default.get('name', False):
311             default['name'] = proj.name + _(' (copy)')
312
313         res = super(project, self).copy(cr, uid, id, default, context)
314         self.map_tasks(cr,uid,id,res,context)
315         return res
316
317     def duplicate_template(self, cr, uid, ids, context=None):
318         if context is None:
319             context = {}
320         data_obj = self.pool.get('ir.model.data')
321         result = []
322         for proj in self.browse(cr, uid, ids, context=context):
323             parent_id = context.get('parent_id', False)
324             context.update({'analytic_project_copy': True})
325             new_date_start = time.strftime('%Y-%m-%d')
326             new_date_end = False
327             if proj.date_start and proj.date:
328                 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
329                 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
330                 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
331             context.update({'copy':True})
332             new_id = self.copy(cr, uid, proj.id, default = {
333                                     'name': proj.name +_(' (copy)'),
334                                     'state':'open',
335                                     'date_start':new_date_start,
336                                     'date':new_date_end,
337                                     'parent_id':parent_id}, context=context)
338             result.append(new_id)
339
340             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
341             parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
342             if child_ids:
343                 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
344
345         if result and len(result):
346             res_id = result[0]
347             form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
348             form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
349             tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
350             tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
351             search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
352             search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
353             return {
354                 'name': _('Projects'),
355                 'view_type': 'form',
356                 'view_mode': 'form,tree',
357                 'res_model': 'project.project',
358                 'view_id': False,
359                 'res_id': res_id,
360                 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
361                 'type': 'ir.actions.act_window',
362                 'search_view_id': search_view['res_id'],
363                 'nodestroy': True
364             }
365
366     # set active value for a project, its sub projects and its tasks
367     def setActive(self, cr, uid, ids, value=True, context=None):
368         task_obj = self.pool.get('project.task')
369         for proj in self.browse(cr, uid, ids, context=None):
370             self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
371             cr.execute('select id from project_task where project_id=%s', (proj.id,))
372             tasks_id = [x[0] for x in cr.fetchall()]
373             if tasks_id:
374                 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
375             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
376             if child_ids:
377                 self.setActive(cr, uid, child_ids, value, context=None)
378         return True
379
380     def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
381         context = context or {}
382         if type(ids) in (long, int,):
383             ids = [ids]
384         projects = self.browse(cr, uid, ids, context=context)
385
386         for project in projects:
387             if (not project.members) and force_members:
388                 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
389
390         resource_pool = self.pool.get('resource.resource')
391
392         result = "from openerp.addons.resource.faces import *\n"
393         result += "import datetime\n"
394         for project in self.browse(cr, uid, ids, context=context):
395             u_ids = [i.id for i in project.members]
396             if project.user_id and (project.user_id.id not in u_ids):
397                 u_ids.append(project.user_id.id)
398             for task in project.tasks:
399                 if task.state in ('done','cancelled'):
400                     continue
401                 if task.user_id and (task.user_id.id not in u_ids):
402                     u_ids.append(task.user_id.id)
403             calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
404             resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
405             for key, vals in resource_objs.items():
406                 result +='''
407 class User_%s(Resource):
408     efficiency = %s
409 ''' % (key,  vals.get('efficiency', False))
410
411         result += '''
412 def Project():
413         '''
414         return result
415
416     def _schedule_project(self, cr, uid, project, context=None):
417         resource_pool = self.pool.get('resource.resource')
418         calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
419         working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
420         # TODO: check if we need working_..., default values are ok.
421         puids = [x.id for x in project.members]
422         if project.user_id:
423             puids.append(project.user_id.id)
424         result = """
425   def Project_%d():
426     start = \'%s\'
427     working_days = %s
428     resource = %s
429 """       % (
430             project.id,
431             project.date_start, working_days,
432             '|'.join(['User_'+str(x) for x in puids])
433         )
434         vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
435         if vacation:
436             result+= """
437     vacation = %s
438 """ %   ( vacation, )
439         return result
440
441     #TODO: DO Resource allocation and compute availability
442     def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
443         if context ==  None:
444             context = {}
445         allocation = {}
446         return allocation
447
448     def schedule_tasks(self, cr, uid, ids, context=None):
449         context = context or {}
450         if type(ids) in (long, int,):
451             ids = [ids]
452         projects = self.browse(cr, uid, ids, context=context)
453         result = self._schedule_header(cr, uid, ids, False, context=context)
454         for project in projects:
455             result += self._schedule_project(cr, uid, project, context=context)
456             result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
457
458         local_dict = {}
459         exec result in local_dict
460         projects_gantt = Task.BalancedProject(local_dict['Project'])
461
462         for project in projects:
463             project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
464             for task in project.tasks:
465                 if task.state in ('done','cancelled'):
466                     continue
467
468                 p = getattr(project_gantt, 'Task_%d' % (task.id,))
469
470                 self.pool.get('project.task').write(cr, uid, [task.id], {
471                     'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
472                     'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
473                 }, context=context)
474                 if (not task.user_id) and (p.booked_resource):
475                     self.pool.get('project.task').write(cr, uid, [task.id], {
476                         'user_id': int(p.booked_resource[0].name[5:]),
477                     }, context=context)
478         return True
479
480     # ------------------------------------------------
481     # OpenChatter methods and notifications
482     # ------------------------------------------------
483     
484     def get_needaction_user_ids(self, cr, uid, ids, context=None):
485         result = dict.fromkeys(ids)
486         for obj in self.browse(cr, uid, ids, context=context):
487             result[obj.id] = []
488             if obj.state == 'draft' and obj.user_id:
489                 result[obj.id] = [obj.user_id.id]
490         return result
491
492     def message_get_subscribers(self, cr, uid, ids, context=None):
493         sub_ids = self.message_get_subscribers_ids(cr, uid, ids, context=context);
494         for obj in self.browse(cr, uid, ids, context=context):
495             if obj.user_id:
496                 sub_ids.append(obj.user_id.id)
497         return self.pool.get('res.users').read(cr, uid, sub_ids, context=context)
498
499     def create(self, cr, uid, vals, context=None):
500         obj_id = super(project, self).create(cr, uid, vals, context=context)
501         self.create_send_note(cr, uid, [obj_id], context=context)
502         return obj_id
503
504     def create_send_note(self, cr, uid, ids, context=None):
505         return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
506
507     def set_open_send_note(self, cr, uid, ids, context=None):
508         message = _("Project has been <b>opened</b>.")
509         return self.message_append_note(cr, uid, ids, body=message, context=context)
510
511     def set_pending_send_note(self, cr, uid, ids, context=None):
512         message = _("Project is now <b>pending</b>.")
513         return self.message_append_note(cr, uid, ids, body=message, context=context)
514
515     def set_cancel_send_note(self, cr, uid, ids, context=None):
516         message = _("Project has been <b>cancelled</b>.")
517         return self.message_append_note(cr, uid, ids, body=message, context=context)
518
519     def set_close_send_note(self, cr, uid, ids, context=None):
520         message = _("Project has been <b>closed</b>.")
521         return self.message_append_note(cr, uid, ids, body=message, context=context)
522
523
524 class task(base_stage, osv.osv):
525     _name = "project.task"
526     _description = "Task"
527     _date_name = "date_start"
528     _inherit = ['ir.needaction_mixin', 'mail.thread']
529
530     def _get_default_project_id(self, cr, uid, context=None):
531         """ Gives default section by checking if present in the context """
532         return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
533
534     def _get_default_stage_id(self, cr, uid, context=None):
535         """ Gives default stage_id """
536         project_id = self._get_default_project_id(cr, uid, context=context)
537         return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
538
539     def _resolve_project_id_from_context(self, cr, uid, context=None):
540         """ Returns ID of project based on the value of 'default_project_id'
541             context key, or None if it cannot be resolved to a single
542             project.
543         """
544         if context is None: context = {}
545         if type(context.get('default_project_id')) in (int, long):
546             return context['default_project_id']
547         if isinstance(context.get('default_project_id'), basestring):
548             project_name = context['default_project_id']
549             project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
550             if len(project_ids) == 1:
551                 return project_ids[0][0]
552         return None
553
554     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
555         stage_obj = self.pool.get('project.task.type')
556         order = stage_obj._order
557         access_rights_uid = access_rights_uid or uid
558         # lame way to allow reverting search, should just work in the trivial case
559         if read_group_order == 'stage_id desc':
560             order = '%s desc' % order
561         # retrieve section_id from the context and write the domain
562         # - ('id', 'in', 'ids'): add columns that should be present
563         # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
564         # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
565         search_domain = []
566         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
567         if project_id:
568             search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
569         search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
570         stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
571         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
572         # restore order of the search
573         result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
574         return result
575     
576     def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
577         res_users = self.pool.get('res.users')
578         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
579         access_rights_uid = access_rights_uid or uid
580         if project_id:
581             ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
582             order = res_users._order
583             # lame way to allow reverting search, should just work in the trivial case
584             if read_group_order == 'user_id desc':
585                 order = '%s desc' % order
586             # de-duplicate and apply search order
587             ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
588         result = res_users.name_get(cr, access_rights_uid, ids, context=context)
589         # restore order of the search
590         result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
591         return result
592
593     _group_by_full = {
594         'stage_id': _read_group_stage_ids,
595         'user_id': _read_group_user_id,
596     }
597
598     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
599         obj_project = self.pool.get('project.project')
600         for domain in args:
601             if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
602                 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
603                 if id and isinstance(id, (long, int)):
604                     if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
605                         args.append(('active', '=', False))
606         return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
607
608     def _str_get(self, task, level=0, border='***', context=None):
609         return border+' '+(task.user_id and task.user_id.name.upper() or '')+(level and (': L'+str(level)) or '')+(' - %.1fh / %.1fh'%(task.effective_hours or 0.0,task.planned_hours))+' '+border+'\n'+ \
610             border[0]+' '+(task.name or '')+'\n'+ \
611             (task.description or '')+'\n\n'
612
613     # Compute: effective_hours, total_hours, progress
614     def _hours_get(self, cr, uid, ids, field_names, args, context=None):
615         res = {}
616         cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
617         hours = dict(cr.fetchall())
618         for task in self.browse(cr, uid, ids, context=context):
619             res[task.id] = {'effective_hours': hours.get(task.id, 0.0), 'total_hours': (task.remaining_hours or 0.0) + hours.get(task.id, 0.0)}
620             res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
621             res[task.id]['progress'] = 0.0
622             if (task.remaining_hours + hours.get(task.id, 0.0)):
623                 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
624             if task.state in ('done','cancelled'):
625                 res[task.id]['progress'] = 100.0
626         return res
627
628     def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
629         if remaining and not planned:
630             return {'value':{'planned_hours': remaining}}
631         return {}
632
633     def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
634         return {'value':{'remaining_hours': planned - effective}}
635
636     def onchange_project(self, cr, uid, id, project_id):
637         if not project_id:
638             return {}
639         data = self.pool.get('project.project').browse(cr, uid, [project_id])
640         partner_id=data and data[0].partner_id
641         if partner_id:
642             return {'value':{'partner_id':partner_id.id}}
643         return {}
644
645     def duplicate_task(self, cr, uid, map_ids, context=None):
646         for new in map_ids.values():
647             task = self.browse(cr, uid, new, context)
648             child_ids = [ ch.id for ch in task.child_ids]
649             if task.child_ids:
650                 for child in task.child_ids:
651                     if child.id in map_ids.keys():
652                         child_ids.remove(child.id)
653                         child_ids.append(map_ids[child.id])
654
655             parent_ids = [ ch.id for ch in task.parent_ids]
656             if task.parent_ids:
657                 for parent in task.parent_ids:
658                     if parent.id in map_ids.keys():
659                         parent_ids.remove(parent.id)
660                         parent_ids.append(map_ids[parent.id])
661             #FIXME why there is already the copy and the old one
662             self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
663
664     def copy_data(self, cr, uid, id, default={}, context=None):
665         default = default or {}
666         default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
667         if not default.get('remaining_hours', False):
668             default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
669         default['active'] = True
670         default['stage_id'] = False
671         if not default.get('name', False):
672             default['name'] = self.browse(cr, uid, id, context=context).name or ''
673             if not context.get('copy',False):
674                 new_name = _("%s (copy)")%default.get('name','')
675                 default.update({'name':new_name})
676         return super(task, self).copy_data(cr, uid, id, default, context)
677
678
679     def _is_template(self, cr, uid, ids, field_name, arg, context=None):
680         res = {}
681         for task in self.browse(cr, uid, ids, context=context):
682             res[task.id] = True
683             if task.project_id:
684                 if task.project_id.active == False or task.project_id.state == 'template':
685                     res[task.id] = False
686         return res
687
688     def _get_task(self, cr, uid, ids, context=None):
689         result = {}
690         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
691             if work.task_id: result[work.task_id.id] = True
692         return result.keys()
693
694     _columns = {
695         'active': fields.function(_is_template, store=True, string='Not a Template Task', type='boolean', help="This field is computed automatically and have the same behavior than the boolean 'active' field: if the task is linked to a template or unactivated project, it will be hidden unless specifically asked."),
696         'name': fields.char('Task Summary', size=128, required=True, select=True),
697         'description': fields.text('Description'),
698         'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
699         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
700         'stage_id': fields.many2one('project.task.type', 'Stage',
701                         domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
702         'state': fields.related('stage_id', 'state', type="selection", store=True,
703                 selection=_TASK_STATE, string="State", readonly=True,
704                 help='The state is set to \'Draft\', when a case is created.\
705                       If the case is in progress the state is set to \'Open\'.\
706                       When the case is over, the state is set to \'Done\'.\
707                       If the case needs to be reviewed then the state is \
708                       set to \'Pending\'.'),
709         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
710                                          help="A task's kanban state indicates special situations affecting it:\n"
711                                               " * Normal is the default situation\n"
712                                               " * Blocked indicates something is preventing the progress of this task\n"
713                                               " * Ready To Pull indicates the task is ready to be pulled to the next stage",
714                                          readonly=True, required=False),
715         'create_date': fields.datetime('Create Date', readonly=True,select=True),
716         'date_start': fields.datetime('Starting Date',select=True),
717         'date_end': fields.datetime('Ending Date',select=True),
718         'date_deadline': fields.date('Deadline',select=True),
719         'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
720         'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
721         'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
722         'notes': fields.text('Notes'),
723         'planned_hours': fields.float('Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
724         'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
725             store = {
726                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
727                 'project.task.work': (_get_task, ['hours'], 10),
728             }),
729         'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
730         'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
731             store = {
732                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
733                 'project.task.work': (_get_task, ['hours'], 10),
734             }),
735         'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="If the task has a progress of 99.99% you should close the task if it's finished or reevaluate the time",
736             store = {
737                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
738                 'project.task.work': (_get_task, ['hours'], 10),
739             }),
740         'delay_hours': fields.function(_hours_get, string='Delay Hours', multi='hours', help="Computed as difference between planned hours by the project manager and the total hours of the task.",
741             store = {
742                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
743                 'project.task.work': (_get_task, ['hours'], 10),
744             }),
745         'user_id': fields.many2one('res.users', 'Assigned to'),
746         'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
747         'partner_id': fields.many2one('res.partner', 'Partner'),
748         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
749         'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
750         'company_id': fields.many2one('res.company', 'Company'),
751         'id': fields.integer('ID', readonly=True),
752         'color': fields.integer('Color Index'),
753         'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
754     }
755
756     _defaults = {
757         'stage_id': _get_default_stage_id,
758         'project_id': _get_default_project_id,
759         'state': 'draft',
760         'kanban_state': 'normal',
761         'priority': '2',
762         'progress': 0,
763         'sequence': 10,
764         'active': True,
765         'user_id': lambda obj, cr, uid, context: uid,
766         'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
767     }
768
769     _order = "priority, sequence, date_start, name, id"
770
771     def set_priority(self, cr, uid, ids, priority):
772         """Set task priority
773         """
774         return self.write(cr, uid, ids, {'priority' : priority})
775
776     def set_high_priority(self, cr, uid, ids, *args):
777         """Set task priority to high
778         """
779         return self.set_priority(cr, uid, ids, '1')
780
781     def set_normal_priority(self, cr, uid, ids, *args):
782         """Set task priority to normal
783         """
784         return self.set_priority(cr, uid, ids, '2')
785
786     def _check_recursion(self, cr, uid, ids, context=None):
787         for id in ids:
788             visited_branch = set()
789             visited_node = set()
790             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
791             if not res:
792                 return False
793
794         return True
795
796     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
797         if id in visited_branch: #Cycle
798             return False
799
800         if id in visited_node: #Already tested don't work one more time for nothing
801             return True
802
803         visited_branch.add(id)
804         visited_node.add(id)
805
806         #visit child using DFS
807         task = self.browse(cr, uid, id, context=context)
808         for child in task.child_ids:
809             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
810             if not res:
811                 return False
812
813         visited_branch.remove(id)
814         return True
815
816     def _check_dates(self, cr, uid, ids, context=None):
817         if context == None:
818             context = {}
819         obj_task = self.browse(cr, uid, ids[0], context=context)
820         start = obj_task.date_start or False
821         end = obj_task.date_end or False
822         if start and end :
823             if start > end:
824                 return False
825         return True
826
827     _constraints = [
828         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
829         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
830     ]
831     #
832     # Override view according to the company definition
833     #
834     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
835         users_obj = self.pool.get('res.users')
836         if context is None: context = {}
837         # read uom as admin to avoid access rights issues, e.g. for portal/share users,
838         # this should be safe (no context passed to avoid side-effects)
839         obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
840         tm = obj_tm and obj_tm.name or 'Hours'
841
842         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
843
844         if tm in ['Hours','Hour']:
845             return res
846
847         eview = etree.fromstring(res['arch'])
848
849         def _check_rec(eview):
850             if eview.attrib.get('widget','') == 'float_time':
851                 eview.set('widget','float')
852             for child in eview:
853                 _check_rec(child)
854             return True
855
856         _check_rec(eview)
857
858         res['arch'] = etree.tostring(eview)
859
860         for f in res['fields']:
861             if 'Hours' in res['fields'][f]['string']:
862                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
863         return res
864
865     # ****************************************
866     # Case management
867     # ****************************************
868
869     def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
870         """ Override of the base.stage method
871             Parameter of the stage search taken from the lead:
872             - section_id: if set, stages must belong to this section or
873               be a default stage; if not set, stages must be default
874               stages
875         """
876         if isinstance(cases, (int, long)):
877             cases = self.browse(cr, uid, cases, context=context)
878         # collect all section_ids
879         section_ids = []
880         if section_id:
881             section_ids.append(section_id)
882         for task in cases:
883             if task.project_id:
884                 section_ids.append(task.project_id.id)
885         # OR all section_ids and OR with case_default
886         search_domain = []
887         if section_ids:
888             search_domain += [('|')] * len(section_ids)
889             for section_id in section_ids:
890                 search_domain.append(('project_ids', '=', section_id))
891         search_domain.append(('case_default', '=', True))
892         # AND with the domain in parameter
893         search_domain += list(domain)
894         # perform search, return the first found
895         stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
896         if stage_ids:
897             return stage_ids[0]
898         return False
899
900     def _check_child_task(self, cr, uid, ids, context=None):
901         if context == None:
902             context = {}
903         tasks = self.browse(cr, uid, ids, context=context)
904         for task in tasks:
905             if task.child_ids:
906                 for child in task.child_ids:
907                     if child.state in ['draft', 'open', 'pending']:
908                         raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
909         return True
910
911     def action_close(self, cr, uid, ids, context=None):
912         """ This action closes the task 
913         """
914         task_id = len(ids) and ids[0] or False
915         self._check_child_task(cr, uid, ids, context=context)
916         if not task_id: return False
917         return self.do_close(cr, uid, [task_id], context=context)
918
919     def do_close(self, cr, uid, ids, context=None):
920         """ Compatibility when changing to case_close. """
921         return self.case_close(cr, uid, ids, context=context)
922     
923     def case_close(self, cr, uid, ids, context=None):
924         """ Closes Task """
925         if not isinstance(ids, list): ids = [ids]
926         for task in self.browse(cr, uid, ids, context=context):
927             vals = {}
928             project = task.project_id
929             for parent_id in task.parent_ids:
930                 if parent_id.state in ('pending','draft'):
931                     reopen = True
932                     for child in parent_id.child_ids:
933                         if child.id != task.id and child.state not in ('done','cancelled'):
934                             reopen = False
935                     if reopen:
936                         self.do_reopen(cr, uid, [parent_id.id], context=context)
937             # close task
938             vals['remaining_hours'] = 0.0
939             if not task.date_end:
940                 vals['date_end'] = fields.datetime.now()
941             self.case_set(cr, uid, [task.id], 'done', vals, context=context)
942             self.case_close_send_note(cr, uid, [task.id], context=context)
943         return True
944
945     def do_reopen(self, cr, uid, ids, context=None):
946         for task in self.browse(cr, uid, ids, context=context):
947             project = task.project_id
948             self.case_set(cr, uid, [task.id], 'open', {}, context=context)
949             self.case_open_send_note(cr, uid, [task.id], context)
950         return True
951
952     def do_cancel(self, cr, uid, ids, context=None):
953         """ Compatibility when changing to case_cancel. """
954         return self.case_cancel(cr, uid, ids, context=context)
955     
956     def case_cancel(self, cr, uid, ids, context=None):
957         tasks = self.browse(cr, uid, ids, context=context)
958         self._check_child_task(cr, uid, ids, context=context)
959         for task in tasks:
960             self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
961             self.case_cancel_send_note(cr, uid, [task.id], context=context)
962         return True
963
964     def do_open(self, cr, uid, ids, context=None):
965         """ Compatibility when changing to case_open. """
966         return self.case_open(cr, uid, ids, context=context)
967     
968     def case_open(self, cr, uid, ids, context=None):
969         if not isinstance(ids,list): ids = [ids]
970         self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
971         self.case_open_send_note(cr, uid, ids, context)
972         return True
973
974     def do_draft(self, cr, uid, ids, context=None):
975         """ Compatibility when changing to case_draft. """
976         return self.case_draft(cr, uid, ids, context=context)
977     
978     def case_draft(self, cr, uid, ids, context=None):
979         self.case_set(cr, uid, ids, 'draft', {}, context=context)
980         self.case_draft_send_note(cr, uid, ids, context=context)
981         return True
982
983     def do_pending(self, cr, uid, ids, context=None):
984         """ Compatibility when changing to case_pending. """
985         return self.case_pending(cr, uid, ids, context=context)
986     
987     def case_pending(self, cr, uid, ids, context=None):
988         self.case_set(cr, uid, ids, 'pending', {}, context=context)
989         return self.case_pending_send_note(cr, uid, ids, context=context)
990     
991     def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
992         attachment = self.pool.get('ir.attachment')
993         attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
994         new_attachment_ids = []
995         for attachment_id in attachment_ids:
996             new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
997         return new_attachment_ids
998
999     def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1000         """
1001         Delegate Task to another users.
1002         """
1003         assert delegate_data['user_id'], _("Delegated User should be specified")
1004         delegated_tasks = {}
1005         for task in self.browse(cr, uid, ids, context=context):
1006             delegated_task_id = self.copy(cr, uid, task.id, {
1007                 'name': delegate_data['name'],
1008                 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1009                 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1010                 'planned_hours': delegate_data['planned_hours'] or 0.0,
1011                 'parent_ids': [(6, 0, [task.id])],
1012                 'state': 'draft',
1013                 'description': delegate_data['new_task_description'] or '',
1014                 'child_ids': [],
1015                 'work_ids': []
1016             }, context=context)
1017             self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1018             newname = delegate_data['prefix'] or ''
1019             task.write({
1020                 'remaining_hours': delegate_data['planned_hours_me'],
1021                 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1022                 'name': newname,
1023             }, context=context)
1024             if delegate_data['state'] == 'pending':
1025                 self.do_pending(cr, uid, [task.id], context=context)
1026             elif delegate_data['state'] == 'done':
1027                 self.do_close(cr, uid, [task.id], context=context)
1028             self.do_delegation_send_note(cr, uid, [task.id], context)
1029             delegated_tasks[task.id] = delegated_task_id
1030         return delegated_tasks
1031
1032     def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1033         for task in self.browse(cr, uid, ids, context=context):
1034             if (task.state=='draft') or (task.planned_hours==0.0):
1035                 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1036         self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1037         return True
1038
1039     def set_remaining_time_1(self, cr, uid, ids, context=None):
1040         return self.set_remaining_time(cr, uid, ids, 1.0, context)
1041
1042     def set_remaining_time_2(self, cr, uid, ids, context=None):
1043         return self.set_remaining_time(cr, uid, ids, 2.0, context)
1044
1045     def set_remaining_time_5(self, cr, uid, ids, context=None):
1046         return self.set_remaining_time(cr, uid, ids, 5.0, context)
1047
1048     def set_remaining_time_10(self, cr, uid, ids, context=None):
1049         return self.set_remaining_time(cr, uid, ids, 10.0, context)
1050
1051     def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1052         self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1053
1054     def set_kanban_state_normal(self, cr, uid, ids, context=None):
1055         self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1056
1057     def set_kanban_state_done(self, cr, uid, ids, context=None):
1058         self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1059
1060     def _store_history(self, cr, uid, ids, context=None):
1061         for task in self.browse(cr, uid, ids, context=context):
1062             self.pool.get('project.task.history').create(cr, uid, {
1063                 'task_id': task.id,
1064                 'remaining_hours': task.remaining_hours,
1065                 'planned_hours': task.planned_hours,
1066                 'kanban_state': task.kanban_state,
1067                 'type_id': task.stage_id.id,
1068                 'state': task.state,
1069                 'user_id': task.user_id.id
1070
1071             }, context=context)
1072         return True
1073
1074     def create(self, cr, uid, vals, context=None):
1075         task_id = super(task, self).create(cr, uid, vals, context=context)
1076         self._store_history(cr, uid, [task_id], context=context)
1077         self.create_send_note(cr, uid, [task_id], context=context)
1078         return task_id
1079
1080     # Overridden to reset the kanban_state to normal whenever
1081     # the stage (stage_id) of the task changes.
1082     def write(self, cr, uid, ids, vals, context=None):
1083         if isinstance(ids, (int, long)):
1084             ids = [ids]
1085         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1086             new_stage = vals.get('stage_id')
1087             vals_reset_kstate = dict(vals, kanban_state='normal')
1088             for t in self.browse(cr, uid, ids, context=context):
1089                 #TO FIX:Kanban view doesn't raise warning 
1090                 #stages = [stage.id for stage in t.project_id.type_ids]
1091                 #if new_stage not in stages:
1092                     #raise osv.except_osv(_('Warning !'), _('Stage is not defined in the project.'))
1093                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1094                 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1095             result = True
1096         else:
1097             result = super(task,self).write(cr, uid, ids, vals, context=context)
1098         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1099             self._store_history(cr, uid, ids, context=context)
1100         return result
1101
1102     def unlink(self, cr, uid, ids, context=None):
1103         if context == None:
1104             context = {}
1105         self._check_child_task(cr, uid, ids, context=context)
1106         res = super(task, self).unlink(cr, uid, ids, context)
1107         return res
1108
1109     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1110         context = context or {}
1111         result = ""
1112         ident = ' '*ident
1113         for task in tasks:
1114             if task.state in ('done','cancelled'):
1115                 continue
1116             result += '''
1117 %sdef Task_%s():
1118 %s  todo = \"%.2fH\"
1119 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1120             start = []
1121             for t2 in task.parent_ids:
1122                 start.append("up.Task_%s.end" % (t2.id,))
1123             if start:
1124                 result += '''
1125 %s  start = max(%s)
1126 ''' % (ident,','.join(start))
1127
1128             if task.user_id:
1129                 result += '''
1130 %s  resource = %s
1131 ''' % (ident, 'User_'+str(task.user_id.id))
1132
1133         result += "\n"
1134         return result
1135     
1136     # ---------------------------------------------------
1137     # OpenChatter methods and notifications
1138     # ---------------------------------------------------
1139
1140     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1141         """ Override of default prefix for notifications. """
1142         return 'Task'
1143
1144     def get_needaction_user_ids(self, cr, uid, ids, context=None):
1145         result = dict.fromkeys(ids, [])
1146         for obj in self.browse(cr, uid, ids, context=context):
1147             if obj.state == 'draft' and obj.user_id:
1148                 result[obj.id] = [obj.user_id.id]
1149         return result
1150
1151     def message_get_subscribers(self, cr, uid, ids, context=None):
1152         sub_ids = self.message_get_subscribers_ids(cr, uid, ids, context=context);
1153         for obj in self.browse(cr, uid, ids, context=context):
1154             if obj.user_id:
1155                 sub_ids.append(obj.user_id.id)
1156             if obj.manager_id:
1157                 sub_ids.append(obj.manager_id.id)
1158         return self.pool.get('res.users').read(cr, uid, sub_ids, context=context)
1159
1160     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1161         """ Override of the (void) default notification method. """
1162         stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1163         return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1164
1165     def create_send_note(self, cr, uid, ids, context=None):
1166         return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1167
1168     def case_draft_send_note(self, cr, uid, ids, context=None):
1169         msg = _('Task has been set as <b>draft</b>.')
1170         return self.message_append_note(cr, uid, ids, body=msg, context=context)
1171
1172     def do_delegation_send_note(self, cr, uid, ids, context=None):
1173         for task in self.browse(cr, uid, ids, context=context):
1174             msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1175             self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1176         return True
1177
1178
1179 class project_work(osv.osv):
1180     _name = "project.task.work"
1181     _description = "Project Task Work"
1182     _columns = {
1183         'name': fields.char('Work summary', size=128),
1184         'date': fields.datetime('Date', select="1"),
1185         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1186         'hours': fields.float('Time Spent'),
1187         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1188         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1189     }
1190
1191     _defaults = {
1192         'user_id': lambda obj, cr, uid, context: uid,
1193         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1194     }
1195
1196     _order = "date desc"
1197     def create(self, cr, uid, vals, *args, **kwargs):
1198         if 'hours' in vals and (not vals['hours']):
1199             vals['hours'] = 0.00
1200         if 'task_id' in vals:
1201             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1202         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1203
1204     def write(self, cr, uid, ids, vals, context=None):
1205         if 'hours' in vals and (not vals['hours']):
1206             vals['hours'] = 0.00
1207         if 'hours' in vals:
1208             for work in self.browse(cr, uid, ids, context=context):
1209                 cr.execute('update project_task set remaining_hours=remaining_hours - %s + (%s) where id=%s', (vals.get('hours',0.0), work.hours, work.task_id.id))
1210         return super(project_work,self).write(cr, uid, ids, vals, context)
1211
1212     def unlink(self, cr, uid, ids, *args, **kwargs):
1213         for work in self.browse(cr, uid, ids):
1214             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1215         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1216
1217
1218 class account_analytic_account(osv.osv):
1219     _inherit = 'account.analytic.account'
1220     _description = 'Analytic Account'
1221     _columns = {
1222         'use_tasks': fields.boolean('Tasks Management',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1223         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1224     }
1225
1226     def _trigger_project_creation(self, cr, uid, vals, context=None):
1227         '''
1228         This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise.
1229         '''
1230         return vals.get('use_tasks')
1231
1232     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1233         '''
1234         This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet.
1235         '''
1236         project_pool = self.pool.get('project.project')
1237         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1238         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1239             project_values = {
1240                 'name': vals.get('name'),
1241                 'analytic_account_id': analytic_account_id,
1242             }
1243             return project_pool.create(cr, uid, project_values, context=context)
1244         return False
1245
1246     def create(self, cr, uid, vals, context=None):
1247         if context is None:
1248             context = {}
1249         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1250             vals['child_ids'] = []
1251         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1252         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1253         return analytic_account_id
1254
1255     def write(self, cr, uid, ids, vals, context=None):
1256         name = vals.get('name')
1257         for account in self.browse(cr, uid, ids, context=context):
1258             if not name:
1259                 vals['name'] = account.name
1260             self.project_create(cr, uid, account.id, vals, context=context)
1261         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1262
1263     def unlink(self, cr, uid, ids, *args, **kwargs):
1264         project_obj = self.pool.get('project.project')
1265         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1266         if analytic_ids:
1267             raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1268         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1269
1270
1271 #
1272 # Tasks History, used for cumulative flow charts (Lean/Agile)
1273 #
1274
1275 class project_task_history(osv.osv):
1276     _name = 'project.task.history'
1277     _description = 'History of Tasks'
1278     _rec_name = 'task_id'
1279     _log_access = False
1280     def _get_date(self, cr, uid, ids, name, arg, context=None):
1281         result = {}
1282         for history in self.browse(cr, uid, ids, context=context):
1283             if history.state in ('done','cancelled'):
1284                 result[history.id] = history.date
1285                 continue
1286             cr.execute('''select
1287                     date
1288                 from
1289                     project_task_history
1290                 where
1291                     task_id=%s and
1292                     id>%s
1293                 order by id limit 1''', (history.task_id.id, history.id))
1294             res = cr.fetchone()
1295             result[history.id] = res and res[0] or False
1296         return result
1297
1298     def _get_related_date(self, cr, uid, ids, context=None):
1299         result = []
1300         for history in self.browse(cr, uid, ids, context=context):
1301             cr.execute('''select
1302                     id
1303                 from
1304                     project_task_history
1305                 where
1306                     task_id=%s and
1307                     id<%s
1308                 order by id desc limit 1''', (history.task_id.id, history.id))
1309             res = cr.fetchone()
1310             if res:
1311                 result.append(res[0])
1312         return result
1313
1314     _columns = {
1315         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1316         'type_id': fields.many2one('project.task.type', 'Stage'),
1317         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1318         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1319         'date': fields.date('Date', select=True),
1320         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1321             'project.task.history': (_get_related_date, None, 20)
1322         }),
1323         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1324         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1325         'user_id': fields.many2one('res.users', 'Responsible'),
1326     }
1327     _defaults = {
1328         'date': fields.date.context_today,
1329     }
1330
1331
1332 class project_task_history_cumulative(osv.osv):
1333     _name = 'project.task.history.cumulative'
1334     _table = 'project_task_history_cumulative'
1335     _inherit = 'project.task.history'
1336     _auto = False
1337     _columns = {
1338         'end_date': fields.date('End Date'),
1339         'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1340     }
1341     def init(self, cr):
1342         cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1343             SELECT
1344                 history.date::varchar||'-'||history.history_id::varchar as id,
1345                 history.date as end_date,
1346                 *
1347             FROM (
1348                 SELECT
1349                     id as history_id,
1350                     date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1351                     task_id, type_id, user_id, kanban_state, state,
1352                     greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1353                 FROM
1354                     project_task_history
1355             ) as history
1356         )
1357         """)
1358