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