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