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"}
56 _inherit = ['ir.needaction', 'mail.thread']
58 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
60 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
61 if context and context.get('user_preference'):
62 cr.execute("""SELECT project.id FROM project_project project
63 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
64 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
65 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
66 return [(r[0]) for r in cr.fetchall()]
67 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
68 context=context, count=count)
70 def _complete_name(self, cr, uid, ids, name, args, context=None):
72 for m in self.browse(cr, uid, ids, context=context):
73 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
76 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
77 partner_obj = self.pool.get('res.partner')
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 self.set_close_send_note(cr, uid, ids, context=context)
231 def set_cancel(self, cr, uid, ids, context=None):
232 task_obj = self.pool.get('project.task')
233 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
234 task_obj.write(cr, uid, task_ids, {'state': 'cancelled', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
235 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
236 self.set_cancel_send_note(cr, uid, ids, context=context)
239 def set_pending(self, cr, uid, ids, context=None):
240 self.write(cr, uid, ids, {'state':'pending'}, context=context)
241 self.set_pending_send_note(cr, uid, ids, context=context)
244 def set_open(self, cr, uid, ids, context=None):
245 self.write(cr, uid, ids, {'state':'open'}, context=context)
246 self.set_open_send_note(cr, uid, ids, context=context)
249 def reset_project(self, cr, uid, ids, context=None):
250 res = self.setActive(cr, uid, ids, value=True, context=context)
251 self.set_open_send_note(cr, uid, ids, context=context)
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:]),
446 # ------------------------------------------------
447 # OpenChatter methods and notifications
448 # ------------------------------------------------
450 def get_needaction_user_ids(self, cr, uid, ids, context=None):
451 result = dict.fromkeys(ids)
452 for obj in self.browse(cr, uid, ids, context=context):
454 if obj.state == 'draft' and obj.user_id:
455 result[obj.id] = [obj.user_id.id]
458 def message_get_subscribers(self, cr, uid, ids, context=None):
459 sub_ids = self.message_get_subscribers_ids(cr, uid, ids, context=context);
460 for obj in self.browse(cr, uid, ids, context=context):
462 sub_ids.append(obj.user_id.id)
463 return self.pool.get('res.users').read(cr, uid, sub_ids, context=context)
465 def create(self, cr, uid, vals, context=None):
466 obj_id = super(project, self).create(cr, uid, vals, context=context)
467 self.create_send_note(cr, uid, [obj_id], context=context)
470 def create_send_note(self, cr, uid, ids, context=None):
471 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
473 def set_open_send_note(self, cr, uid, ids, context=None):
474 message = _("Project has been <b>opened</b>.")
475 return self.message_append_note(cr, uid, ids, body=message, context=context)
477 def set_pending_send_note(self, cr, uid, ids, context=None):
478 message = _("Project is now <b>pending</b>.")
479 return self.message_append_note(cr, uid, ids, body=message, context=context)
481 def set_cancel_send_note(self, cr, uid, ids, context=None):
482 message = _("Project has been <b>cancelled</b>.")
483 return self.message_append_note(cr, uid, ids, body=message, context=context)
485 def set_close_send_note(self, cr, uid, ids, context=None):
486 message = _("Project has been <b>closed</b>.")
487 return self.message_append_note(cr, uid, ids, body=message, context=context)
491 class users(osv.osv):
492 _inherit = 'res.users'
494 'context_project_id': fields.many2one('project.project', 'Project')
499 _name = "project.task"
500 _description = "Task"
502 _date_name = "date_start"
505 def _resolve_project_id_from_context(self, cr, uid, context=None):
506 """Return ID of project based on the value of 'project_id'
507 context key, or None if it cannot be resolved to a single project.
509 if context is None: context = {}
510 if type(context.get('project_id')) in (int, long):
511 project_id = context['project_id']
513 if isinstance(context.get('project_id'), basestring):
514 project_name = context['project_id']
515 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name)
516 if len(project_ids) == 1:
517 return project_ids[0][0]
519 def _read_group_type_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
520 stage_obj = self.pool.get('project.task.type')
521 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
522 order = stage_obj._order
523 access_rights_uid = access_rights_uid or uid
524 if read_group_order == 'type_id desc':
525 # lame way to allow reverting search, should just work in the trivial case
526 order = '%s desc' % order
528 domain = ['|', ('id','in',ids), ('project_ids','in',project_id)]
530 domain = ['|', ('id','in',ids), ('project_default','=',1)]
531 stage_ids = stage_obj._search(cr, uid, domain, order=order, access_rights_uid=access_rights_uid, context=context)
532 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
533 # restore order of the search
534 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
537 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
538 res_users = self.pool.get('res.users')
539 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
540 access_rights_uid = access_rights_uid or uid
542 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
543 order = res_users._order
544 # lame way to allow reverting search, should just work in the trivial case
545 if read_group_order == 'user_id desc':
546 order = '%s desc' % order
547 # de-duplicate and apply search order
548 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
549 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
550 # restore order of the search
551 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
555 'type_id': _read_group_type_id,
556 'user_id': _read_group_user_id
560 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
561 obj_project = self.pool.get('project.project')
563 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
564 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
565 if id and isinstance(id, (long, int)):
566 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
567 args.append(('active', '=', False))
568 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
570 def _str_get(self, task, level=0, border='***', context=None):
571 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'+ \
572 border[0]+' '+(task.name or '')+'\n'+ \
573 (task.description or '')+'\n\n'
575 # Compute: effective_hours, total_hours, progress
576 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
578 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
579 hours = dict(cr.fetchall())
580 for task in self.browse(cr, uid, ids, context=context):
581 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)}
582 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
583 res[task.id]['progress'] = 0.0
584 if (task.remaining_hours + hours.get(task.id, 0.0)):
585 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
586 if task.state in ('done','cancelled'):
587 res[task.id]['progress'] = 100.0
591 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
592 if remaining and not planned:
593 return {'value':{'planned_hours': remaining}}
596 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
597 return {'value':{'remaining_hours': planned - effective}}
599 def onchange_project(self, cr, uid, id, project_id):
602 data = self.pool.get('project.project').browse(cr, uid, [project_id])
603 partner_id=data and data[0].partner_id
605 return {'value':{'partner_id':partner_id.id}}
608 def duplicate_task(self, cr, uid, map_ids, context=None):
609 for new in map_ids.values():
610 task = self.browse(cr, uid, new, context)
611 child_ids = [ ch.id for ch in task.child_ids]
613 for child in task.child_ids:
614 if child.id in map_ids.keys():
615 child_ids.remove(child.id)
616 child_ids.append(map_ids[child.id])
618 parent_ids = [ ch.id for ch in task.parent_ids]
620 for parent in task.parent_ids:
621 if parent.id in map_ids.keys():
622 parent_ids.remove(parent.id)
623 parent_ids.append(map_ids[parent.id])
624 #FIXME why there is already the copy and the old one
625 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
627 def copy_data(self, cr, uid, id, default={}, context=None):
628 default = default or {}
629 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
630 if not default.get('remaining_hours', False):
631 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
632 default['active'] = True
633 default['type_id'] = False
634 if not default.get('name', False):
635 default['name'] = self.browse(cr, uid, id, context=context).name or ''
636 if not context.get('copy',False):
637 new_name = _("%s (copy)")%default.get('name','')
638 default.update({'name':new_name})
639 return super(task, self).copy_data(cr, uid, id, default, context)
642 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
644 for task in self.browse(cr, uid, ids, context=context):
647 if task.project_id.active == False or task.project_id.state == 'template':
651 def _get_task(self, cr, uid, ids, context=None):
653 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
654 if work.task_id: result[work.task_id.id] = True
658 '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."),
659 'name': fields.char('Task Summary', size=128, required=True, select=True),
660 'description': fields.text('Description'),
661 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
662 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
663 'type_id': fields.many2one('project.task.type', 'Stage'),
664 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
665 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.\
666 \n If the task is over, the states is set to \'Done\'.'),
667 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
668 help="A task's kanban state indicates special situations affecting it:\n"
669 " * Normal is the default situation\n"
670 " * Blocked indicates something is preventing the progress of this task\n"
671 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
672 readonly=True, required=False),
673 'create_date': fields.datetime('Create Date', readonly=True,select=True),
674 'date_start': fields.datetime('Starting Date',select=True),
675 'date_end': fields.datetime('Ending Date',select=True),
676 'date_deadline': fields.date('Deadline',select=True),
677 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
678 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
679 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
680 'notes': fields.text('Notes'),
681 '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.'),
682 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
684 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
685 'project.task.work': (_get_task, ['hours'], 10),
687 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
688 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
690 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
691 'project.task.work': (_get_task, ['hours'], 10),
693 '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",
695 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
696 'project.task.work': (_get_task, ['hours'], 10),
698 '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.",
700 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
701 'project.task.work': (_get_task, ['hours'], 10),
703 'user_id': fields.many2one('res.users', 'Assigned to'),
704 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
705 'partner_id': fields.many2one('res.partner', 'Partner'),
706 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
707 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
708 'company_id': fields.many2one('res.company', 'Company'),
709 'id': fields.integer('ID', readonly=True),
710 'color': fields.integer('Color Index'),
711 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
716 'kanban_state': 'normal',
721 'user_id': lambda obj, cr, uid, context: uid,
722 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
725 _order = "priority, sequence, date_start, name, id"
727 def set_priority(self, cr, uid, ids, priority):
730 return self.write(cr, uid, ids, {'priority' : priority})
732 def set_high_priority(self, cr, uid, ids, *args):
733 """Set task priority to high
735 return self.set_priority(cr, uid, ids, '1')
737 def set_normal_priority(self, cr, uid, ids, *args):
738 """Set task priority to normal
740 return self.set_priority(cr, uid, ids, '2')
742 def _check_recursion(self, cr, uid, ids, context=None):
744 visited_branch = set()
746 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
752 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
753 if id in visited_branch: #Cycle
756 if id in visited_node: #Already tested don't work one more time for nothing
759 visited_branch.add(id)
762 #visit child using DFS
763 task = self.browse(cr, uid, id, context=context)
764 for child in task.child_ids:
765 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
769 visited_branch.remove(id)
772 def _check_dates(self, cr, uid, ids, context=None):
775 obj_task = self.browse(cr, uid, ids[0], context=context)
776 start = obj_task.date_start or False
777 end = obj_task.date_end or False
784 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
785 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
788 # Override view according to the company definition
790 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
791 users_obj = self.pool.get('res.users')
793 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
794 # this should be safe (no context passed to avoid side-effects)
795 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
796 tm = obj_tm and obj_tm.name or 'Hours'
798 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
800 if tm in ['Hours','Hour']:
803 eview = etree.fromstring(res['arch'])
805 def _check_rec(eview):
806 if eview.attrib.get('widget','') == 'float_time':
807 eview.set('widget','float')
814 res['arch'] = etree.tostring(eview)
816 for f in res['fields']:
817 if 'Hours' in res['fields'][f]['string']:
818 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
821 def _check_child_task(self, cr, uid, ids, context=None):
824 tasks = self.browse(cr, uid, ids, context=context)
827 for child in task.child_ids:
828 if child.state in ['draft', 'open', 'pending']:
829 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
832 def action_close(self, cr, uid, ids, context=None):
833 # This action open wizard to send email to partner or project manager after close task.
836 task_id = len(ids) and ids[0] or False
837 self._check_child_task(cr, uid, ids, context=context)
838 if not task_id: return False
839 task = self.browse(cr, uid, task_id, context=context)
840 project = task.project_id
841 res = self.do_close(cr, uid, [task_id], context=context)
842 if project.warn_manager or project.warn_customer:
844 'name': _('Send Email after close task'),
847 'res_model': 'mail.compose.message',
848 'type': 'ir.actions.act_window',
851 'context': {'active_id': task.id,
852 'active_model': 'project.task'}
856 def do_close(self, cr, uid, ids, context={}):
860 request = self.pool.get('res.request')
861 if not isinstance(ids,list): ids = [ids]
862 for task in self.browse(cr, uid, ids, context=context):
864 project = task.project_id
866 # Send request to project manager
867 if project.warn_manager and project.user_id and (project.user_id.id != uid):
868 request.create(cr, uid, {
869 'name': _("Task '%s' closed") % task.name,
872 'act_to': project.user_id.id,
873 'ref_partner_id': task.partner_id.id,
874 'ref_doc1': 'project.task,%d'% (task.id,),
875 'ref_doc2': 'project.project,%d'% (project.id,),
878 for parent_id in task.parent_ids:
879 if parent_id.state in ('pending','draft'):
881 for child in parent_id.child_ids:
882 if child.id != task.id and child.state not in ('done','cancelled'):
885 self.do_reopen(cr, uid, [parent_id.id], context=context)
886 vals.update({'state': 'done'})
887 vals.update({'remaining_hours': 0.0})
888 if not task.date_end:
889 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
890 self.write(cr, uid, [task.id],vals, context=context)
891 message = _("The task '%s' is done") % (task.name,)
892 self.log(cr, uid, task.id, message)
895 def do_reopen(self, cr, uid, ids, context=None):
896 request = self.pool.get('res.request')
898 for task in self.browse(cr, uid, ids, context=context):
899 project = task.project_id
900 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
901 request.create(cr, uid, {
902 'name': _("Task '%s' set in progress") % task.name,
905 'act_to': project.user_id.id,
906 'ref_partner_id': task.partner_id.id,
907 'ref_doc1': 'project.task,%d' % task.id,
908 'ref_doc2': 'project.project,%d' % project.id,
911 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
914 def do_cancel(self, cr, uid, ids, context={}):
915 request = self.pool.get('res.request')
916 tasks = self.browse(cr, uid, ids, context=context)
917 self._check_child_task(cr, uid, ids, context=context)
919 project = task.project_id
920 if project.warn_manager and project.user_id and (project.user_id.id != uid):
921 request.create(cr, uid, {
922 'name': _("Task '%s' cancelled") % task.name,
925 'act_to': project.user_id.id,
926 'ref_partner_id': task.partner_id.id,
927 'ref_doc1': 'project.task,%d' % task.id,
928 'ref_doc2': 'project.project,%d' % project.id,
930 message = _("The task '%s' is cancelled.") % (task.name,)
931 self.log(cr, uid, task.id, message)
932 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
935 def do_open(self, cr, uid, ids, context={}):
936 if not isinstance(ids,list): ids = [ids]
937 tasks= self.browse(cr, uid, ids, context=context)
939 data = {'state': 'open'}
941 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
942 self.write(cr, uid, [t.id], data, context=context)
943 message = _("The task '%s' is opened.") % (t.name,)
944 self.log(cr, uid, t.id, message)
947 def do_draft(self, cr, uid, ids, context={}):
948 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
952 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
953 attachment = self.pool.get('ir.attachment')
954 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
955 new_attachment_ids = []
956 for attachment_id in attachment_ids:
957 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
958 return new_attachment_ids
961 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
963 Delegate Task to another users.
965 assert delegate_data['user_id'], _("Delegated User should be specified")
967 for task in self.browse(cr, uid, ids, context=context):
968 delegated_task_id = self.copy(cr, uid, task.id, {
969 'name': delegate_data['name'],
970 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
971 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
972 'planned_hours': delegate_data['planned_hours'] or 0.0,
973 'parent_ids': [(6, 0, [task.id])],
975 'description': delegate_data['new_task_description'] or '',
979 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
980 newname = delegate_data['prefix'] or ''
982 'remaining_hours': delegate_data['planned_hours_me'],
983 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
986 if delegate_data['state'] == 'pending':
987 self.do_pending(cr, uid, task.id, context=context)
988 elif delegate_data['state'] == 'done':
989 self.do_close(cr, uid, task.id, context=context)
991 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
992 self.log(cr, uid, task.id, message)
993 delegated_tasks[task.id] = delegated_task_id
994 return delegated_tasks
996 def do_pending(self, cr, uid, ids, context={}):
997 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
998 for (id, name) in self.name_get(cr, uid, ids):
999 message = _("The task '%s' is pending.") % name
1000 self.log(cr, uid, id, message)
1003 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1004 for task in self.browse(cr, uid, ids, context=context):
1005 if (task.state=='draft') or (task.planned_hours==0.0):
1006 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1007 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1010 def set_remaining_time_1(self, cr, uid, ids, context=None):
1011 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1013 def set_remaining_time_2(self, cr, uid, ids, context=None):
1014 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1016 def set_remaining_time_5(self, cr, uid, ids, context=None):
1017 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1019 def set_remaining_time_10(self, cr, uid, ids, context=None):
1020 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1022 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1023 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1025 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1026 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1028 def set_kanban_state_done(self, cr, uid, ids, context=None):
1029 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1031 def _change_type(self, cr, uid, ids, next, *args):
1033 go to the next stage
1034 if next is False, go to previous stage
1036 for task in self.browse(cr, uid, ids):
1037 if task.project_id.type_ids:
1038 typeid = task.type_id.id
1040 for type in task.project_id.type_ids :
1041 types_seq[type.id] = type.sequence
1043 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
1045 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
1046 sorted_types = [x[0] for x in types]
1048 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
1049 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
1050 index = sorted_types.index(typeid)
1051 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
1054 def next_type(self, cr, uid, ids, *args):
1055 return self._change_type(cr, uid, ids, True, *args)
1057 def prev_type(self, cr, uid, ids, *args):
1058 return self._change_type(cr, uid, ids, False, *args)
1060 def _store_history(self, cr, uid, ids, context=None):
1061 for task in self.browse(cr, uid, ids, context=context):
1062 self.pool.get('project.task.history').create(cr, uid, {
1064 'remaining_hours': task.remaining_hours,
1065 'planned_hours': task.planned_hours,
1066 'kanban_state': task.kanban_state,
1067 'type_id': task.type_id.id,
1068 'state': task.state,
1069 'user_id': task.user_id.id
1074 def create(self, cr, uid, vals, context=None):
1075 result = super(task, self).create(cr, uid, vals, context=context)
1076 self._store_history(cr, uid, [result], context=context)
1079 # Overridden to reset the kanban_state to normal whenever
1080 # the stage (type_id) of the task changes.
1081 def write(self, cr, uid, ids, vals, context=None):
1082 if isinstance(ids, (int, long)):
1084 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1085 new_stage = vals.get('type_id')
1086 vals_reset_kstate = dict(vals, kanban_state='normal')
1087 for t in self.browse(cr, uid, ids, context=context):
1088 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1089 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1092 result = super(task,self).write(cr, uid, ids, vals, context=context)
1093 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1094 self._store_history(cr, uid, ids, context=context)
1097 def unlink(self, cr, uid, ids, context=None):
1100 self._check_child_task(cr, uid, ids, context=context)
1101 res = super(task, self).unlink(cr, uid, ids, context)
1104 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1105 context = context or {}
1109 if task.state in ('done','cancelled'):
1114 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1116 for t2 in task.parent_ids:
1117 start.append("up.Task_%s.end" % (t2.id,))
1121 ''' % (ident,','.join(start))
1126 ''' % (ident, 'User_'+str(task.user_id.id))
1133 class project_work(osv.osv):
1134 _name = "project.task.work"
1135 _description = "Project Task Work"
1137 'name': fields.char('Work summary', size=128),
1138 'date': fields.datetime('Date', select="1"),
1139 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1140 'hours': fields.float('Time Spent'),
1141 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1142 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1146 'user_id': lambda obj, cr, uid, context: uid,
1147 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1150 _order = "date desc"
1151 def create(self, cr, uid, vals, *args, **kwargs):
1152 if 'hours' in vals and (not vals['hours']):
1153 vals['hours'] = 0.00
1154 if 'task_id' in vals:
1155 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1156 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1158 def write(self, cr, uid, ids, vals, context=None):
1159 if 'hours' in vals and (not vals['hours']):
1160 vals['hours'] = 0.00
1162 for work in self.browse(cr, uid, ids, context=context):
1163 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))
1164 return super(project_work,self).write(cr, uid, ids, vals, context)
1166 def unlink(self, cr, uid, ids, *args, **kwargs):
1167 for work in self.browse(cr, uid, ids):
1168 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1169 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1172 class account_analytic_account(osv.osv):
1174 _inherit = 'account.analytic.account'
1175 _description = 'Analytic Account'
1177 def create(self, cr, uid, vals, context=None):
1180 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1181 vals['child_ids'] = []
1182 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1184 def unlink(self, cr, uid, ids, *args, **kwargs):
1185 project_obj = self.pool.get('project.project')
1186 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1188 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1189 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1191 account_analytic_account()
1194 # Tasks History, used for cumulative flow charts (Lean/Agile)
1197 class project_task_history(osv.osv):
1198 _name = 'project.task.history'
1199 _description = 'History of Tasks'
1200 _rec_name = 'task_id'
1202 def _get_date(self, cr, uid, ids, name, arg, context=None):
1204 for history in self.browse(cr, uid, ids, context=context):
1205 if history.state in ('done','cancelled'):
1206 result[history.id] = history.date
1208 cr.execute('''select
1211 project_task_history
1215 order by id limit 1''', (history.task_id.id, history.id))
1217 result[history.id] = res and res[0] or False
1220 def _get_related_date(self, cr, uid, ids, context=None):
1222 for history in self.browse(cr, uid, ids, context=context):
1223 cr.execute('''select
1226 project_task_history
1230 order by id desc limit 1''', (history.task_id.id, history.id))
1233 result.append(res[0])
1237 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1238 'type_id': fields.many2one('project.task.type', 'Stage'),
1239 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1240 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1241 'date': fields.date('Date', select=True),
1242 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1243 'project.task.history': (_get_related_date, None, 20)
1245 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1246 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1247 'user_id': fields.many2one('res.users', 'Responsible'),
1250 'date': fields.date.context_today,
1252 project_task_history()
1254 class project_task_history_cumulative(osv.osv):
1255 _name = 'project.task.history.cumulative'
1256 _table = 'project_task_history_cumulative'
1257 _inherit = 'project.task.history'
1260 'end_date': fields.date('End Date'),
1261 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1264 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1266 history.date::varchar||'-'||history.history_id::varchar as id,
1267 history.date as end_date,
1272 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1273 task_id, type_id, user_id, kanban_state, state,
1274 remaining_hours, planned_hours
1276 project_task_history
1280 project_task_history_cumulative()