re
[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('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 Hours', 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             result = True
1093         else:
1094             result = super(task,self).write(cr, uid, ids, vals, context=context)
1095         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1096             self._store_history(cr, uid, ids, context=context)
1097         return result
1098
1099     def unlink(self, cr, uid, ids, context=None):
1100         if context == None:
1101             context = {}
1102         self._check_child_task(cr, uid, ids, context=context)
1103         res = super(task, self).unlink(cr, uid, ids, context)
1104         return res
1105
1106     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1107         context = context or {}
1108         result = ""
1109         ident = ' '*ident
1110         for task in tasks:
1111             if task.state in ('done','cancelled'):
1112                 continue
1113             result += '''
1114 %sdef Task_%s():
1115 %s  todo = \"%.2fH\"
1116 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1117             start = []
1118             for t2 in task.parent_ids:
1119                 start.append("up.Task_%s.end" % (t2.id,))
1120             if start:
1121                 result += '''
1122 %s  start = max(%s)
1123 ''' % (ident,','.join(start))
1124
1125             if task.user_id:
1126                 result += '''
1127 %s  resource = %s
1128 ''' % (ident, 'User_'+str(task.user_id.id))
1129
1130         result += "\n"
1131         return result
1132     
1133     # ---------------------------------------------------
1134     # OpenChatter methods and notifications
1135     # ---------------------------------------------------
1136
1137     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1138         """ Override of default prefix for notifications. """
1139         return 'Task'
1140
1141     def get_needaction_user_ids(self, cr, uid, ids, context=None):
1142         """ Returns the user_ids that have to perform an action.
1143             Add to the previous results given by super the document responsible
1144             when in draft mode.
1145             :return: dict { record_id: [user_ids], }
1146         """
1147         result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1148         for obj in self.browse(cr, uid, ids, context=context):
1149             if obj.state == 'draft' and obj.user_id:
1150                 result[obj.id].append(obj.user_id.id)
1151         return result
1152
1153     def message_get_subscribers(self, cr, uid, ids, context=None):
1154         """ Override to add responsible user and project manager. """
1155         user_ids = super(task, self).message_get_subscribers(cr, uid, ids, context=context)
1156         for obj in self.browse(cr, uid, ids, context=context):
1157             if obj.user_id and not obj.user_id.id in user_ids:
1158                 user_ids.append(obj.user_id.id)
1159             if obj.manager_id and not obj.manager_id.id in user_ids:
1160                 user_ids.append(obj.manager_id.id)
1161         return user_ids
1162
1163     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1164         """ Override of the (void) default notification method. """
1165         stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1166         return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1167
1168     def create_send_note(self, cr, uid, ids, context=None):
1169         return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1170
1171     def case_draft_send_note(self, cr, uid, ids, context=None):
1172         msg = _('Task has been set as <b>draft</b>.')
1173         return self.message_append_note(cr, uid, ids, body=msg, context=context)
1174
1175     def do_delegation_send_note(self, cr, uid, ids, context=None):
1176         for task in self.browse(cr, uid, ids, context=context):
1177             msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1178             self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1179         return True
1180
1181
1182 class project_work(osv.osv):
1183     _name = "project.task.work"
1184     _description = "Project Task Work"
1185     _columns = {
1186         'name': fields.char('Work summary', size=128),
1187         'date': fields.datetime('Date', select="1"),
1188         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1189         'hours': fields.float('Time Spent'),
1190         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1191         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1192     }
1193
1194     _defaults = {
1195         'user_id': lambda obj, cr, uid, context: uid,
1196         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1197     }
1198
1199     _order = "date desc"
1200     def create(self, cr, uid, vals, *args, **kwargs):
1201         if 'hours' in vals and (not vals['hours']):
1202             vals['hours'] = 0.00
1203         if 'task_id' in vals:
1204             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1205         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1206
1207     def write(self, cr, uid, ids, vals, context=None):
1208         if 'hours' in vals and (not vals['hours']):
1209             vals['hours'] = 0.00
1210         if 'hours' in vals:
1211             for work in self.browse(cr, uid, ids, context=context):
1212                 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))
1213         return super(project_work,self).write(cr, uid, ids, vals, context)
1214
1215     def unlink(self, cr, uid, ids, *args, **kwargs):
1216         for work in self.browse(cr, uid, ids):
1217             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1218         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1219
1220
1221 class account_analytic_account(osv.osv):
1222     _inherit = 'account.analytic.account'
1223     _description = 'Analytic Account'
1224     _columns = {
1225         'use_tasks': fields.boolean('Tasks Management',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1226         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1227     }
1228
1229     def _trigger_project_creation(self, cr, uid, vals, context=None):
1230         '''
1231         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.
1232         '''
1233         return vals.get('use_tasks')
1234
1235     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1236         '''
1237         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.
1238         '''
1239         project_pool = self.pool.get('project.project')
1240         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1241         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1242             project_values = {
1243                 'name': vals.get('name'),
1244                 'analytic_account_id': analytic_account_id,
1245             }
1246             return project_pool.create(cr, uid, project_values, context=context)
1247         return False
1248
1249     def create(self, cr, uid, vals, context=None):
1250         if context is None:
1251             context = {}
1252         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1253             vals['child_ids'] = []
1254         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1255         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1256         return analytic_account_id
1257
1258     def write(self, cr, uid, ids, vals, context=None):
1259         name = vals.get('name')
1260         for account in self.browse(cr, uid, ids, context=context):
1261             if not name:
1262                 vals['name'] = account.name
1263             self.project_create(cr, uid, account.id, vals, context=context)
1264         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1265
1266     def unlink(self, cr, uid, ids, *args, **kwargs):
1267         project_obj = self.pool.get('project.project')
1268         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1269         if analytic_ids:
1270             raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1271         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1272
1273
1274 #
1275 # Tasks History, used for cumulative flow charts (Lean/Agile)
1276 #
1277
1278 class project_task_history(osv.osv):
1279     _name = 'project.task.history'
1280     _description = 'History of Tasks'
1281     _rec_name = 'task_id'
1282     _log_access = False
1283     def _get_date(self, cr, uid, ids, name, arg, context=None):
1284         result = {}
1285         for history in self.browse(cr, uid, ids, context=context):
1286             if history.state in ('done','cancelled'):
1287                 result[history.id] = history.date
1288                 continue
1289             cr.execute('''select
1290                     date
1291                 from
1292                     project_task_history
1293                 where
1294                     task_id=%s and
1295                     id>%s
1296                 order by id limit 1''', (history.task_id.id, history.id))
1297             res = cr.fetchone()
1298             result[history.id] = res and res[0] or False
1299         return result
1300
1301     def _get_related_date(self, cr, uid, ids, context=None):
1302         result = []
1303         for history in self.browse(cr, uid, ids, context=context):
1304             cr.execute('''select
1305                     id
1306                 from
1307                     project_task_history
1308                 where
1309                     task_id=%s and
1310                     id<%s
1311                 order by id desc limit 1''', (history.task_id.id, history.id))
1312             res = cr.fetchone()
1313             if res:
1314                 result.append(res[0])
1315         return result
1316
1317     _columns = {
1318         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1319         'type_id': fields.many2one('project.task.type', 'Stage'),
1320         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1321         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1322         'date': fields.date('Date', select=True),
1323         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1324             'project.task.history': (_get_related_date, None, 20)
1325         }),
1326         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1327         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1328         'user_id': fields.many2one('res.users', 'Responsible'),
1329     }
1330     _defaults = {
1331         'date': fields.date.context_today,
1332     }
1333
1334
1335 class project_task_history_cumulative(osv.osv):
1336     _name = 'project.task.history.cumulative'
1337     _table = 'project_task_history_cumulative'
1338     _inherit = 'project.task.history'
1339     _auto = False
1340     _columns = {
1341         'end_date': fields.date('End Date'),
1342         'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1343     }
1344     def init(self, cr):
1345         cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1346             SELECT
1347                 history.date::varchar||'-'||history.history_id::varchar as id,
1348                 history.date as end_date,
1349                 *
1350             FROM (
1351                 SELECT
1352                     id as history_id,
1353                     date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1354                     task_id, type_id, user_id, kanban_state, state,
1355                     greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1356                 FROM
1357                     project_task_history
1358             ) as history
1359         )
1360         """)
1361