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')
80 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
81 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
82 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
83 val['pricelist_id'] = pricelist_id
86 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
87 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
88 project_ids = [task.project_id.id for task in tasks if task.project_id]
89 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
91 def _get_project_and_parents(self, cr, uid, ids, context=None):
92 """ return the project ids and all their parent projects """
96 SELECT DISTINCT parent.id
97 FROM project_project project, project_project parent, account_analytic_account account
98 WHERE project.analytic_account_id = account.id
99 AND parent.analytic_account_id = account.parent_id
102 ids = [t[0] for t in cr.fetchall()]
106 def _get_project_and_children(self, cr, uid, ids, context=None):
107 """ retrieve all children projects of project ids;
108 return a dictionary mapping each project to its parent project (or None)
110 res = dict.fromkeys(ids, None)
113 SELECT project.id, parent.id
114 FROM project_project project, project_project parent, account_analytic_account account
115 WHERE project.analytic_account_id = account.id
116 AND parent.analytic_account_id = account.parent_id
119 dic = dict(cr.fetchall())
124 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
125 child_parent = self._get_project_and_children(cr, uid, ids, context)
126 # compute planned_hours, total_hours, effective_hours specific to each project
128 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
129 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
130 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
132 """, (tuple(child_parent.keys()),))
133 # aggregate results into res
134 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
135 for id, planned, total, effective in cr.fetchall():
136 # add the values specific to id to all parent projects of id in the result
139 res[id]['planned_hours'] += planned
140 res[id]['total_hours'] += total
141 res[id]['effective_hours'] += effective
142 id = child_parent[id]
143 # compute progress rates
145 if res[id]['total_hours']:
146 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
148 res[id]['progress_rate'] = 0.0
151 def unlink(self, cr, uid, ids, *args, **kwargs):
152 for proj in self.browse(cr, uid, ids):
154 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
155 return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
158 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
159 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
160 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
161 '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),
162 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
163 '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)]}),
165 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
166 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)]}),
167 'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
168 '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.",
170 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
171 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
173 '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.",
175 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
176 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
178 '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.",
180 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
181 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
183 '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.",
185 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
186 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
188 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
189 '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)]}),
190 '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)]}),
191 '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)]}),
192 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
194 def _get_type_common(self, cr, uid, context):
195 ids = self.pool.get('project.task.type').search(cr, uid, [('project_default','=',1)], context=context)
203 'type_ids': _get_type_common
206 # TODO: Why not using a SQL contraints ?
207 def _check_dates(self, cr, uid, ids, context=None):
208 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
209 if leave['date_start'] and leave['date']:
210 if leave['date_start'] > leave['date']:
215 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
218 def set_template(self, cr, uid, ids, context=None):
219 res = self.setActive(cr, uid, ids, value=False, context=context)
222 def set_done(self, cr, uid, ids, context=None):
223 task_obj = self.pool.get('project.task')
224 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
225 task_obj.write(cr, uid, task_ids, {'state': 'done', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
226 self.write(cr, uid, ids, {'state':'close'}, context=context)
227 for (id, name) in self.name_get(cr, uid, ids):
228 message = _("The project '%s' has been closed.") % name
229 self.log(cr, uid, id, message)
232 def set_cancel(self, cr, uid, ids, context=None):
233 task_obj = self.pool.get('project.task')
234 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
235 task_obj.write(cr, uid, task_ids, {'state': 'cancelled', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
236 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
239 def set_pending(self, cr, uid, ids, context=None):
240 self.write(cr, uid, ids, {'state':'pending'}, context=context)
243 def set_open(self, cr, uid, ids, context=None):
244 self.write(cr, uid, ids, {'state':'open'}, context=context)
247 def reset_project(self, cr, uid, ids, context=None):
248 res = self.setActive(cr, uid, ids, value=True, context=context)
249 for (id, name) in self.name_get(cr, uid, ids):
250 message = _("The project '%s' has been opened.") % name
251 self.log(cr, uid, id, message)
254 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
255 """ copy and map tasks from old to new project """
259 task_obj = self.pool.get('project.task')
260 proj = self.browse(cr, uid, old_project_id, context=context)
261 for task in proj.tasks:
262 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
263 self.write(cr, uid, new_project_id, {'tasks':[(6,0, map_task_id.values())]})
264 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
267 def copy(self, cr, uid, id, default={}, context=None):
271 default = default or {}
272 context['active_test'] = False
273 default['state'] = 'open'
274 default['tasks'] = []
275 proj = self.browse(cr, uid, id, context=context)
276 if not default.get('name', False):
277 default['name'] = proj.name + _(' (copy)')
279 res = super(project, self).copy(cr, uid, id, default, context)
280 self.map_tasks(cr,uid,id,res,context)
283 def duplicate_template(self, cr, uid, ids, context=None):
286 data_obj = self.pool.get('ir.model.data')
288 for proj in self.browse(cr, uid, ids, context=context):
289 parent_id = context.get('parent_id', False)
290 context.update({'analytic_project_copy': True})
291 new_date_start = time.strftime('%Y-%m-%d')
293 if proj.date_start and proj.date:
294 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
295 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
296 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
297 context.update({'copy':True})
298 new_id = self.copy(cr, uid, proj.id, default = {
299 'name': proj.name +_(' (copy)'),
301 'date_start':new_date_start,
303 'parent_id':parent_id}, context=context)
304 result.append(new_id)
306 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
307 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
309 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
311 if result and len(result):
313 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
314 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
315 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
316 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
317 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
318 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
320 'name': _('Projects'),
322 'view_mode': 'form,tree',
323 'res_model': 'project.project',
326 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
327 'type': 'ir.actions.act_window',
328 'search_view_id': search_view['res_id'],
332 # set active value for a project, its sub projects and its tasks
333 def setActive(self, cr, uid, ids, value=True, context=None):
334 task_obj = self.pool.get('project.task')
335 for proj in self.browse(cr, uid, ids, context=None):
336 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
337 cr.execute('select id from project_task where project_id=%s', (proj.id,))
338 tasks_id = [x[0] for x in cr.fetchall()]
340 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
341 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
343 self.setActive(cr, uid, child_ids, value, context=None)
346 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
347 context = context or {}
348 if type(ids) in (long, int,):
350 projects = self.browse(cr, uid, ids, context=context)
352 for project in projects:
353 if (not project.members) and force_members:
354 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
356 resource_pool = self.pool.get('resource.resource')
358 result = "from openerp.addons.resource.faces import *\n"
359 result += "import datetime\n"
360 for project in self.browse(cr, uid, ids, context=context):
361 u_ids = [i.id for i in project.members]
362 if project.user_id and (project.user_id.id not in u_ids):
363 u_ids.append(project.user_id.id)
364 for task in project.tasks:
365 if task.state in ('done','cancelled'):
367 if task.user_id and (task.user_id.id not in u_ids):
368 u_ids.append(task.user_id.id)
369 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
370 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
371 for key, vals in resource_objs.items():
373 class User_%s(Resource):
375 ''' % (key, vals.get('efficiency', False))
382 def _schedule_project(self, cr, uid, project, context=None):
383 resource_pool = self.pool.get('resource.resource')
384 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
385 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
386 # TODO: check if we need working_..., default values are ok.
387 puids = [x.id for x in project.members]
389 puids.append(project.user_id.id)
397 project.date_start, working_days,
398 '|'.join(['User_'+str(x) for x in puids])
400 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
407 #TODO: DO Resource allocation and compute availability
408 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
414 def schedule_tasks(self, cr, uid, ids, context=None):
415 context = context or {}
416 if type(ids) in (long, int,):
418 projects = self.browse(cr, uid, ids, context=context)
419 result = self._schedule_header(cr, uid, ids, False, context=context)
420 for project in projects:
421 result += self._schedule_project(cr, uid, project, context=context)
422 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
425 exec result in local_dict
426 projects_gantt = Task.BalancedProject(local_dict['Project'])
428 for project in projects:
429 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
430 for task in project.tasks:
431 if task.state in ('done','cancelled'):
434 p = getattr(project_gantt, 'Task_%d' % (task.id,))
436 self.pool.get('project.task').write(cr, uid, [task.id], {
437 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
438 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
440 if (not task.user_id) and (p.booked_resource):
441 self.pool.get('project.task').write(cr, uid, [task.id], {
442 'user_id': int(p.booked_resource[0].name[5:]),
447 class users(osv.osv):
448 _inherit = 'res.users'
450 'context_project_id': fields.many2one('project.project', 'Project')
455 _name = "project.task"
456 _description = "Task"
458 _date_name = "date_start"
461 def _resolve_project_id_from_context(self, cr, uid, context=None):
462 """Return ID of project based on the value of 'project_id'
463 context key, or None if it cannot be resolved to a single project.
465 if context is None: context = {}
466 if type(context.get('project_id')) in (int, long):
467 project_id = context['project_id']
469 if isinstance(context.get('project_id'), basestring):
470 project_name = context['project_id']
471 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name)
472 if len(project_ids) == 1:
473 return project_ids[0][0]
475 def _read_group_type_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
476 stage_obj = self.pool.get('project.task.type')
477 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
478 order = stage_obj._order
479 access_rights_uid = access_rights_uid or uid
480 if read_group_order == 'type_id desc':
481 # lame way to allow reverting search, should just work in the trivial case
482 order = '%s desc' % order
484 domain = ['|', ('id','in',ids), ('project_ids','in',project_id)]
486 domain = ['|', ('id','in',ids), ('project_default','=',1)]
487 stage_ids = stage_obj._search(cr, uid, domain, order=order, access_rights_uid=access_rights_uid, context=context)
488 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
489 # restore order of the search
490 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
493 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
494 res_users = self.pool.get('res.users')
495 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
496 access_rights_uid = access_rights_uid or uid
498 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
499 order = res_users._order
500 # lame way to allow reverting search, should just work in the trivial case
501 if read_group_order == 'user_id desc':
502 order = '%s desc' % order
503 # de-duplicate and apply search order
504 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
505 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
506 # restore order of the search
507 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
511 'type_id': _read_group_type_id,
512 'user_id': _read_group_user_id
516 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
517 obj_project = self.pool.get('project.project')
519 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
520 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
521 if id and isinstance(id, (long, int)):
522 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
523 args.append(('active', '=', False))
524 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
526 def _str_get(self, task, level=0, border='***', context=None):
527 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'+ \
528 border[0]+' '+(task.name or '')+'\n'+ \
529 (task.description or '')+'\n\n'
531 # Compute: effective_hours, total_hours, progress
532 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
534 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
535 hours = dict(cr.fetchall())
536 for task in self.browse(cr, uid, ids, context=context):
537 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)}
538 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
539 res[task.id]['progress'] = 0.0
540 if (task.remaining_hours + hours.get(task.id, 0.0)):
541 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
542 if task.state in ('done','cancelled'):
543 res[task.id]['progress'] = 100.0
547 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
548 if remaining and not planned:
549 return {'value':{'planned_hours': remaining}}
552 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
553 return {'value':{'remaining_hours': planned - effective}}
555 def onchange_project(self, cr, uid, id, project_id):
558 data = self.pool.get('project.project').browse(cr, uid, [project_id])
559 partner_id=data and data[0].partner_id
561 return {'value':{'partner_id':partner_id.id}}
564 def duplicate_task(self, cr, uid, map_ids, context=None):
565 for new in map_ids.values():
566 task = self.browse(cr, uid, new, context)
567 child_ids = [ ch.id for ch in task.child_ids]
569 for child in task.child_ids:
570 if child.id in map_ids.keys():
571 child_ids.remove(child.id)
572 child_ids.append(map_ids[child.id])
574 parent_ids = [ ch.id for ch in task.parent_ids]
576 for parent in task.parent_ids:
577 if parent.id in map_ids.keys():
578 parent_ids.remove(parent.id)
579 parent_ids.append(map_ids[parent.id])
580 #FIXME why there is already the copy and the old one
581 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
583 def copy_data(self, cr, uid, id, default={}, context=None):
584 default = default or {}
585 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
586 if not default.get('remaining_hours', False):
587 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
588 default['active'] = True
589 default['type_id'] = False
590 if not default.get('name', False):
591 default['name'] = self.browse(cr, uid, id, context=context).name or ''
592 if not context.get('copy',False):
593 new_name = _("%s (copy)")%default.get('name','')
594 default.update({'name':new_name})
595 return super(task, self).copy_data(cr, uid, id, default, context)
598 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
600 for task in self.browse(cr, uid, ids, context=context):
603 if task.project_id.active == False or task.project_id.state == 'template':
607 def _get_task(self, cr, uid, ids, context=None):
609 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
610 if work.task_id: result[work.task_id.id] = True
614 '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."),
615 'name': fields.char('Task Summary', size=128, required=True, select=True),
616 'description': fields.text('Description'),
617 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
618 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
619 'type_id': fields.many2one('project.task.type', 'Stage'),
620 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
621 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.\
622 \n If the task is over, the states is set to \'Done\'.'),
623 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
624 help="A task's kanban state indicates special situations affecting it:\n"
625 " * Normal is the default situation\n"
626 " * Blocked indicates something is preventing the progress of this task\n"
627 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
628 readonly=True, required=False),
629 'create_date': fields.datetime('Create Date', readonly=True,select=True),
630 'date_start': fields.datetime('Starting Date',select=True),
631 'date_end': fields.datetime('Ending Date',select=True),
632 'date_deadline': fields.date('Deadline',select=True),
633 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
634 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
635 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
636 'notes': fields.text('Notes'),
637 '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.'),
638 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
640 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
641 'project.task.work': (_get_task, ['hours'], 10),
643 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
644 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
646 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
647 'project.task.work': (_get_task, ['hours'], 10),
649 '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",
651 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
652 'project.task.work': (_get_task, ['hours'], 10),
654 '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.",
656 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
657 'project.task.work': (_get_task, ['hours'], 10),
659 'user_id': fields.many2one('res.users', 'Assigned to'),
660 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
661 'partner_id': fields.many2one('res.partner', 'Partner'),
662 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
663 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
664 'company_id': fields.many2one('res.company', 'Company'),
665 'id': fields.integer('ID', readonly=True),
666 'color': fields.integer('Color Index'),
667 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
672 'kanban_state': 'normal',
677 'user_id': lambda obj, cr, uid, context: uid,
678 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
681 _order = "priority, sequence, date_start, name, id"
683 def set_priority(self, cr, uid, ids, priority):
686 return self.write(cr, uid, ids, {'priority' : priority})
688 def set_high_priority(self, cr, uid, ids, *args):
689 """Set task priority to high
691 return self.set_priority(cr, uid, ids, '1')
693 def set_normal_priority(self, cr, uid, ids, *args):
694 """Set task priority to normal
696 return self.set_priority(cr, uid, ids, '2')
698 def _check_recursion(self, cr, uid, ids, context=None):
700 visited_branch = set()
702 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
708 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
709 if id in visited_branch: #Cycle
712 if id in visited_node: #Already tested don't work one more time for nothing
715 visited_branch.add(id)
718 #visit child using DFS
719 task = self.browse(cr, uid, id, context=context)
720 for child in task.child_ids:
721 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
725 visited_branch.remove(id)
728 def _check_dates(self, cr, uid, ids, context=None):
731 obj_task = self.browse(cr, uid, ids[0], context=context)
732 start = obj_task.date_start or False
733 end = obj_task.date_end or False
740 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
741 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
744 # Override view according to the company definition
746 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
747 users_obj = self.pool.get('res.users')
749 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
750 # this should be safe (no context passed to avoid side-effects)
751 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
752 tm = obj_tm and obj_tm.name or 'Hours'
754 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
756 if tm in ['Hours','Hour']:
759 eview = etree.fromstring(res['arch'])
761 def _check_rec(eview):
762 if eview.attrib.get('widget','') == 'float_time':
763 eview.set('widget','float')
770 res['arch'] = etree.tostring(eview)
772 for f in res['fields']:
773 if 'Hours' in res['fields'][f]['string']:
774 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
777 def _check_child_task(self, cr, uid, ids, context=None):
780 tasks = self.browse(cr, uid, ids, context=context)
783 for child in task.child_ids:
784 if child.state in ['draft', 'open', 'pending']:
785 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
788 def action_close(self, cr, uid, ids, context=None):
789 # This action open wizard to send email to partner or project manager after close task.
792 task_id = len(ids) and ids[0] or False
793 self._check_child_task(cr, uid, ids, context=context)
794 if not task_id: return False
795 task = self.browse(cr, uid, task_id, context=context)
796 project = task.project_id
797 res = self.do_close(cr, uid, [task_id], context=context)
798 if project.warn_manager or project.warn_customer:
800 'name': _('Send Email after close task'),
803 'res_model': 'mail.compose.message',
804 'type': 'ir.actions.act_window',
807 'context': {'active_id': task.id,
808 'active_model': 'project.task'}
812 def do_close(self, cr, uid, ids, context={}):
816 request = self.pool.get('res.request')
817 if not isinstance(ids,list): ids = [ids]
818 for task in self.browse(cr, uid, ids, context=context):
820 project = task.project_id
822 # Send request to project manager
823 if project.warn_manager and project.user_id and (project.user_id.id != uid):
824 request.create(cr, uid, {
825 'name': _("Task '%s' closed") % task.name,
828 'act_to': project.user_id.id,
829 'ref_partner_id': task.partner_id.id,
830 'ref_doc1': 'project.task,%d'% (task.id,),
831 'ref_doc2': 'project.project,%d'% (project.id,),
834 for parent_id in task.parent_ids:
835 if parent_id.state in ('pending','draft'):
837 for child in parent_id.child_ids:
838 if child.id != task.id and child.state not in ('done','cancelled'):
841 self.do_reopen(cr, uid, [parent_id.id], context=context)
842 vals.update({'state': 'done'})
843 vals.update({'remaining_hours': 0.0})
844 if not task.date_end:
845 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
846 self.write(cr, uid, [task.id],vals, context=context)
847 message = _("The task '%s' is done") % (task.name,)
848 self.log(cr, uid, task.id, message)
851 def do_reopen(self, cr, uid, ids, context=None):
852 request = self.pool.get('res.request')
854 for task in self.browse(cr, uid, ids, context=context):
855 project = task.project_id
856 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
857 request.create(cr, uid, {
858 'name': _("Task '%s' set in progress") % task.name,
861 'act_to': project.user_id.id,
862 'ref_partner_id': task.partner_id.id,
863 'ref_doc1': 'project.task,%d' % task.id,
864 'ref_doc2': 'project.project,%d' % project.id,
867 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
870 def do_cancel(self, cr, uid, ids, context={}):
871 request = self.pool.get('res.request')
872 tasks = self.browse(cr, uid, ids, context=context)
873 self._check_child_task(cr, uid, ids, context=context)
875 project = task.project_id
876 if project.warn_manager and project.user_id and (project.user_id.id != uid):
877 request.create(cr, uid, {
878 'name': _("Task '%s' cancelled") % task.name,
881 'act_to': project.user_id.id,
882 'ref_partner_id': task.partner_id.id,
883 'ref_doc1': 'project.task,%d' % task.id,
884 'ref_doc2': 'project.project,%d' % project.id,
886 message = _("The task '%s' is cancelled.") % (task.name,)
887 self.log(cr, uid, task.id, message)
888 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
891 def do_open(self, cr, uid, ids, context={}):
892 if not isinstance(ids,list): ids = [ids]
893 tasks= self.browse(cr, uid, ids, context=context)
895 data = {'state': 'open'}
897 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
898 self.write(cr, uid, [t.id], data, context=context)
899 message = _("The task '%s' is opened.") % (t.name,)
900 self.log(cr, uid, t.id, message)
903 def do_draft(self, cr, uid, ids, context={}):
904 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
908 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
909 attachment = self.pool.get('ir.attachment')
910 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
911 new_attachment_ids = []
912 for attachment_id in attachment_ids:
913 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
914 return new_attachment_ids
917 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
919 Delegate Task to another users.
921 assert delegate_data['user_id'], _("Delegated User should be specified")
923 for task in self.browse(cr, uid, ids, context=context):
924 delegated_task_id = self.copy(cr, uid, task.id, {
925 'name': delegate_data['name'],
926 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
927 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
928 'planned_hours': delegate_data['planned_hours'] or 0.0,
929 'parent_ids': [(6, 0, [task.id])],
931 'description': delegate_data['new_task_description'] or '',
935 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
936 newname = delegate_data['prefix'] or ''
938 'remaining_hours': delegate_data['planned_hours_me'],
939 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
942 if delegate_data['state'] == 'pending':
943 self.do_pending(cr, uid, task.id, context=context)
944 elif delegate_data['state'] == 'done':
945 self.do_close(cr, uid, task.id, context=context)
947 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
948 self.log(cr, uid, task.id, message)
949 delegated_tasks[task.id] = delegated_task_id
950 return delegated_tasks
952 def do_pending(self, cr, uid, ids, context={}):
953 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
954 for (id, name) in self.name_get(cr, uid, ids):
955 message = _("The task '%s' is pending.") % name
956 self.log(cr, uid, id, message)
959 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
960 for task in self.browse(cr, uid, ids, context=context):
961 if (task.state=='draft') or (task.planned_hours==0.0):
962 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
963 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
966 def set_remaining_time_1(self, cr, uid, ids, context=None):
967 return self.set_remaining_time(cr, uid, ids, 1.0, context)
969 def set_remaining_time_2(self, cr, uid, ids, context=None):
970 return self.set_remaining_time(cr, uid, ids, 2.0, context)
972 def set_remaining_time_5(self, cr, uid, ids, context=None):
973 return self.set_remaining_time(cr, uid, ids, 5.0, context)
975 def set_remaining_time_10(self, cr, uid, ids, context=None):
976 return self.set_remaining_time(cr, uid, ids, 10.0, context)
978 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
979 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
981 def set_kanban_state_normal(self, cr, uid, ids, context=None):
982 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
984 def set_kanban_state_done(self, cr, uid, ids, context=None):
985 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
987 def _change_type(self, cr, uid, ids, next, *args):
990 if next is False, go to previous stage
992 for task in self.browse(cr, uid, ids):
993 if task.project_id.type_ids:
994 typeid = task.type_id.id
996 for type in task.project_id.type_ids :
997 types_seq[type.id] = type.sequence
999 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
1001 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
1002 sorted_types = [x[0] for x in types]
1004 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
1005 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
1006 index = sorted_types.index(typeid)
1007 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
1010 def next_type(self, cr, uid, ids, *args):
1011 return self._change_type(cr, uid, ids, True, *args)
1013 def prev_type(self, cr, uid, ids, *args):
1014 return self._change_type(cr, uid, ids, False, *args)
1016 def _store_history(self, cr, uid, ids, context=None):
1017 for task in self.browse(cr, uid, ids, context=context):
1018 self.pool.get('project.task.history').create(cr, uid, {
1020 'remaining_hours': task.remaining_hours,
1021 'planned_hours': task.planned_hours,
1022 'kanban_state': task.kanban_state,
1023 'type_id': task.type_id.id,
1024 'state': task.state,
1025 'user_id': task.user_id.id
1030 def create(self, cr, uid, vals, context=None):
1031 result = super(task, self).create(cr, uid, vals, context=context)
1032 self._store_history(cr, uid, [result], context=context)
1035 # Overridden to reset the kanban_state to normal whenever
1036 # the stage (type_id) of the task changes.
1037 def write(self, cr, uid, ids, vals, context=None):
1038 if isinstance(ids, (int, long)):
1040 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1041 new_stage = vals.get('type_id')
1042 vals_reset_kstate = dict(vals, kanban_state='normal')
1043 for t in self.browse(cr, uid, ids, context=context):
1044 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1045 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1048 result = super(task,self).write(cr, uid, ids, vals, context=context)
1049 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1050 self._store_history(cr, uid, ids, context=context)
1053 def unlink(self, cr, uid, ids, context=None):
1056 self._check_child_task(cr, uid, ids, context=context)
1057 res = super(task, self).unlink(cr, uid, ids, context)
1060 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1061 context = context or {}
1065 if task.state in ('done','cancelled'):
1070 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1072 for t2 in task.parent_ids:
1073 start.append("up.Task_%s.end" % (t2.id,))
1077 ''' % (ident,','.join(start))
1082 ''' % (ident, 'User_'+str(task.user_id.id))
1089 class project_work(osv.osv):
1090 _name = "project.task.work"
1091 _description = "Project Task Work"
1093 'name': fields.char('Work summary', size=128),
1094 'date': fields.datetime('Date', select="1"),
1095 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1096 'hours': fields.float('Time Spent'),
1097 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1098 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1102 'user_id': lambda obj, cr, uid, context: uid,
1103 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1106 _order = "date desc"
1107 def create(self, cr, uid, vals, *args, **kwargs):
1108 if 'hours' in vals and (not vals['hours']):
1109 vals['hours'] = 0.00
1110 if 'task_id' in vals:
1111 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1112 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1114 def write(self, cr, uid, ids, vals, context=None):
1115 if 'hours' in vals and (not vals['hours']):
1116 vals['hours'] = 0.00
1118 for work in self.browse(cr, uid, ids, context=context):
1119 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))
1120 return super(project_work,self).write(cr, uid, ids, vals, context)
1122 def unlink(self, cr, uid, ids, *args, **kwargs):
1123 for work in self.browse(cr, uid, ids):
1124 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1125 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1128 class account_analytic_account(osv.osv):
1130 _inherit = 'account.analytic.account'
1131 _description = 'Analytic Account'
1133 def create(self, cr, uid, vals, context=None):
1136 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1137 vals['child_ids'] = []
1138 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1140 def unlink(self, cr, uid, ids, *args, **kwargs):
1141 project_obj = self.pool.get('project.project')
1142 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1144 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1145 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1147 account_analytic_account()
1150 # Tasks History, used for cumulative flow charts (Lean/Agile)
1153 class project_task_history(osv.osv):
1154 _name = 'project.task.history'
1155 _description = 'History of Tasks'
1156 _rec_name = 'task_id'
1158 def _get_date(self, cr, uid, ids, name, arg, context=None):
1160 for history in self.browse(cr, uid, ids, context=context):
1161 if history.state in ('done','cancelled'):
1162 result[history.id] = history.date
1164 cr.execute('''select
1167 project_task_history
1171 order by id limit 1''', (history.task_id.id, history.id))
1173 result[history.id] = res and res[0] or False
1176 def _get_related_date(self, cr, uid, ids, context=None):
1178 for history in self.browse(cr, uid, ids, context=context):
1179 cr.execute('''select
1182 project_task_history
1186 order by id desc limit 1''', (history.task_id.id, history.id))
1189 result.append(res[0])
1193 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1194 'type_id': fields.many2one('project.task.type', 'Stage'),
1195 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1196 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1197 'date': fields.date('Date', select=True),
1198 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1199 'project.task.history': (_get_related_date, None, 20)
1201 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1202 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1203 'user_id': fields.many2one('res.users', 'Responsible'),
1206 'date': fields.date.context_today,
1208 project_task_history()
1210 class project_task_history_cumulative(osv.osv):
1211 _name = 'project.task.history.cumulative'
1212 _table = 'project_task_history_cumulative'
1213 _inherit = 'project.task.history'
1216 'end_date': fields.date('End Date'),
1217 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1220 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1222 history.date::varchar||'-'||history.history_id::varchar as id,
1223 history.date as end_date,
1228 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1229 task_id, type_id, user_id, kanban_state, state,
1230 remaining_hours, planned_hours
1232 project_task_history
1236 project_task_history_cumulative()