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 copy(self, cr, uid, id, default={}, context=None):
259 default = default or {}
260 context['active_test'] = False
261 default['state'] = 'open'
262 proj = self.browse(cr, uid, id, context=context)
263 if not default.get('name', False):
264 default['name'] = proj.name + _(' (copy)')
266 res = super(project, self).copy(cr, uid, id, default, context)
270 def template_copy(self, cr, uid, id, default={}, context=None):
271 task_obj = self.pool.get('project.task')
272 proj = self.browse(cr, uid, id, context=context)
274 default['tasks'] = [] #avoid to copy all the task automaticly
275 res = self.copy(cr, uid, id, default=default, context=context)
277 #copy all the task manually
279 for task in proj.tasks:
280 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
282 self.write(cr, uid, res, {'tasks':[(6,0, map_task_id.values())]})
283 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
287 def duplicate_template(self, cr, uid, ids, context=None):
290 data_obj = self.pool.get('ir.model.data')
292 for proj in self.browse(cr, uid, ids, context=context):
293 parent_id = context.get('parent_id', False)
294 context.update({'analytic_project_copy': True})
295 new_date_start = time.strftime('%Y-%m-%d')
297 if proj.date_start and proj.date:
298 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
299 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
300 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
301 context.update({'copy':True})
302 new_id = self.template_copy(cr, uid, proj.id, default = {
303 'name': proj.name +_(' (copy)'),
305 'date_start':new_date_start,
307 'parent_id':parent_id}, context=context)
308 result.append(new_id)
310 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
311 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
313 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
315 if result and len(result):
317 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
318 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
319 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
320 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
321 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
322 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
324 'name': _('Projects'),
326 'view_mode': 'form,tree',
327 'res_model': 'project.project',
330 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
331 'type': 'ir.actions.act_window',
332 'search_view_id': search_view['res_id'],
336 # set active value for a project, its sub projects and its tasks
337 def setActive(self, cr, uid, ids, value=True, context=None):
338 task_obj = self.pool.get('project.task')
339 for proj in self.browse(cr, uid, ids, context=None):
340 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
341 cr.execute('select id from project_task where project_id=%s', (proj.id,))
342 tasks_id = [x[0] for x in cr.fetchall()]
344 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
345 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
347 self.setActive(cr, uid, child_ids, value, context=None)
350 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
351 context = context or {}
352 if type(ids) in (long, int,):
354 projects = self.browse(cr, uid, ids, context=context)
356 for project in projects:
357 if (not project.members) and force_members:
358 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
360 resource_pool = self.pool.get('resource.resource')
362 result = "from openerp.addons.resource.faces import *\n"
363 result += "import datetime\n"
364 for project in self.browse(cr, uid, ids, context=context):
365 u_ids = [i.id for i in project.members]
366 if project.user_id and (project.user_id.id not in u_ids):
367 u_ids.append(project.user_id.id)
368 for task in project.tasks:
369 if task.state in ('done','cancelled'):
371 if task.user_id and (task.user_id.id not in u_ids):
372 u_ids.append(task.user_id.id)
373 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
374 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
375 for key, vals in resource_objs.items():
377 class User_%s(Resource):
379 ''' % (key, vals.get('efficiency', False))
386 def _schedule_project(self, cr, uid, project, context=None):
387 resource_pool = self.pool.get('resource.resource')
388 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
389 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
390 # TODO: check if we need working_..., default values are ok.
391 puids = [x.id for x in project.members]
393 puids.append(project.user_id.id)
401 project.date_start, working_days,
402 '|'.join(['User_'+str(x) for x in puids])
404 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
411 #TODO: DO Resource allocation and compute availability
412 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
418 def schedule_tasks(self, cr, uid, ids, context=None):
419 context = context or {}
420 if type(ids) in (long, int,):
422 projects = self.browse(cr, uid, ids, context=context)
423 result = self._schedule_header(cr, uid, ids, False, context=context)
424 for project in projects:
425 result += self._schedule_project(cr, uid, project, context=context)
426 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
429 exec result in local_dict
430 projects_gantt = Task.BalancedProject(local_dict['Project'])
432 for project in projects:
433 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
434 for task in project.tasks:
435 if task.state in ('done','cancelled'):
438 p = getattr(project_gantt, 'Task_%d' % (task.id,))
440 self.pool.get('project.task').write(cr, uid, [task.id], {
441 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
442 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
444 if (not task.user_id) and (p.booked_resource):
445 self.pool.get('project.task').write(cr, uid, [task.id], {
446 'user_id': int(p.booked_resource[0].name[5:]),
451 class users(osv.osv):
452 _inherit = 'res.users'
454 'context_project_id': fields.many2one('project.project', 'Project')
459 _name = "project.task"
460 _description = "Task"
462 _date_name = "date_start"
465 def _resolve_project_id_from_context(self, cr, uid, context=None):
466 """Return ID of project based on the value of 'project_id'
467 context key, or None if it cannot be resolved to a single project.
469 if context is None: context = {}
470 if type(context.get('project_id')) in (int, long):
471 project_id = context['project_id']
473 if isinstance(context.get('project_id'), basestring):
474 project_name = context['project_id']
475 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name)
476 if len(project_ids) == 1:
477 return project_ids[0][0]
479 def _read_group_type_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
480 stage_obj = self.pool.get('project.task.type')
481 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
482 order = stage_obj._order
483 access_rights_uid = access_rights_uid or uid
484 if read_group_order == 'type_id desc':
485 # lame way to allow reverting search, should just work in the trivial case
486 order = '%s desc' % order
488 domain = ['|', ('id','in',ids), ('project_ids','in',project_id)]
490 domain = ['|', ('id','in',ids), ('project_default','=',1)]
491 stage_ids = stage_obj._search(cr, uid, domain, order=order, access_rights_uid=access_rights_uid, context=context)
492 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
493 # restore order of the search
494 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
497 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
498 res_users = self.pool.get('res.users')
499 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
500 access_rights_uid = access_rights_uid or uid
502 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
503 order = res_users._order
504 # lame way to allow reverting search, should just work in the trivial case
505 if read_group_order == 'user_id desc':
506 order = '%s desc' % order
507 # de-duplicate and apply search order
508 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
509 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
510 # restore order of the search
511 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
515 'type_id': _read_group_type_id,
516 'user_id': _read_group_user_id
520 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
521 obj_project = self.pool.get('project.project')
523 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
524 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
525 if id and isinstance(id, (long, int)):
526 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
527 args.append(('active', '=', False))
528 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
530 def _str_get(self, task, level=0, border='***', context=None):
531 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'+ \
532 border[0]+' '+(task.name or '')+'\n'+ \
533 (task.description or '')+'\n\n'
535 # Compute: effective_hours, total_hours, progress
536 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
538 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
539 hours = dict(cr.fetchall())
540 for task in self.browse(cr, uid, ids, context=context):
541 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)}
542 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
543 res[task.id]['progress'] = 0.0
544 if (task.remaining_hours + hours.get(task.id, 0.0)):
545 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
546 if task.state in ('done','cancelled'):
547 res[task.id]['progress'] = 100.0
551 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
552 if remaining and not planned:
553 return {'value':{'planned_hours': remaining}}
556 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
557 return {'value':{'remaining_hours': planned - effective}}
559 def onchange_project(self, cr, uid, id, project_id):
562 data = self.pool.get('project.project').browse(cr, uid, [project_id])
563 partner_id=data and data[0].partner_id
565 return {'value':{'partner_id':partner_id.id}}
568 def duplicate_task(self, cr, uid, map_ids, context=None):
569 for new in map_ids.values():
570 task = self.browse(cr, uid, new, context)
571 child_ids = [ ch.id for ch in task.child_ids]
573 for child in task.child_ids:
574 if child.id in map_ids.keys():
575 child_ids.remove(child.id)
576 child_ids.append(map_ids[child.id])
578 parent_ids = [ ch.id for ch in task.parent_ids]
580 for parent in task.parent_ids:
581 if parent.id in map_ids.keys():
582 parent_ids.remove(parent.id)
583 parent_ids.append(map_ids[parent.id])
584 #FIXME why there is already the copy and the old one
585 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
587 def copy_data(self, cr, uid, id, default={}, context=None):
588 default = default or {}
589 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
590 if not default.get('remaining_hours', False):
591 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
592 default['active'] = True
593 default['type_id'] = False
594 if not default.get('name', False):
595 default['name'] = self.browse(cr, uid, id, context=context).name or ''
596 if not context.get('copy',False):
597 new_name = _("%s (copy)")%default.get('name','')
598 default.update({'name':new_name})
599 return super(task, self).copy_data(cr, uid, id, default, context)
602 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
604 for task in self.browse(cr, uid, ids, context=context):
607 if task.project_id.active == False or task.project_id.state == 'template':
611 def _get_task(self, cr, uid, ids, context=None):
613 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
614 if work.task_id: result[work.task_id.id] = True
618 '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."),
619 'name': fields.char('Task Summary', size=128, required=True, select=True),
620 'description': fields.text('Description'),
621 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
622 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
623 'type_id': fields.many2one('project.task.type', 'Stage'),
624 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
625 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.\
626 \n If the task is over, the states is set to \'Done\'.'),
627 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
628 help="A task's kanban state indicates special situations affecting it:\n"
629 " * Normal is the default situation\n"
630 " * Blocked indicates something is preventing the progress of this task\n"
631 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
632 readonly=True, required=False),
633 'create_date': fields.datetime('Create Date', readonly=True,select=True),
634 'date_start': fields.datetime('Starting Date',select=True),
635 'date_end': fields.datetime('Ending Date',select=True),
636 'date_deadline': fields.date('Deadline',select=True),
637 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
638 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
639 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
640 'notes': fields.text('Notes'),
641 '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.'),
642 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
644 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
645 'project.task.work': (_get_task, ['hours'], 10),
647 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
648 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
650 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
651 'project.task.work': (_get_task, ['hours'], 10),
653 '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",
655 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
656 'project.task.work': (_get_task, ['hours'], 10),
658 '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.",
660 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
661 'project.task.work': (_get_task, ['hours'], 10),
663 'user_id': fields.many2one('res.users', 'Assigned to'),
664 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
665 'partner_id': fields.many2one('res.partner', 'Partner'),
666 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
667 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
668 'company_id': fields.many2one('res.company', 'Company'),
669 'id': fields.integer('ID', readonly=True),
670 'color': fields.integer('Color Index'),
671 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
676 'kanban_state': 'normal',
681 'user_id': lambda obj, cr, uid, context: uid,
682 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
685 _order = "priority, sequence, date_start, name, id"
687 def set_priority(self, cr, uid, ids, priority):
690 return self.write(cr, uid, ids, {'priority' : priority})
692 def set_high_priority(self, cr, uid, ids, *args):
693 """Set task priority to high
695 return self.set_priority(cr, uid, ids, '1')
697 def set_normal_priority(self, cr, uid, ids, *args):
698 """Set task priority to normal
700 return self.set_priority(cr, uid, ids, '2')
702 def _check_recursion(self, cr, uid, ids, context=None):
704 visited_branch = set()
706 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
712 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
713 if id in visited_branch: #Cycle
716 if id in visited_node: #Already tested don't work one more time for nothing
719 visited_branch.add(id)
722 #visit child using DFS
723 task = self.browse(cr, uid, id, context=context)
724 for child in task.child_ids:
725 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
729 visited_branch.remove(id)
732 def _check_dates(self, cr, uid, ids, context=None):
735 obj_task = self.browse(cr, uid, ids[0], context=context)
736 start = obj_task.date_start or False
737 end = obj_task.date_end or False
744 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
745 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
748 # Override view according to the company definition
750 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
751 users_obj = self.pool.get('res.users')
753 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
754 # this should be safe (no context passed to avoid side-effects)
755 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
756 tm = obj_tm and obj_tm.name or 'Hours'
758 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
760 if tm in ['Hours','Hour']:
763 eview = etree.fromstring(res['arch'])
765 def _check_rec(eview):
766 if eview.attrib.get('widget','') == 'float_time':
767 eview.set('widget','float')
774 res['arch'] = etree.tostring(eview)
776 for f in res['fields']:
777 if 'Hours' in res['fields'][f]['string']:
778 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
781 def _check_child_task(self, cr, uid, ids, context=None):
784 tasks = self.browse(cr, uid, ids, context=context)
787 for child in task.child_ids:
788 if child.state in ['draft', 'open', 'pending']:
789 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
792 def action_close(self, cr, uid, ids, context=None):
793 # This action open wizard to send email to partner or project manager after close task.
796 task_id = len(ids) and ids[0] or False
797 self._check_child_task(cr, uid, ids, context=context)
798 if not task_id: return False
799 task = self.browse(cr, uid, task_id, context=context)
800 project = task.project_id
801 res = self.do_close(cr, uid, [task_id], context=context)
802 if project.warn_manager or project.warn_customer:
804 'name': _('Send Email after close task'),
807 'res_model': 'mail.compose.message',
808 'type': 'ir.actions.act_window',
811 'context': {'active_id': task.id,
812 'active_model': 'project.task'}
816 def do_close(self, cr, uid, ids, context={}):
820 request = self.pool.get('res.request')
821 if not isinstance(ids,list): ids = [ids]
822 for task in self.browse(cr, uid, ids, context=context):
824 project = task.project_id
826 # Send request to project manager
827 if project.warn_manager and project.user_id and (project.user_id.id != uid):
828 request.create(cr, uid, {
829 'name': _("Task '%s' closed") % task.name,
832 'act_to': project.user_id.id,
833 'ref_partner_id': task.partner_id.id,
834 'ref_doc1': 'project.task,%d'% (task.id,),
835 'ref_doc2': 'project.project,%d'% (project.id,),
838 for parent_id in task.parent_ids:
839 if parent_id.state in ('pending','draft'):
841 for child in parent_id.child_ids:
842 if child.id != task.id and child.state not in ('done','cancelled'):
845 self.do_reopen(cr, uid, [parent_id.id], context=context)
846 vals.update({'state': 'done'})
847 vals.update({'remaining_hours': 0.0})
848 if not task.date_end:
849 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
850 self.write(cr, uid, [task.id],vals, context=context)
851 message = _("The task '%s' is done") % (task.name,)
852 self.log(cr, uid, task.id, message)
855 def do_reopen(self, cr, uid, ids, context=None):
856 request = self.pool.get('res.request')
858 for task in self.browse(cr, uid, ids, context=context):
859 project = task.project_id
860 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
861 request.create(cr, uid, {
862 'name': _("Task '%s' set in progress") % task.name,
865 'act_to': project.user_id.id,
866 'ref_partner_id': task.partner_id.id,
867 'ref_doc1': 'project.task,%d' % task.id,
868 'ref_doc2': 'project.project,%d' % project.id,
871 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
874 def do_cancel(self, cr, uid, ids, context={}):
875 request = self.pool.get('res.request')
876 tasks = self.browse(cr, uid, ids, context=context)
877 self._check_child_task(cr, uid, ids, context=context)
879 project = task.project_id
880 if project.warn_manager and project.user_id and (project.user_id.id != uid):
881 request.create(cr, uid, {
882 'name': _("Task '%s' cancelled") % task.name,
885 'act_to': project.user_id.id,
886 'ref_partner_id': task.partner_id.id,
887 'ref_doc1': 'project.task,%d' % task.id,
888 'ref_doc2': 'project.project,%d' % project.id,
890 message = _("The task '%s' is cancelled.") % (task.name,)
891 self.log(cr, uid, task.id, message)
892 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
895 def do_open(self, cr, uid, ids, context={}):
896 if not isinstance(ids,list): ids = [ids]
897 tasks= self.browse(cr, uid, ids, context=context)
899 data = {'state': 'open'}
901 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
902 self.write(cr, uid, [t.id], data, context=context)
903 message = _("The task '%s' is opened.") % (t.name,)
904 self.log(cr, uid, t.id, message)
907 def do_draft(self, cr, uid, ids, context={}):
908 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
912 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
913 attachment = self.pool.get('ir.attachment')
914 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
915 new_attachment_ids = []
916 for attachment_id in attachment_ids:
917 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
918 return new_attachment_ids
921 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
923 Delegate Task to another users.
925 assert delegate_data['user_id'], _("Delegated User should be specified")
927 for task in self.browse(cr, uid, ids, context=context):
928 delegated_task_id = self.copy(cr, uid, task.id, {
929 'name': delegate_data['name'],
930 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
931 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
932 'planned_hours': delegate_data['planned_hours'] or 0.0,
933 'parent_ids': [(6, 0, [task.id])],
935 'description': delegate_data['new_task_description'] or '',
939 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
940 newname = delegate_data['prefix'] or ''
942 'remaining_hours': delegate_data['planned_hours_me'],
943 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
946 if delegate_data['state'] == 'pending':
947 self.do_pending(cr, uid, task.id, context=context)
948 elif delegate_data['state'] == 'done':
949 self.do_close(cr, uid, task.id, context=context)
951 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
952 self.log(cr, uid, task.id, message)
953 delegated_tasks[task.id] = delegated_task_id
954 return delegated_tasks
956 def do_pending(self, cr, uid, ids, context={}):
957 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
958 for (id, name) in self.name_get(cr, uid, ids):
959 message = _("The task '%s' is pending.") % name
960 self.log(cr, uid, id, message)
963 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
964 for task in self.browse(cr, uid, ids, context=context):
965 if (task.state=='draft') or (task.planned_hours==0.0):
966 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
967 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
970 def set_remaining_time_1(self, cr, uid, ids, context=None):
971 return self.set_remaining_time(cr, uid, ids, 1.0, context)
973 def set_remaining_time_2(self, cr, uid, ids, context=None):
974 return self.set_remaining_time(cr, uid, ids, 2.0, context)
976 def set_remaining_time_5(self, cr, uid, ids, context=None):
977 return self.set_remaining_time(cr, uid, ids, 5.0, context)
979 def set_remaining_time_10(self, cr, uid, ids, context=None):
980 return self.set_remaining_time(cr, uid, ids, 10.0, context)
982 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
983 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
985 def set_kanban_state_normal(self, cr, uid, ids, context=None):
986 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
988 def set_kanban_state_done(self, cr, uid, ids, context=None):
989 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
991 def _change_type(self, cr, uid, ids, next, *args):
994 if next is False, go to previous stage
996 for task in self.browse(cr, uid, ids):
997 if task.project_id.type_ids:
998 typeid = task.type_id.id
1000 for type in task.project_id.type_ids :
1001 types_seq[type.id] = type.sequence
1003 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
1005 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
1006 sorted_types = [x[0] for x in types]
1008 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
1009 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
1010 index = sorted_types.index(typeid)
1011 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
1014 def next_type(self, cr, uid, ids, *args):
1015 return self._change_type(cr, uid, ids, True, *args)
1017 def prev_type(self, cr, uid, ids, *args):
1018 return self._change_type(cr, uid, ids, False, *args)
1020 def _store_history(self, cr, uid, ids, context=None):
1021 for task in self.browse(cr, uid, ids, context=context):
1022 self.pool.get('project.task.history').create(cr, uid, {
1024 'remaining_hours': task.remaining_hours,
1025 'planned_hours': task.planned_hours,
1026 'kanban_state': task.kanban_state,
1027 'type_id': task.type_id.id,
1028 'state': task.state,
1029 'user_id': task.user_id.id
1034 def create(self, cr, uid, vals, context=None):
1035 result = super(task, self).create(cr, uid, vals, context=context)
1036 self._store_history(cr, uid, [result], context=context)
1039 # Overridden to reset the kanban_state to normal whenever
1040 # the stage (type_id) of the task changes.
1041 def write(self, cr, uid, ids, vals, context=None):
1042 if isinstance(ids, (int, long)):
1044 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1045 new_stage = vals.get('type_id')
1046 vals_reset_kstate = dict(vals, kanban_state='normal')
1047 for t in self.browse(cr, uid, ids, context=context):
1048 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1049 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1052 result = super(task,self).write(cr, uid, ids, vals, context=context)
1053 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1054 self._store_history(cr, uid, ids, context=context)
1057 def unlink(self, cr, uid, ids, context=None):
1060 self._check_child_task(cr, uid, ids, context=context)
1061 res = super(task, self).unlink(cr, uid, ids, context)
1064 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1065 context = context or {}
1069 if task.state in ('done','cancelled'):
1074 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1076 for t2 in task.parent_ids:
1077 start.append("up.Task_%s.end" % (t2.id,))
1081 ''' % (ident,','.join(start))
1086 ''' % (ident, 'User_'+str(task.user_id.id))
1093 class project_work(osv.osv):
1094 _name = "project.task.work"
1095 _description = "Project Task Work"
1097 'name': fields.char('Work summary', size=128),
1098 'date': fields.datetime('Date', select="1"),
1099 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1100 'hours': fields.float('Time Spent'),
1101 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1102 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1106 'user_id': lambda obj, cr, uid, context: uid,
1107 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1110 _order = "date desc"
1111 def create(self, cr, uid, vals, *args, **kwargs):
1112 if 'hours' in vals and (not vals['hours']):
1113 vals['hours'] = 0.00
1114 if 'task_id' in vals:
1115 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1116 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1118 def write(self, cr, uid, ids, vals, context=None):
1119 if 'hours' in vals and (not vals['hours']):
1120 vals['hours'] = 0.00
1122 for work in self.browse(cr, uid, ids, context=context):
1123 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))
1124 return super(project_work,self).write(cr, uid, ids, vals, context)
1126 def unlink(self, cr, uid, ids, *args, **kwargs):
1127 for work in self.browse(cr, uid, ids):
1128 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1129 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1132 class account_analytic_account(osv.osv):
1134 _inherit = 'account.analytic.account'
1135 _description = 'Analytic Account'
1137 def create(self, cr, uid, vals, context=None):
1140 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1141 vals['child_ids'] = []
1142 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1144 def unlink(self, cr, uid, ids, *args, **kwargs):
1145 project_obj = self.pool.get('project.project')
1146 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1148 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1149 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1151 account_analytic_account()
1154 # Tasks History, used for cumulative flow charts (Lean/Agile)
1157 class project_task_history(osv.osv):
1158 _name = 'project.task.history'
1159 _description = 'History of Tasks'
1160 _rec_name = 'task_id'
1162 def _get_date(self, cr, uid, ids, name, arg, context=None):
1164 for history in self.browse(cr, uid, ids, context=context):
1165 if history.state in ('done','cancelled'):
1166 result[history.id] = history.date
1168 cr.execute('''select
1171 project_task_history
1175 order by id limit 1''', (history.task_id.id, history.id))
1177 result[history.id] = res and res[0] or False
1180 def _get_related_date(self, cr, uid, ids, context=None):
1182 for history in self.browse(cr, uid, ids, context=context):
1183 cr.execute('''select
1186 project_task_history
1190 order by id desc limit 1''', (history.task_id.id, history.id))
1193 result.append(res[0])
1197 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1198 'type_id': fields.many2one('project.task.type', 'Stage'),
1199 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1200 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1201 'date': fields.date('Date', select=True),
1202 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1203 'project.task.history': (_get_related_date, None, 20)
1205 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1206 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1207 'user_id': fields.many2one('res.users', 'Responsible'),
1210 'date': fields.date.context_today,
1212 project_task_history()
1214 class project_task_history_cumulative(osv.osv):
1215 _name = 'project.task.history.cumulative'
1216 _table = 'project_task_history_cumulative'
1217 _inherit = 'project.task.history'
1220 'end_date': fields.date('End Date'),
1221 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1224 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1226 history.date::varchar||'-'||history.history_id::varchar as id,
1227 history.date as end_date,
1232 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1233 task_id, type_id, user_id, kanban_state, state,
1234 remaining_hours, planned_hours
1236 project_task_history
1240 project_task_history_cumulative()