1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
22 from lxml import etree
24 from datetime import datetime, date
26 from tools.translate import _
27 from osv import fields, osv
28 from openerp.addons.resource.faces import task as Task
30 # I think we can remove this in v6.1 since VMT's improvements in the framework ?
31 #class project_project(osv.osv):
32 # _name = 'project.project'
35 class project_task_type(osv.osv):
36 _name = 'project.task.type'
37 _description = 'Task Stage'
40 'name': fields.char('Stage Name', required=True, size=64, translate=True),
41 'description': fields.text('Description'),
42 'sequence': fields.integer('Sequence'),
43 'project_default': fields.boolean('Common to All Projects', help="If you check this field, this stage will be proposed by default on each new project. It will not assign this stage to existing projects."),
44 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
52 class project(osv.osv):
53 _name = "project.project"
54 _description = "Project"
55 _inherits = {'account.analytic.account': "analytic_account_id"}
57 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
59 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
60 if context and context.get('user_preference'):
61 cr.execute("""SELECT project.id FROM project_project project
62 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
63 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
64 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
65 return [(r[0]) for r in cr.fetchall()]
66 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
67 context=context, count=count)
69 def _complete_name(self, cr, uid, ids, name, args, context=None):
71 for m in self.browse(cr, uid, ids, context=context):
72 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
75 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
76 partner_obj = self.pool.get('res.partner')
78 return {'value':{'contact_id': False}}
79 addr = partner_obj.address_get(cr, uid, [part], ['contact'])
80 val = {'contact_id': addr['contact']}
81 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
82 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
83 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
84 val['pricelist_id'] = pricelist_id
87 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
88 res = {}.fromkeys(ids, 0.0)
92 project_id, sum(planned_hours), sum(total_hours), sum(effective_hours), SUM(remaining_hours)
99 project_id''', (tuple(ids),))
100 progress = dict(map(lambda x: (x[0], (x[1],x[2],x[3],x[4])), cr.fetchall()))
101 for project in self.browse(cr, uid, ids, context=context):
102 s = progress.get(project.id, (0.0,0.0,0.0,0.0))
104 'planned_hours': s[0],
105 'effective_hours': s[2],
107 'progress_rate': s[1] and round(100.0*s[2]/s[1],2) or 0.0
111 def _get_project_task(self, cr, uid, ids, context=None):
113 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
114 if task.project_id: result[task.project_id.id] = True
117 def unlink(self, cr, uid, ids, *args, **kwargs):
118 for proj in self.browse(cr, uid, ids):
120 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
121 return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
124 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
125 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
126 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
127 '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),
128 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
129 '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)]}),
131 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
132 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)]}),
133 'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
134 '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.",
136 'project.project': (lambda self, cr, uid, ids, c={}: ids, ['tasks'], 10),
137 'project.task': (_get_project_task, ['planned_hours', 'effective_hours', 'remaining_hours', 'total_hours', 'progress', 'delay_hours','state'], 10),
139 '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."),
140 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
141 '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.",
143 'project.project': (lambda self, cr, uid, ids, c={}: ids, ['tasks'], 10),
144 'project.task': (_get_project_task, ['planned_hours', 'effective_hours', 'remaining_hours', 'total_hours', 'progress', 'delay_hours','state'], 10),
146 '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."),
147 '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)]}),
148 '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)]}),
149 '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)]}),
150 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
152 def _get_type_common(self, cr, uid, context):
153 ids = self.pool.get('project.task.type').search(cr, uid, [('project_default','=',1)], context=context)
161 'type_ids': _get_type_common
164 # TODO: Why not using a SQL contraints ?
165 def _check_dates(self, cr, uid, ids, context=None):
166 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
167 if leave['date_start'] and leave['date']:
168 if leave['date_start'] > leave['date']:
173 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
176 def set_template(self, cr, uid, ids, context=None):
177 res = self.setActive(cr, uid, ids, value=False, context=context)
180 def set_done(self, cr, uid, ids, context=None):
181 task_obj = self.pool.get('project.task')
182 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
183 task_obj.write(cr, uid, task_ids, {'state': 'done', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
184 self.write(cr, uid, ids, {'state':'close'}, context=context)
185 for (id, name) in self.name_get(cr, uid, ids):
186 message = _("The project '%s' has been closed.") % name
187 self.log(cr, uid, id, message)
190 def set_cancel(self, cr, uid, ids, context=None):
191 task_obj = self.pool.get('project.task')
192 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
193 task_obj.write(cr, uid, task_ids, {'state': 'cancelled', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
194 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
197 def set_pending(self, cr, uid, ids, context=None):
198 self.write(cr, uid, ids, {'state':'pending'}, context=context)
201 def set_open(self, cr, uid, ids, context=None):
202 self.write(cr, uid, ids, {'state':'open'}, context=context)
205 def reset_project(self, cr, uid, ids, context=None):
206 res = self.setActive(cr, uid, ids, value=True, context=context)
207 for (id, name) in self.name_get(cr, uid, ids):
208 message = _("The project '%s' has been opened.") % name
209 self.log(cr, uid, id, message)
212 def copy(self, cr, uid, id, default={}, context=None):
216 default = default or {}
217 context['active_test'] = False
218 default['state'] = 'open'
219 proj = self.browse(cr, uid, id, context=context)
220 if not default.get('name', False):
221 default['name'] = proj.name + _(' (copy)')
223 res = super(project, self).copy(cr, uid, id, default, context)
227 def template_copy(self, cr, uid, id, default={}, context=None):
228 task_obj = self.pool.get('project.task')
229 proj = self.browse(cr, uid, id, context=context)
231 default['tasks'] = [] #avoid to copy all the task automaticly
232 res = self.copy(cr, uid, id, default=default, context=context)
234 #copy all the task manually
236 for task in proj.tasks:
237 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
239 self.write(cr, uid, res, {'tasks':[(6,0, map_task_id.values())]})
240 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
244 def duplicate_template(self, cr, uid, ids, context=None):
247 data_obj = self.pool.get('ir.model.data')
249 for proj in self.browse(cr, uid, ids, context=context):
250 parent_id = context.get('parent_id', False)
251 context.update({'analytic_project_copy': True})
252 new_date_start = time.strftime('%Y-%m-%d')
254 if proj.date_start and proj.date:
255 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
256 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
257 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
258 context.update({'copy':True})
259 new_id = self.template_copy(cr, uid, proj.id, default = {
260 'name': proj.name +_(' (copy)'),
262 'date_start':new_date_start,
264 'parent_id':parent_id}, context=context)
265 result.append(new_id)
267 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
268 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
270 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
272 if result and len(result):
274 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
275 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
276 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
277 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
278 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
279 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
281 'name': _('Projects'),
283 'view_mode': 'form,tree',
284 'res_model': 'project.project',
287 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
288 'type': 'ir.actions.act_window',
289 'search_view_id': search_view['res_id'],
293 # set active value for a project, its sub projects and its tasks
294 def setActive(self, cr, uid, ids, value=True, context=None):
295 task_obj = self.pool.get('project.task')
296 for proj in self.browse(cr, uid, ids, context=None):
297 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
298 cr.execute('select id from project_task where project_id=%s', (proj.id,))
299 tasks_id = [x[0] for x in cr.fetchall()]
301 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
302 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
304 self.setActive(cr, uid, child_ids, value, context=None)
307 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
308 context = context or {}
309 if type(ids) in (long, int,):
311 projects = self.browse(cr, uid, ids, context=context)
313 for project in projects:
314 if (not project.members) and force_members:
315 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
317 resource_pool = self.pool.get('resource.resource')
319 result = "from openerp.addons.resource.faces import *\n"
320 result += "import datetime\n"
321 for project in self.browse(cr, uid, ids, context=context):
322 u_ids = [i.id for i in project.members]
323 if project.user_id and (project.user_id.id not in u_ids):
324 u_ids.append(project.user_id.id)
325 for task in project.tasks:
326 if task.state in ('done','cancelled'):
328 if task.user_id and (task.user_id.id not in u_ids):
329 u_ids.append(task.user_id.id)
330 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
331 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
332 for key, vals in resource_objs.items():
334 class User_%s(Resource):
336 ''' % (key, vals.get('efficiency', False))
343 def _schedule_project(self, cr, uid, project, context=None):
344 resource_pool = self.pool.get('resource.resource')
345 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
346 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
347 # TODO: check if we need working_..., default values are ok.
348 puids = [x.id for x in project.members]
350 puids.append(project.user_id.id)
358 project.date_start, working_days,
359 '|'.join(['User_'+str(x) for x in puids])
361 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
368 #TODO: DO Resource allocation and compute availability
369 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
375 def schedule_tasks(self, cr, uid, ids, context=None):
376 context = context or {}
377 if type(ids) in (long, int,):
379 projects = self.browse(cr, uid, ids, context=context)
380 result = self._schedule_header(cr, uid, ids, False, context=context)
381 for project in projects:
382 result += self._schedule_project(cr, uid, project, context=context)
383 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
386 exec result in local_dict
387 projects_gantt = Task.BalancedProject(local_dict['Project'])
389 for project in projects:
390 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
391 for task in project.tasks:
392 if task.state in ('done','cancelled'):
395 p = getattr(project_gantt, 'Task_%d' % (task.id,))
397 self.pool.get('project.task').write(cr, uid, [task.id], {
398 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
399 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
401 if (not task.user_id) and (p.booked_resource):
402 self.pool.get('project.task').write(cr, uid, [task.id], {
403 'user_id': int(p.booked_resource[0].name[5:]),
408 class users(osv.osv):
409 _inherit = 'res.users'
411 'context_project_id': fields.many2one('project.project', 'Project')
416 _name = "project.task"
417 _description = "Task"
419 _date_name = "date_start"
422 def _resolve_project_id_from_context(self, cr, uid, context=None):
423 """Return ID of project based on the value of 'project_id'
424 context key, or None if it cannot be resolved to a single project.
426 if context is None: context = {}
427 if type(context.get('project_id')) in (int, long):
428 project_id = context['project_id']
430 if isinstance(context.get('project_id'), basestring):
431 project_name = context['project_id']
432 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name)
433 if len(project_ids) == 1:
434 return project_ids[0][0]
436 def _read_group_type_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
437 stage_obj = self.pool.get('project.task.type')
438 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
439 order = stage_obj._order
440 access_rights_uid = access_rights_uid or uid
441 if read_group_order == 'type_id desc':
442 # lame way to allow reverting search, should just work in the trivial case
443 order = '%s desc' % order
445 domain = ['|', ('id','in',ids), ('project_ids','in',project_id)]
447 domain = ['|', ('id','in',ids), ('project_default','=',1)]
448 stage_ids = stage_obj._search(cr, uid, domain, order=order, access_rights_uid=access_rights_uid, context=context)
449 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
450 # restore order of the search
451 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
454 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
455 res_users = self.pool.get('res.users')
456 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
457 access_rights_uid = access_rights_uid or uid
459 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
460 order = res_users._order
461 # lame way to allow reverting search, should just work in the trivial case
462 if read_group_order == 'user_id desc':
463 order = '%s desc' % order
464 # de-duplicate and apply search order
465 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
466 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
467 # restore order of the search
468 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
472 'type_id': _read_group_type_id,
473 'user_id': _read_group_user_id
477 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
478 obj_project = self.pool.get('project.project')
480 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
481 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
482 if id and isinstance(id, (long, int)):
483 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
484 args.append(('active', '=', False))
485 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
487 def _str_get(self, task, level=0, border='***', context=None):
488 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'+ \
489 border[0]+' '+(task.name or '')+'\n'+ \
490 (task.description or '')+'\n\n'
492 # Compute: effective_hours, total_hours, progress
493 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
495 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
496 hours = dict(cr.fetchall())
497 for task in self.browse(cr, uid, ids, context=context):
498 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)}
499 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
500 res[task.id]['progress'] = 0.0
501 if (task.remaining_hours + hours.get(task.id, 0.0)):
502 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
503 if task.state in ('done','cancelled'):
504 res[task.id]['progress'] = 100.0
508 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
509 if remaining and not planned:
510 return {'value':{'planned_hours': remaining}}
513 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
514 return {'value':{'remaining_hours': planned - effective}}
516 def onchange_project(self, cr, uid, id, project_id):
519 data = self.pool.get('project.project').browse(cr, uid, [project_id])
520 partner_id=data and data[0].partner_id
522 return {'value':{'partner_id':partner_id.id}}
525 def duplicate_task(self, cr, uid, map_ids, context=None):
526 for new in map_ids.values():
527 task = self.browse(cr, uid, new, context)
528 child_ids = [ ch.id for ch in task.child_ids]
530 for child in task.child_ids:
531 if child.id in map_ids.keys():
532 child_ids.remove(child.id)
533 child_ids.append(map_ids[child.id])
535 parent_ids = [ ch.id for ch in task.parent_ids]
537 for parent in task.parent_ids:
538 if parent.id in map_ids.keys():
539 parent_ids.remove(parent.id)
540 parent_ids.append(map_ids[parent.id])
541 #FIXME why there is already the copy and the old one
542 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
544 def copy_data(self, cr, uid, id, default={}, context=None):
545 default = default or {}
546 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
547 if not default.get('remaining_hours', False):
548 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
549 default['active'] = True
550 default['type_id'] = False
551 if not default.get('name', False):
552 default['name'] = self.browse(cr, uid, id, context=context).name or ''
553 if not context.get('copy',False):
554 new_name = _("%s (copy)")%default.get('name','')
555 default.update({'name':new_name})
556 return super(task, self).copy_data(cr, uid, id, default, context)
559 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
561 for task in self.browse(cr, uid, ids, context=context):
564 if task.project_id.active == False or task.project_id.state == 'template':
568 def _get_task(self, cr, uid, ids, context=None):
570 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
571 if work.task_id: result[work.task_id.id] = True
575 '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."),
576 'name': fields.char('Task Summary', size=128, required=True, select=True),
577 'description': fields.text('Description'),
578 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
579 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
580 'type_id': fields.many2one('project.task.type', 'Stage'),
581 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
582 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.\
583 \n If the task is over, the states is set to \'Done\'.'),
584 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
585 help="A task's kanban state indicates special situations affecting it:\n"
586 " * Normal is the default situation\n"
587 " * Blocked indicates something is preventing the progress of this task\n"
588 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
589 readonly=True, required=False),
590 'create_date': fields.datetime('Create Date', readonly=True,select=True),
591 'date_start': fields.datetime('Starting Date',select=True),
592 'date_end': fields.datetime('Ending Date',select=True),
593 'date_deadline': fields.date('Deadline',select=True),
594 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
595 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
596 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
597 'notes': fields.text('Notes'),
598 '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.'),
599 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
601 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
602 'project.task.work': (_get_task, ['hours'], 10),
604 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
605 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
607 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
608 'project.task.work': (_get_task, ['hours'], 10),
610 '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",
612 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
613 'project.task.work': (_get_task, ['hours'], 10),
615 'delay_hours': fields.function(_hours_get, string='Delay Hours', multi='hours', help="Computed as difference of the time estimated by the project manager and the real time to close the task.",
617 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
618 'project.task.work': (_get_task, ['hours'], 10),
620 'user_id': fields.many2one('res.users', 'Assigned to'),
621 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
622 'partner_id': fields.many2one('res.partner', 'Partner'),
623 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
624 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
625 'company_id': fields.many2one('res.company', 'Company'),
626 'id': fields.integer('ID', readonly=True),
627 'color': fields.integer('Color Index'),
628 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
633 'kanban_state': 'normal',
638 'user_id': lambda obj, cr, uid, context: uid,
639 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
642 _order = "priority, sequence, date_start, name, id"
644 def set_priority(self, cr, uid, ids, priority):
647 return self.write(cr, uid, ids, {'priority' : priority})
649 def set_high_priority(self, cr, uid, ids, *args):
650 """Set task priority to high
652 return self.set_priority(cr, uid, ids, '1')
654 def set_normal_priority(self, cr, uid, ids, *args):
655 """Set task priority to normal
657 return self.set_priority(cr, uid, ids, '3')
659 def _check_recursion(self, cr, uid, ids, context=None):
661 visited_branch = set()
663 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
669 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
670 if id in visited_branch: #Cycle
673 if id in visited_node: #Already tested don't work one more time for nothing
676 visited_branch.add(id)
679 #visit child using DFS
680 task = self.browse(cr, uid, id, context=context)
681 for child in task.child_ids:
682 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
686 visited_branch.remove(id)
689 def _check_dates(self, cr, uid, ids, context=None):
692 obj_task = self.browse(cr, uid, ids[0], context=context)
693 start = obj_task.date_start or False
694 end = obj_task.date_end or False
701 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
702 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
705 # Override view according to the company definition
707 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
708 users_obj = self.pool.get('res.users')
710 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
711 # this should be safe (no context passed to avoid side-effects)
712 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
713 tm = obj_tm and obj_tm.name or 'Hours'
715 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
717 if tm in ['Hours','Hour']:
720 eview = etree.fromstring(res['arch'])
722 def _check_rec(eview):
723 if eview.attrib.get('widget','') == 'float_time':
724 eview.set('widget','float')
731 res['arch'] = etree.tostring(eview)
733 for f in res['fields']:
734 if 'Hours' in res['fields'][f]['string']:
735 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
738 def _check_child_task(self, cr, uid, ids, context=None):
741 tasks = self.browse(cr, uid, ids, context=context)
744 for child in task.child_ids:
745 if child.state in ['draft', 'open', 'pending']:
746 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
749 def action_close(self, cr, uid, ids, context=None):
750 # This action open wizard to send email to partner or project manager after close task.
753 task_id = len(ids) and ids[0] or False
754 self._check_child_task(cr, uid, ids, context=context)
755 if not task_id: return False
756 task = self.browse(cr, uid, task_id, context=context)
757 project = task.project_id
758 res = self.do_close(cr, uid, [task_id], context=context)
759 if project.warn_manager or project.warn_customer:
761 'name': _('Send Email after close task'),
764 'res_model': 'mail.compose.message',
765 'type': 'ir.actions.act_window',
768 'context': {'active_id': task.id,
769 'active_model': 'project.task'}
773 def do_close(self, cr, uid, ids, context={}):
777 request = self.pool.get('res.request')
778 if not isinstance(ids,list): ids = [ids]
779 for task in self.browse(cr, uid, ids, context=context):
781 project = task.project_id
783 # Send request to project manager
784 if project.warn_manager and project.user_id and (project.user_id.id != uid):
785 request.create(cr, uid, {
786 'name': _("Task '%s' closed") % task.name,
789 'act_to': project.user_id.id,
790 'ref_partner_id': task.partner_id.id,
791 'ref_doc1': 'project.task,%d'% (task.id,),
792 'ref_doc2': 'project.project,%d'% (project.id,),
795 for parent_id in task.parent_ids:
796 if parent_id.state in ('pending','draft'):
798 for child in parent_id.child_ids:
799 if child.id != task.id and child.state not in ('done','cancelled'):
802 self.do_reopen(cr, uid, [parent_id.id], context=context)
803 vals.update({'state': 'done'})
804 vals.update({'remaining_hours': 0.0})
805 if not task.date_end:
806 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
807 self.write(cr, uid, [task.id],vals, context=context)
808 message = _("The task '%s' is done") % (task.name,)
809 self.log(cr, uid, task.id, message)
812 def do_reopen(self, cr, uid, ids, context=None):
813 request = self.pool.get('res.request')
815 for task in self.browse(cr, uid, ids, context=context):
816 project = task.project_id
817 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
818 request.create(cr, uid, {
819 'name': _("Task '%s' set in progress") % task.name,
822 'act_to': project.user_id.id,
823 'ref_partner_id': task.partner_id.id,
824 'ref_doc1': 'project.task,%d' % task.id,
825 'ref_doc2': 'project.project,%d' % project.id,
828 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
831 def do_cancel(self, cr, uid, ids, context={}):
832 request = self.pool.get('res.request')
833 tasks = self.browse(cr, uid, ids, context=context)
834 self._check_child_task(cr, uid, ids, context=context)
836 project = task.project_id
837 if project.warn_manager and project.user_id and (project.user_id.id != uid):
838 request.create(cr, uid, {
839 'name': _("Task '%s' cancelled") % task.name,
842 'act_to': project.user_id.id,
843 'ref_partner_id': task.partner_id.id,
844 'ref_doc1': 'project.task,%d' % task.id,
845 'ref_doc2': 'project.project,%d' % project.id,
847 message = _("The task '%s' is cancelled.") % (task.name,)
848 self.log(cr, uid, task.id, message)
849 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
852 def do_open(self, cr, uid, ids, context={}):
853 if not isinstance(ids,list): ids = [ids]
854 tasks= self.browse(cr, uid, ids, context=context)
856 data = {'state': 'open'}
858 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
859 self.write(cr, uid, [t.id], data, context=context)
860 message = _("The task '%s' is opened.") % (t.name,)
861 self.log(cr, uid, t.id, message)
864 def do_draft(self, cr, uid, ids, context={}):
865 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
869 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
870 attachment = self.pool.get('ir.attachment')
871 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
872 new_attachment_ids = []
873 for attachment_id in attachment_ids:
874 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
875 return new_attachment_ids
878 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
880 Delegate Task to another users.
882 assert delegate_data['user_id'], _("Delegated User should be specified")
884 for task in self.browse(cr, uid, ids, context=context):
885 delegated_task_id = self.copy(cr, uid, task.id, {
886 'name': delegate_data['name'],
887 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
888 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
889 'planned_hours': delegate_data['planned_hours'] or 0.0,
890 'parent_ids': [(6, 0, [task.id])],
892 'description': delegate_data['new_task_description'] or '',
896 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
897 newname = delegate_data['prefix'] or ''
899 'remaining_hours': delegate_data['planned_hours_me'],
900 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
903 if delegate_data['state'] == 'pending':
904 self.do_pending(cr, uid, task.id, context=context)
905 elif delegate_data['state'] == 'done':
906 self.do_close(cr, uid, task.id, context=context)
908 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
909 self.log(cr, uid, task.id, message)
910 delegated_tasks[task.id] = delegated_task_id
911 return delegated_tasks
913 def do_pending(self, cr, uid, ids, context={}):
914 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
915 for (id, name) in self.name_get(cr, uid, ids):
916 message = _("The task '%s' is pending.") % name
917 self.log(cr, uid, id, message)
920 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
921 for task in self.browse(cr, uid, ids, context=context):
922 if (task.state=='draft') or (task.planned_hours==0.0):
923 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
924 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
927 def set_remaining_time_1(self, cr, uid, ids, context=None):
928 return self.set_remaining_time(cr, uid, ids, 1.0, context)
930 def set_remaining_time_2(self, cr, uid, ids, context=None):
931 return self.set_remaining_time(cr, uid, ids, 2.0, context)
933 def set_remaining_time_5(self, cr, uid, ids, context=None):
934 return self.set_remaining_time(cr, uid, ids, 5.0, context)
936 def set_remaining_time_10(self, cr, uid, ids, context=None):
937 return self.set_remaining_time(cr, uid, ids, 10.0, context)
939 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
940 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
942 def set_kanban_state_normal(self, cr, uid, ids, context=None):
943 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
945 def set_kanban_state_done(self, cr, uid, ids, context=None):
946 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
948 def _change_type(self, cr, uid, ids, next, *args):
951 if next is False, go to previous stage
953 for task in self.browse(cr, uid, ids):
954 if task.project_id.type_ids:
955 typeid = task.type_id.id
957 for type in task.project_id.type_ids :
958 types_seq[type.id] = type.sequence
960 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
962 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
963 sorted_types = [x[0] for x in types]
965 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
966 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
967 index = sorted_types.index(typeid)
968 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
971 def next_type(self, cr, uid, ids, *args):
972 return self._change_type(cr, uid, ids, True, *args)
974 def prev_type(self, cr, uid, ids, *args):
975 return self._change_type(cr, uid, ids, False, *args)
977 def _store_history(self, cr, uid, ids, context=None):
978 for task in self.browse(cr, uid, ids, context=context):
979 self.pool.get('project.task.history').create(cr, uid, {
981 'remaining_hours': task.remaining_hours,
982 'planned_hours': task.planned_hours,
983 'kanban_state': task.kanban_state,
984 'type_id': task.type_id.id,
986 'user_id': task.user_id.id
991 def create(self, cr, uid, vals, context=None):
992 result = super(task, self).create(cr, uid, vals, context=context)
993 self._store_history(cr, uid, [result], context=context)
996 # Overridden to reset the kanban_state to normal whenever
997 # the stage (type_id) of the task changes.
998 def write(self, cr, uid, ids, vals, context=None):
999 if isinstance(ids, (int, long)):
1001 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1002 new_stage = vals.get('type_id')
1003 vals_reset_kstate = dict(vals, kanban_state='normal')
1004 for t in self.browse(cr, uid, ids, context=context):
1005 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1006 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1009 result = super(task,self).write(cr, uid, ids, vals, context=context)
1010 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1011 self._store_history(cr, uid, ids, context=context)
1014 def unlink(self, cr, uid, ids, context=None):
1017 self._check_child_task(cr, uid, ids, context=context)
1018 res = super(task, self).unlink(cr, uid, ids, context)
1021 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1022 context = context or {}
1026 if task.state in ('done','cancelled'):
1031 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1033 for t2 in task.parent_ids:
1034 start.append("up.Task_%s.end" % (t2.id,))
1038 ''' % (ident,','.join(start))
1043 ''' % (ident, 'User_'+str(task.user_id.id))
1050 class project_work(osv.osv):
1051 _name = "project.task.work"
1052 _description = "Project Task Work"
1054 'name': fields.char('Work summary', size=128),
1055 'date': fields.datetime('Date', select="1"),
1056 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1057 'hours': fields.float('Time Spent'),
1058 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1059 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1063 'user_id': lambda obj, cr, uid, context: uid,
1064 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1067 _order = "date desc"
1068 def create(self, cr, uid, vals, *args, **kwargs):
1069 if 'hours' in vals and (not vals['hours']):
1070 vals['hours'] = 0.00
1071 if 'task_id' in vals:
1072 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1073 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1075 def write(self, cr, uid, ids, vals, context=None):
1076 if 'hours' in vals and (not vals['hours']):
1077 vals['hours'] = 0.00
1079 for work in self.browse(cr, uid, ids, context=context):
1080 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))
1081 return super(project_work,self).write(cr, uid, ids, vals, context)
1083 def unlink(self, cr, uid, ids, *args, **kwargs):
1084 for work in self.browse(cr, uid, ids):
1085 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1086 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1089 class account_analytic_account(osv.osv):
1091 _inherit = 'account.analytic.account'
1092 _description = 'Analytic Account'
1094 def create(self, cr, uid, vals, context=None):
1097 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1098 vals['child_ids'] = []
1099 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1101 def unlink(self, cr, uid, ids, *args, **kwargs):
1102 project_obj = self.pool.get('project.project')
1103 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1105 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1106 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1108 account_analytic_account()
1111 # Tasks History, used for cumulative flow charts (Lean/Agile)
1114 class project_task_history(osv.osv):
1115 _name = 'project.task.history'
1116 _description = 'History of Tasks'
1117 _rec_name = 'task_id'
1119 def _get_date(self, cr, uid, ids, name, arg, context=None):
1121 for history in self.browse(cr, uid, ids, context=context):
1122 if history.state in ('done','cancelled'):
1123 result[history.id] = history.date
1125 cr.execute('''select
1128 project_task_history
1132 order by id limit 1''', (history.task_id.id, history.id))
1134 result[history.id] = res and res[0] or False
1137 def _get_related_date(self, cr, uid, ids, context=None):
1139 for history in self.browse(cr, uid, ids, context=context):
1140 cr.execute('''select
1143 project_task_history
1147 order by id desc limit 1''', (history.task_id.id, history.id))
1150 result.append(res[0])
1154 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1155 'type_id': fields.many2one('project.task.type', 'Stage'),
1156 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1157 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1158 'date': fields.date('Date', select=True),
1159 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1160 'project.task.history': (_get_related_date, None, 20)
1162 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1163 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1164 'user_id': fields.many2one('res.users', 'Responsible'),
1167 'date': lambda s,c,u,ctx: time.strftime('%Y-%m-%d')
1169 project_task_history()
1171 class project_task_history_cumulative(osv.osv):
1172 _name = 'project.task.history.cumulative'
1173 _table = 'project_task_history_cumulative'
1174 _inherit = 'project.task.history'
1177 'end_date': fields.date('End Date'),
1178 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1181 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1183 history.date::varchar||'-'||history.history_id::varchar as id,
1184 history.date as end_date,
1189 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1190 task_id, type_id, user_id, kanban_state, state,
1191 remaining_hours, planned_hours
1193 project_task_history
1197 project_task_history_cumulative()