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