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