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