1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
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.
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.
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/>.
20 ##############################################################################
22 from lxml import etree
24 from datetime import datetime, date
26 from tools.translate import _
27 from osv import fields, osv
28 from openerp.addons.resource.faces import task as Task
30 # I think we can remove this in v6.1 since VMT's improvements in the framework ?
31 #class project_project(osv.osv):
32 # _name = 'project.project'
35 class project_task_type(osv.osv):
36 _name = 'project.task.type'
37 _description = 'Task Stage'
40 'name': fields.char('Stage Name', required=True, size=64, translate=True),
41 'description': fields.text('Description'),
42 'sequence': fields.integer('Sequence'),
43 'project_default': fields.boolean('Common to All Projects', 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."),
44 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
52 class project(osv.osv):
53 _name = "project.project"
54 _description = "Project"
55 _inherits = {'account.analytic.account': "analytic_account_id"}
57 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
59 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
60 if context and context.get('user_preference'):
61 cr.execute("""SELECT project.id FROM project_project project
62 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
63 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
64 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
65 return [(r[0]) for r in cr.fetchall()]
66 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
67 context=context, count=count)
69 def _complete_name(self, cr, uid, ids, name, args, context=None):
71 for m in self.browse(cr, uid, ids, context=context):
72 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
75 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
76 partner_obj = self.pool.get('res.partner')
78 return {'value':{'contact_id': False}}
79 addr = partner_obj.address_get(cr, uid, [part], ['contact'])
80 val = {'contact_id': addr['contact']}
81 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
82 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
83 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
84 val['pricelist_id'] = pricelist_id
87 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
88 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
89 project_ids = [task.project_id.id for task in tasks if task.project_id]
90 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
92 def _get_project_and_parents(self, cr, uid, ids, context=None):
93 """ return the project ids and all their parent projects """
97 SELECT DISTINCT parent.id
98 FROM project_project project, project_project parent, account_analytic_account account
99 WHERE project.analytic_account_id = account.id
100 AND parent.analytic_account_id = account.parent_id
103 ids = [t[0] for t in cr.fetchall()]
107 def _get_project_and_children(self, cr, uid, ids, context=None):
108 """ retrieve all children projects of project ids;
109 return a dictionary mapping each project to its parent project (or None)
111 res = dict.fromkeys(ids, None)
114 SELECT project.id, parent.id
115 FROM project_project project, project_project parent, account_analytic_account account
116 WHERE project.analytic_account_id = account.id
117 AND parent.analytic_account_id = account.parent_id
120 dic = dict(cr.fetchall())
125 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
126 child_parent = self._get_project_and_children(cr, uid, ids, context)
127 # compute planned_hours, total_hours, effective_hours specific to each project
129 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
130 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
131 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
133 """, (tuple(child_parent.keys()),))
134 # aggregate results into res
135 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
136 for id, planned, total, effective in cr.fetchall():
137 # add the values specific to id to all parent projects of id in the result
140 res[id]['planned_hours'] += planned
141 res[id]['total_hours'] += total
142 res[id]['effective_hours'] += effective
143 id = child_parent[id]
144 # compute progress rates
146 if res[id]['total_hours']:
147 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
149 res[id]['progress_rate'] = 0.0
152 def unlink(self, cr, uid, ids, *args, **kwargs):
153 for proj in self.browse(cr, uid, ids):
155 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
156 return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
159 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
160 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
161 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
162 '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),
163 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
164 'warn_manager': fields.boolean('Warn Manager', help="If you check this field, the project manager will receive an email each time a task is completed by his team.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
166 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
167 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)]}),
168 'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
169 '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.",
171 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
172 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
174 '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.",
176 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
177 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
179 '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.",
181 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
182 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
184 '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.",
186 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
187 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
189 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
190 'warn_customer': fields.boolean('Warn Partner', help="If you check this, the user will have a popup when closing a task that propose a message to send by email to the customer.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
191 'warn_header': fields.text('Mail Header', help="Header added at the beginning of the email for the warning message sent to the customer when a task is closed.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
192 'warn_footer': fields.text('Mail Footer', help="Footer added at the beginning of the email for the warning message sent to the customer when a task is closed.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
193 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
195 def _get_type_common(self, cr, uid, context):
196 ids = self.pool.get('project.task.type').search(cr, uid, [('project_default','=',1)], context=context)
204 'type_ids': _get_type_common
207 # TODO: Why not using a SQL contraints ?
208 def _check_dates(self, cr, uid, ids, context=None):
209 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
210 if leave['date_start'] and leave['date']:
211 if leave['date_start'] > leave['date']:
216 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
219 def set_template(self, cr, uid, ids, context=None):
220 res = self.setActive(cr, uid, ids, value=False, context=context)
223 def set_done(self, cr, uid, ids, context=None):
224 task_obj = self.pool.get('project.task')
225 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
226 task_obj.write(cr, uid, task_ids, {'state': 'done', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
227 self.write(cr, uid, ids, {'state':'close'}, context=context)
228 for (id, name) in self.name_get(cr, uid, ids):
229 message = _("The project '%s' has been closed.") % name
230 self.log(cr, uid, id, message)
233 def set_cancel(self, cr, uid, ids, context=None):
234 task_obj = self.pool.get('project.task')
235 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
236 task_obj.write(cr, uid, task_ids, {'state': 'cancelled', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
237 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
240 def set_pending(self, cr, uid, ids, context=None):
241 self.write(cr, uid, ids, {'state':'pending'}, context=context)
244 def set_open(self, cr, uid, ids, context=None):
245 self.write(cr, uid, ids, {'state':'open'}, context=context)
248 def reset_project(self, cr, uid, ids, context=None):
249 res = self.setActive(cr, uid, ids, value=True, context=context)
250 for (id, name) in self.name_get(cr, uid, ids):
251 message = _("The project '%s' has been opened.") % name
252 self.log(cr, uid, id, message)
255 def map_tasks(self,cr,uid,project_id,new_id,context=None):
256 #copy all the task manually
257 task_obj = self.pool.get('project.task')
258 proj = self.browse(cr, uid, project_id, context=context)
260 for task in proj.tasks:
261 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
262 self.write(cr, uid, new_id, {'tasks':[(6,0, map_task_id.values())]})
263 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
266 def copy(self, cr, uid, id, default={}, context=None):
270 default = default or {}
271 context['active_test'] = False
272 default['state'] = 'open'
273 default['tasks'] = []
274 proj = self.browse(cr, uid, id, context=context)
275 if not default.get('name', False):
276 default['name'] = proj.name + _(' (copy)')
278 res = super(project, self).copy(cr, uid, id, default, context)
279 self.map_tasks(cr,uid,id,res,context)
283 def template_copy(self, cr, uid, id, default={}, context=None):
284 proj = self.browse(cr, uid, id, context=context)
286 default['tasks'] = [] #avoid to copy all the task automaticly
287 res = self.copy(cr, uid, id, default=default, context=context)
289 self.map_tasks(cr,uid,id,res,context)
292 def duplicate_template(self, cr, uid, ids, context=None):
295 data_obj = self.pool.get('ir.model.data')
297 for proj in self.browse(cr, uid, ids, context=context):
298 parent_id = context.get('parent_id', False)
299 context.update({'analytic_project_copy': True})
300 new_date_start = time.strftime('%Y-%m-%d')
302 if proj.date_start and proj.date:
303 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
304 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
305 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
306 context.update({'copy':True})
307 new_id = self.template_copy(cr, uid, proj.id, default = {
308 'name': proj.name +_(' (copy)'),
310 'date_start':new_date_start,
312 'parent_id':parent_id}, context=context)
313 result.append(new_id)
315 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
316 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
318 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
320 if result and len(result):
322 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
323 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
324 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
325 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
326 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
327 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
329 'name': _('Projects'),
331 'view_mode': 'form,tree',
332 'res_model': 'project.project',
335 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
336 'type': 'ir.actions.act_window',
337 'search_view_id': search_view['res_id'],
341 # set active value for a project, its sub projects and its tasks
342 def setActive(self, cr, uid, ids, value=True, context=None):
343 task_obj = self.pool.get('project.task')
344 for proj in self.browse(cr, uid, ids, context=None):
345 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
346 cr.execute('select id from project_task where project_id=%s', (proj.id,))
347 tasks_id = [x[0] for x in cr.fetchall()]
349 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
350 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
352 self.setActive(cr, uid, child_ids, value, context=None)
355 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
356 context = context or {}
357 if type(ids) in (long, int,):
359 projects = self.browse(cr, uid, ids, context=context)
361 for project in projects:
362 if (not project.members) and force_members:
363 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
365 resource_pool = self.pool.get('resource.resource')
367 result = "from openerp.addons.resource.faces import *\n"
368 result += "import datetime\n"
369 for project in self.browse(cr, uid, ids, context=context):
370 u_ids = [i.id for i in project.members]
371 if project.user_id and (project.user_id.id not in u_ids):
372 u_ids.append(project.user_id.id)
373 for task in project.tasks:
374 if task.state in ('done','cancelled'):
376 if task.user_id and (task.user_id.id not in u_ids):
377 u_ids.append(task.user_id.id)
378 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
379 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
380 for key, vals in resource_objs.items():
382 class User_%s(Resource):
384 ''' % (key, vals.get('efficiency', False))
391 def _schedule_project(self, cr, uid, project, context=None):
392 resource_pool = self.pool.get('resource.resource')
393 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
394 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
395 # TODO: check if we need working_..., default values are ok.
396 puids = [x.id for x in project.members]
398 puids.append(project.user_id.id)
406 project.date_start, working_days,
407 '|'.join(['User_'+str(x) for x in puids])
409 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
416 #TODO: DO Resource allocation and compute availability
417 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
423 def schedule_tasks(self, cr, uid, ids, context=None):
424 context = context or {}
425 if type(ids) in (long, int,):
427 projects = self.browse(cr, uid, ids, context=context)
428 result = self._schedule_header(cr, uid, ids, False, context=context)
429 for project in projects:
430 result += self._schedule_project(cr, uid, project, context=context)
431 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
434 exec result in local_dict
435 projects_gantt = Task.BalancedProject(local_dict['Project'])
437 for project in projects:
438 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
439 for task in project.tasks:
440 if task.state in ('done','cancelled'):
443 p = getattr(project_gantt, 'Task_%d' % (task.id,))
445 self.pool.get('project.task').write(cr, uid, [task.id], {
446 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
447 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
449 if (not task.user_id) and (p.booked_resource):
450 self.pool.get('project.task').write(cr, uid, [task.id], {
451 'user_id': int(p.booked_resource[0].name[5:]),
456 class users(osv.osv):
457 _inherit = 'res.users'
459 'context_project_id': fields.many2one('project.project', 'Project')
464 _name = "project.task"
465 _description = "Task"
467 _date_name = "date_start"
470 def _resolve_project_id_from_context(self, cr, uid, context=None):
471 """Return ID of project based on the value of 'project_id'
472 context key, or None if it cannot be resolved to a single project.
474 if context is None: context = {}
475 if type(context.get('project_id')) in (int, long):
476 project_id = context['project_id']
478 if isinstance(context.get('project_id'), basestring):
479 project_name = context['project_id']
480 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name)
481 if len(project_ids) == 1:
482 return project_ids[0][0]
484 def _read_group_type_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
485 stage_obj = self.pool.get('project.task.type')
486 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
487 order = stage_obj._order
488 access_rights_uid = access_rights_uid or uid
489 if read_group_order == 'type_id desc':
490 # lame way to allow reverting search, should just work in the trivial case
491 order = '%s desc' % order
493 domain = ['|', ('id','in',ids), ('project_ids','in',project_id)]
495 domain = ['|', ('id','in',ids), ('project_default','=',1)]
496 stage_ids = stage_obj._search(cr, uid, domain, order=order, access_rights_uid=access_rights_uid, context=context)
497 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
498 # restore order of the search
499 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
502 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
503 res_users = self.pool.get('res.users')
504 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
505 access_rights_uid = access_rights_uid or uid
507 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
508 order = res_users._order
509 # lame way to allow reverting search, should just work in the trivial case
510 if read_group_order == 'user_id desc':
511 order = '%s desc' % order
512 # de-duplicate and apply search order
513 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
514 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
515 # restore order of the search
516 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
520 'type_id': _read_group_type_id,
521 'user_id': _read_group_user_id
525 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
526 obj_project = self.pool.get('project.project')
528 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
529 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
530 if id and isinstance(id, (long, int)):
531 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
532 args.append(('active', '=', False))
533 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
535 def _str_get(self, task, level=0, border='***', context=None):
536 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'+ \
537 border[0]+' '+(task.name or '')+'\n'+ \
538 (task.description or '')+'\n\n'
540 # Compute: effective_hours, total_hours, progress
541 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
543 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
544 hours = dict(cr.fetchall())
545 for task in self.browse(cr, uid, ids, context=context):
546 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)}
547 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
548 res[task.id]['progress'] = 0.0
549 if (task.remaining_hours + hours.get(task.id, 0.0)):
550 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
551 if task.state in ('done','cancelled'):
552 res[task.id]['progress'] = 100.0
556 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
557 if remaining and not planned:
558 return {'value':{'planned_hours': remaining}}
561 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
562 return {'value':{'remaining_hours': planned - effective}}
564 def onchange_project(self, cr, uid, id, project_id):
567 data = self.pool.get('project.project').browse(cr, uid, [project_id])
568 partner_id=data and data[0].partner_id
570 return {'value':{'partner_id':partner_id.id}}
573 def duplicate_task(self, cr, uid, map_ids, context=None):
574 for new in map_ids.values():
575 task = self.browse(cr, uid, new, context)
576 child_ids = [ ch.id for ch in task.child_ids]
578 for child in task.child_ids:
579 if child.id in map_ids.keys():
580 child_ids.remove(child.id)
581 child_ids.append(map_ids[child.id])
583 parent_ids = [ ch.id for ch in task.parent_ids]
585 for parent in task.parent_ids:
586 if parent.id in map_ids.keys():
587 parent_ids.remove(parent.id)
588 parent_ids.append(map_ids[parent.id])
589 #FIXME why there is already the copy and the old one
590 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
592 def copy_data(self, cr, uid, id, default={}, context=None):
593 default = default or {}
594 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
595 if not default.get('remaining_hours', False):
596 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
597 default['active'] = True
598 default['type_id'] = False
599 if not default.get('name', False):
600 default['name'] = self.browse(cr, uid, id, context=context).name or ''
601 if not context.get('copy',False):
602 new_name = _("%s (copy)")%default.get('name','')
603 default.update({'name':new_name})
604 return super(task, self).copy_data(cr, uid, id, default, context)
607 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
609 for task in self.browse(cr, uid, ids, context=context):
612 if task.project_id.active == False or task.project_id.state == 'template':
616 def _get_task(self, cr, uid, ids, context=None):
618 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
619 if work.task_id: result[work.task_id.id] = True
623 '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."),
624 'name': fields.char('Task Summary', size=128, required=True, select=True),
625 'description': fields.text('Description'),
626 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
627 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
628 'type_id': fields.many2one('project.task.type', 'Stage'),
629 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
630 help='If the task is created the state is \'Draft\'.\n If the task is started, the state becomes \'In Progress\'.\n If review is needed the task is in \'Pending\' state.\
631 \n If the task is over, the states is set to \'Done\'.'),
632 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
633 help="A task's kanban state indicates special situations affecting it:\n"
634 " * Normal is the default situation\n"
635 " * Blocked indicates something is preventing the progress of this task\n"
636 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
637 readonly=True, required=False),
638 'create_date': fields.datetime('Create Date', readonly=True,select=True),
639 'date_start': fields.datetime('Starting Date',select=True),
640 'date_end': fields.datetime('Ending Date',select=True),
641 'date_deadline': fields.date('Deadline',select=True),
642 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
643 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
644 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
645 'notes': fields.text('Notes'),
646 '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.'),
647 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
649 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
650 'project.task.work': (_get_task, ['hours'], 10),
652 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
653 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
655 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
656 'project.task.work': (_get_task, ['hours'], 10),
658 '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",
660 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
661 'project.task.work': (_get_task, ['hours'], 10),
663 '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.",
665 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
666 'project.task.work': (_get_task, ['hours'], 10),
668 'user_id': fields.many2one('res.users', 'Assigned to'),
669 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
670 'partner_id': fields.many2one('res.partner', 'Partner'),
671 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
672 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
673 'company_id': fields.many2one('res.company', 'Company'),
674 'id': fields.integer('ID', readonly=True),
675 'color': fields.integer('Color Index'),
676 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
681 'kanban_state': 'normal',
686 'user_id': lambda obj, cr, uid, context: uid,
687 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
690 _order = "priority, sequence, date_start, name, id"
692 def set_priority(self, cr, uid, ids, priority):
695 return self.write(cr, uid, ids, {'priority' : priority})
697 def set_high_priority(self, cr, uid, ids, *args):
698 """Set task priority to high
700 return self.set_priority(cr, uid, ids, '1')
702 def set_normal_priority(self, cr, uid, ids, *args):
703 """Set task priority to normal
705 return self.set_priority(cr, uid, ids, '2')
707 def _check_recursion(self, cr, uid, ids, context=None):
709 visited_branch = set()
711 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
717 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
718 if id in visited_branch: #Cycle
721 if id in visited_node: #Already tested don't work one more time for nothing
724 visited_branch.add(id)
727 #visit child using DFS
728 task = self.browse(cr, uid, id, context=context)
729 for child in task.child_ids:
730 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
734 visited_branch.remove(id)
737 def _check_dates(self, cr, uid, ids, context=None):
740 obj_task = self.browse(cr, uid, ids[0], context=context)
741 start = obj_task.date_start or False
742 end = obj_task.date_end or False
749 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
750 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
753 # Override view according to the company definition
755 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
756 users_obj = self.pool.get('res.users')
758 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
759 # this should be safe (no context passed to avoid side-effects)
760 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
761 tm = obj_tm and obj_tm.name or 'Hours'
763 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
765 if tm in ['Hours','Hour']:
768 eview = etree.fromstring(res['arch'])
770 def _check_rec(eview):
771 if eview.attrib.get('widget','') == 'float_time':
772 eview.set('widget','float')
779 res['arch'] = etree.tostring(eview)
781 for f in res['fields']:
782 if 'Hours' in res['fields'][f]['string']:
783 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
786 def _check_child_task(self, cr, uid, ids, context=None):
789 tasks = self.browse(cr, uid, ids, context=context)
792 for child in task.child_ids:
793 if child.state in ['draft', 'open', 'pending']:
794 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
797 def action_close(self, cr, uid, ids, context=None):
798 # This action open wizard to send email to partner or project manager after close task.
801 task_id = len(ids) and ids[0] or False
802 self._check_child_task(cr, uid, ids, context=context)
803 if not task_id: return False
804 task = self.browse(cr, uid, task_id, context=context)
805 project = task.project_id
806 res = self.do_close(cr, uid, [task_id], context=context)
807 if project.warn_manager or project.warn_customer:
809 'name': _('Send Email after close task'),
812 'res_model': 'mail.compose.message',
813 'type': 'ir.actions.act_window',
816 'context': {'active_id': task.id,
817 'active_model': 'project.task'}
821 def do_close(self, cr, uid, ids, context={}):
825 request = self.pool.get('res.request')
826 if not isinstance(ids,list): ids = [ids]
827 for task in self.browse(cr, uid, ids, context=context):
829 project = task.project_id
831 # Send request to project manager
832 if project.warn_manager and project.user_id and (project.user_id.id != uid):
833 request.create(cr, uid, {
834 'name': _("Task '%s' closed") % task.name,
837 'act_to': project.user_id.id,
838 'ref_partner_id': task.partner_id.id,
839 'ref_doc1': 'project.task,%d'% (task.id,),
840 'ref_doc2': 'project.project,%d'% (project.id,),
843 for parent_id in task.parent_ids:
844 if parent_id.state in ('pending','draft'):
846 for child in parent_id.child_ids:
847 if child.id != task.id and child.state not in ('done','cancelled'):
850 self.do_reopen(cr, uid, [parent_id.id], context=context)
851 vals.update({'state': 'done'})
852 vals.update({'remaining_hours': 0.0})
853 if not task.date_end:
854 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
855 self.write(cr, uid, [task.id],vals, context=context)
856 message = _("The task '%s' is done") % (task.name,)
857 self.log(cr, uid, task.id, message)
860 def do_reopen(self, cr, uid, ids, context=None):
861 request = self.pool.get('res.request')
863 for task in self.browse(cr, uid, ids, context=context):
864 project = task.project_id
865 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
866 request.create(cr, uid, {
867 'name': _("Task '%s' set in progress") % task.name,
870 'act_to': project.user_id.id,
871 'ref_partner_id': task.partner_id.id,
872 'ref_doc1': 'project.task,%d' % task.id,
873 'ref_doc2': 'project.project,%d' % project.id,
876 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
879 def do_cancel(self, cr, uid, ids, context={}):
880 request = self.pool.get('res.request')
881 tasks = self.browse(cr, uid, ids, context=context)
882 self._check_child_task(cr, uid, ids, context=context)
884 project = task.project_id
885 if project.warn_manager and project.user_id and (project.user_id.id != uid):
886 request.create(cr, uid, {
887 'name': _("Task '%s' cancelled") % task.name,
890 'act_to': project.user_id.id,
891 'ref_partner_id': task.partner_id.id,
892 'ref_doc1': 'project.task,%d' % task.id,
893 'ref_doc2': 'project.project,%d' % project.id,
895 message = _("The task '%s' is cancelled.") % (task.name,)
896 self.log(cr, uid, task.id, message)
897 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
900 def do_open(self, cr, uid, ids, context={}):
901 if not isinstance(ids,list): ids = [ids]
902 tasks= self.browse(cr, uid, ids, context=context)
904 data = {'state': 'open'}
906 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
907 self.write(cr, uid, [t.id], data, context=context)
908 message = _("The task '%s' is opened.") % (t.name,)
909 self.log(cr, uid, t.id, message)
912 def do_draft(self, cr, uid, ids, context={}):
913 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
917 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
918 attachment = self.pool.get('ir.attachment')
919 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
920 new_attachment_ids = []
921 for attachment_id in attachment_ids:
922 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
923 return new_attachment_ids
926 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
928 Delegate Task to another users.
930 assert delegate_data['user_id'], _("Delegated User should be specified")
932 for task in self.browse(cr, uid, ids, context=context):
933 delegated_task_id = self.copy(cr, uid, task.id, {
934 'name': delegate_data['name'],
935 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
936 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
937 'planned_hours': delegate_data['planned_hours'] or 0.0,
938 'parent_ids': [(6, 0, [task.id])],
940 'description': delegate_data['new_task_description'] or '',
944 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
945 newname = delegate_data['prefix'] or ''
947 'remaining_hours': delegate_data['planned_hours_me'],
948 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
951 if delegate_data['state'] == 'pending':
952 self.do_pending(cr, uid, task.id, context=context)
953 elif delegate_data['state'] == 'done':
954 self.do_close(cr, uid, task.id, context=context)
956 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
957 self.log(cr, uid, task.id, message)
958 delegated_tasks[task.id] = delegated_task_id
959 return delegated_tasks
961 def do_pending(self, cr, uid, ids, context={}):
962 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
963 for (id, name) in self.name_get(cr, uid, ids):
964 message = _("The task '%s' is pending.") % name
965 self.log(cr, uid, id, message)
968 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
969 for task in self.browse(cr, uid, ids, context=context):
970 if (task.state=='draft') or (task.planned_hours==0.0):
971 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
972 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
975 def set_remaining_time_1(self, cr, uid, ids, context=None):
976 return self.set_remaining_time(cr, uid, ids, 1.0, context)
978 def set_remaining_time_2(self, cr, uid, ids, context=None):
979 return self.set_remaining_time(cr, uid, ids, 2.0, context)
981 def set_remaining_time_5(self, cr, uid, ids, context=None):
982 return self.set_remaining_time(cr, uid, ids, 5.0, context)
984 def set_remaining_time_10(self, cr, uid, ids, context=None):
985 return self.set_remaining_time(cr, uid, ids, 10.0, context)
987 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
988 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
990 def set_kanban_state_normal(self, cr, uid, ids, context=None):
991 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
993 def set_kanban_state_done(self, cr, uid, ids, context=None):
994 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
996 def _change_type(self, cr, uid, ids, next, *args):
999 if next is False, go to previous stage
1001 for task in self.browse(cr, uid, ids):
1002 if task.project_id.type_ids:
1003 typeid = task.type_id.id
1005 for type in task.project_id.type_ids :
1006 types_seq[type.id] = type.sequence
1008 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
1010 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
1011 sorted_types = [x[0] for x in types]
1013 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
1014 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
1015 index = sorted_types.index(typeid)
1016 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
1019 def next_type(self, cr, uid, ids, *args):
1020 return self._change_type(cr, uid, ids, True, *args)
1022 def prev_type(self, cr, uid, ids, *args):
1023 return self._change_type(cr, uid, ids, False, *args)
1025 def _store_history(self, cr, uid, ids, context=None):
1026 for task in self.browse(cr, uid, ids, context=context):
1027 self.pool.get('project.task.history').create(cr, uid, {
1029 'remaining_hours': task.remaining_hours,
1030 'planned_hours': task.planned_hours,
1031 'kanban_state': task.kanban_state,
1032 'type_id': task.type_id.id,
1033 'state': task.state,
1034 'user_id': task.user_id.id
1039 def create(self, cr, uid, vals, context=None):
1040 result = super(task, self).create(cr, uid, vals, context=context)
1041 self._store_history(cr, uid, [result], context=context)
1044 # Overridden to reset the kanban_state to normal whenever
1045 # the stage (type_id) of the task changes.
1046 def write(self, cr, uid, ids, vals, context=None):
1047 if isinstance(ids, (int, long)):
1049 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1050 new_stage = vals.get('type_id')
1051 vals_reset_kstate = dict(vals, kanban_state='normal')
1052 for t in self.browse(cr, uid, ids, context=context):
1053 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1054 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1057 result = super(task,self).write(cr, uid, ids, vals, context=context)
1058 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1059 self._store_history(cr, uid, ids, context=context)
1062 def unlink(self, cr, uid, ids, context=None):
1065 self._check_child_task(cr, uid, ids, context=context)
1066 res = super(task, self).unlink(cr, uid, ids, context)
1069 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1070 context = context or {}
1074 if task.state in ('done','cancelled'):
1079 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1081 for t2 in task.parent_ids:
1082 start.append("up.Task_%s.end" % (t2.id,))
1086 ''' % (ident,','.join(start))
1091 ''' % (ident, 'User_'+str(task.user_id.id))
1098 class project_work(osv.osv):
1099 _name = "project.task.work"
1100 _description = "Project Task Work"
1102 'name': fields.char('Work summary', size=128),
1103 'date': fields.datetime('Date', select="1"),
1104 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1105 'hours': fields.float('Time Spent'),
1106 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1107 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1111 'user_id': lambda obj, cr, uid, context: uid,
1112 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1115 _order = "date desc"
1116 def create(self, cr, uid, vals, *args, **kwargs):
1117 if 'hours' in vals and (not vals['hours']):
1118 vals['hours'] = 0.00
1119 if 'task_id' in vals:
1120 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1121 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1123 def write(self, cr, uid, ids, vals, context=None):
1124 if 'hours' in vals and (not vals['hours']):
1125 vals['hours'] = 0.00
1127 for work in self.browse(cr, uid, ids, context=context):
1128 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))
1129 return super(project_work,self).write(cr, uid, ids, vals, context)
1131 def unlink(self, cr, uid, ids, *args, **kwargs):
1132 for work in self.browse(cr, uid, ids):
1133 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1134 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1137 class account_analytic_account(osv.osv):
1139 _inherit = 'account.analytic.account'
1140 _description = 'Analytic Account'
1142 def create(self, cr, uid, vals, context=None):
1145 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1146 vals['child_ids'] = []
1147 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1149 def unlink(self, cr, uid, ids, *args, **kwargs):
1150 project_obj = self.pool.get('project.project')
1151 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1153 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1154 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1156 account_analytic_account()
1159 # Tasks History, used for cumulative flow charts (Lean/Agile)
1162 class project_task_history(osv.osv):
1163 _name = 'project.task.history'
1164 _description = 'History of Tasks'
1165 _rec_name = 'task_id'
1167 def _get_date(self, cr, uid, ids, name, arg, context=None):
1169 for history in self.browse(cr, uid, ids, context=context):
1170 if history.state in ('done','cancelled'):
1171 result[history.id] = history.date
1173 cr.execute('''select
1176 project_task_history
1180 order by id limit 1''', (history.task_id.id, history.id))
1182 result[history.id] = res and res[0] or False
1185 def _get_related_date(self, cr, uid, ids, context=None):
1187 for history in self.browse(cr, uid, ids, context=context):
1188 cr.execute('''select
1191 project_task_history
1195 order by id desc limit 1''', (history.task_id.id, history.id))
1198 result.append(res[0])
1202 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1203 'type_id': fields.many2one('project.task.type', 'Stage'),
1204 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1205 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1206 'date': fields.date('Date', select=True),
1207 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1208 'project.task.history': (_get_related_date, None, 20)
1210 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1211 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1212 'user_id': fields.many2one('res.users', 'Responsible'),
1215 'date': fields.date.context_today,
1217 project_task_history()
1219 class project_task_history_cumulative(osv.osv):
1220 _name = 'project.task.history.cumulative'
1221 _table = 'project_task_history_cumulative'
1222 _inherit = 'project.task.history'
1225 'end_date': fields.date('End Date'),
1226 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1229 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1231 history.date::varchar||'-'||history.history_id::varchar as id,
1232 history.date as end_date,
1237 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1238 task_id, type_id, user_id, kanban_state, state,
1239 remaining_hours, planned_hours
1241 project_task_history
1245 project_task_history_cumulative()