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 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 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].parent_id.partner_id
522 return {'value':{'partner_id':partner_id.id}}
525 def _default_project(self, cr, uid, context=None):
528 if 'project_id' in context and context['project_id']:
529 return int(context['project_id'])
532 def duplicate_task(self, cr, uid, map_ids, context=None):
533 for new in map_ids.values():
534 task = self.browse(cr, uid, new, context)
535 child_ids = [ ch.id for ch in task.child_ids]
537 for child in task.child_ids:
538 if child.id in map_ids.keys():
539 child_ids.remove(child.id)
540 child_ids.append(map_ids[child.id])
542 parent_ids = [ ch.id for ch in task.parent_ids]
544 for parent in task.parent_ids:
545 if parent.id in map_ids.keys():
546 parent_ids.remove(parent.id)
547 parent_ids.append(map_ids[parent.id])
548 #FIXME why there is already the copy and the old one
549 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
551 def copy_data(self, cr, uid, id, default={}, context=None):
552 default = default or {}
553 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
554 if not default.get('remaining_hours', False):
555 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
556 default['active'] = True
557 default['type_id'] = False
558 if not default.get('name', False):
559 default['name'] = self.browse(cr, uid, id, context=context).name or ''
560 if not context.get('copy',False):
561 new_name = _("%s (copy)")%default.get('name','')
562 default.update({'name':new_name})
563 return super(task, self).copy_data(cr, uid, id, default, context)
566 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
568 for task in self.browse(cr, uid, ids, context=context):
571 if task.project_id.active == False or task.project_id.state == 'template':
575 def _get_task(self, cr, uid, ids, context=None):
577 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
578 if work.task_id: result[work.task_id.id] = True
582 '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."),
583 'name': fields.char('Task Summary', size=128, required=True, select=True),
584 'description': fields.text('Description'),
585 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
586 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
587 'type_id': fields.many2one('project.task.type', 'Stage'),
588 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
589 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.\
590 \n If the task is over, the states is set to \'Done\'.'),
591 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
592 help="A task's kanban state indicates special situations affecting it:\n"
593 " * Normal is the default situation\n"
594 " * Blocked indicates something is preventing the progress of this task\n"
595 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
596 readonly=True, required=False),
597 'create_date': fields.datetime('Create Date', readonly=True,select=True),
598 'date_start': fields.datetime('Starting Date',select=True),
599 'date_end': fields.datetime('Ending Date',select=True),
600 'date_deadline': fields.date('Deadline',select=True),
601 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
602 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
603 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
604 'notes': fields.text('Notes'),
605 '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.'),
606 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
608 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
609 'project.task.work': (_get_task, ['hours'], 10),
611 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
612 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
614 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
615 'project.task.work': (_get_task, ['hours'], 10),
617 '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",
619 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
620 'project.task.work': (_get_task, ['hours'], 10),
622 '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.",
624 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
625 'project.task.work': (_get_task, ['hours'], 10),
627 'user_id': fields.many2one('res.users', 'Assigned to'),
628 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
629 'partner_id': fields.many2one('res.partner', 'Partner'),
630 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
631 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
632 'company_id': fields.many2one('res.company', 'Company'),
633 'id': fields.integer('ID', readonly=True),
634 'color': fields.integer('Color Index'),
635 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
640 'kanban_state': 'normal',
645 'project_id': _default_project,
646 'user_id': lambda obj, cr, uid, context: uid,
647 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
650 _order = "priority, sequence, date_start, name, id"
652 def set_priority(self, cr, uid, ids, priority):
655 return self.write(cr, uid, ids, {'priority' : priority})
657 def set_high_priority(self, cr, uid, ids, *args):
658 """Set task priority to high
660 return self.set_priority(cr, uid, ids, '1')
662 def set_normal_priority(self, cr, uid, ids, *args):
663 """Set task priority to normal
665 return self.set_priority(cr, uid, ids, '3')
667 def _check_recursion(self, cr, uid, ids, context=None):
669 visited_branch = set()
671 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
677 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
678 if id in visited_branch: #Cycle
681 if id in visited_node: #Already tested don't work one more time for nothing
684 visited_branch.add(id)
687 #visit child using DFS
688 task = self.browse(cr, uid, id, context=context)
689 for child in task.child_ids:
690 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
694 visited_branch.remove(id)
697 def _check_dates(self, cr, uid, ids, context=None):
700 obj_task = self.browse(cr, uid, ids[0], context=context)
701 start = obj_task.date_start or False
702 end = obj_task.date_end or False
709 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
710 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
713 # Override view according to the company definition
715 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
716 users_obj = self.pool.get('res.users')
718 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
719 # this should be safe (no context passed to avoid side-effects)
720 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
721 tm = obj_tm and obj_tm.name or 'Hours'
723 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
725 if tm in ['Hours','Hour']:
728 eview = etree.fromstring(res['arch'])
730 def _check_rec(eview):
731 if eview.attrib.get('widget','') == 'float_time':
732 eview.set('widget','float')
739 res['arch'] = etree.tostring(eview)
741 for f in res['fields']:
742 if 'Hours' in res['fields'][f]['string']:
743 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
746 def _check_child_task(self, cr, uid, ids, context=None):
749 tasks = self.browse(cr, uid, ids, context=context)
752 for child in task.child_ids:
753 if child.state in ['draft', 'open', 'pending']:
754 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
757 def action_close(self, cr, uid, ids, context=None):
758 # This action open wizard to send email to partner or project manager after close task.
761 task_id = len(ids) and ids[0] or False
762 self._check_child_task(cr, uid, ids, context=context)
763 if not task_id: return False
764 task = self.browse(cr, uid, task_id, context=context)
765 project = task.project_id
766 res = self.do_close(cr, uid, [task_id], context=context)
767 if project.warn_manager or project.warn_customer:
769 'name': _('Send Email after close task'),
772 'res_model': 'mail.compose.message',
773 'type': 'ir.actions.act_window',
776 'context': {'active_id': task.id,
777 'active_model': 'project.task'}
781 def do_close(self, cr, uid, ids, context={}):
785 request = self.pool.get('res.request')
786 if not isinstance(ids,list): ids = [ids]
787 for task in self.browse(cr, uid, ids, context=context):
789 project = task.project_id
791 # Send request to project manager
792 if project.warn_manager and project.user_id and (project.user_id.id != uid):
793 request.create(cr, uid, {
794 'name': _("Task '%s' closed") % task.name,
797 'act_to': project.user_id.id,
798 'ref_partner_id': task.partner_id.id,
799 'ref_doc1': 'project.task,%d'% (task.id,),
800 'ref_doc2': 'project.project,%d'% (project.id,),
803 for parent_id in task.parent_ids:
804 if parent_id.state in ('pending','draft'):
806 for child in parent_id.child_ids:
807 if child.id != task.id and child.state not in ('done','cancelled'):
810 self.do_reopen(cr, uid, [parent_id.id], context=context)
811 vals.update({'state': 'done'})
812 vals.update({'remaining_hours': 0.0})
813 if not task.date_end:
814 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
815 self.write(cr, uid, [task.id],vals, context=context)
816 message = _("The task '%s' is done") % (task.name,)
817 self.log(cr, uid, task.id, message)
820 def do_reopen(self, cr, uid, ids, context=None):
821 request = self.pool.get('res.request')
823 for task in self.browse(cr, uid, ids, context=context):
824 project = task.project_id
825 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
826 request.create(cr, uid, {
827 'name': _("Task '%s' set in progress") % task.name,
830 'act_to': project.user_id.id,
831 'ref_partner_id': task.partner_id.id,
832 'ref_doc1': 'project.task,%d' % task.id,
833 'ref_doc2': 'project.project,%d' % project.id,
836 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
839 def do_cancel(self, cr, uid, ids, context={}):
840 request = self.pool.get('res.request')
841 tasks = self.browse(cr, uid, ids, context=context)
842 self._check_child_task(cr, uid, ids, context=context)
844 project = task.project_id
845 if project.warn_manager and project.user_id and (project.user_id.id != uid):
846 request.create(cr, uid, {
847 'name': _("Task '%s' cancelled") % task.name,
850 'act_to': project.user_id.id,
851 'ref_partner_id': task.partner_id.id,
852 'ref_doc1': 'project.task,%d' % task.id,
853 'ref_doc2': 'project.project,%d' % project.id,
855 message = _("The task '%s' is cancelled.") % (task.name,)
856 self.log(cr, uid, task.id, message)
857 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
860 def do_open(self, cr, uid, ids, context={}):
861 if not isinstance(ids,list): ids = [ids]
862 tasks= self.browse(cr, uid, ids, context=context)
864 data = {'state': 'open'}
866 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
867 self.write(cr, uid, [t.id], data, context=context)
868 message = _("The task '%s' is opened.") % (t.name,)
869 self.log(cr, uid, t.id, message)
872 def do_draft(self, cr, uid, ids, context={}):
873 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
877 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
878 attachment = self.pool.get('ir.attachment')
879 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
880 new_attachment_ids = []
881 for attachment_id in attachment_ids:
882 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
883 return new_attachment_ids
886 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
888 Delegate Task to another users.
890 assert delegate_data['user_id'], _("Delegated User should be specified")
892 for task in self.browse(cr, uid, ids, context=context):
893 delegated_task_id = self.copy(cr, uid, task.id, {
894 'name': delegate_data['name'],
895 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
896 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
897 'planned_hours': delegate_data['planned_hours'] or 0.0,
898 'parent_ids': [(6, 0, [task.id])],
900 'description': delegate_data['new_task_description'] or '',
904 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
905 newname = delegate_data['prefix'] or ''
907 'remaining_hours': delegate_data['planned_hours_me'],
908 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
911 if delegate_data['state'] == 'pending':
912 self.do_pending(cr, uid, task.id, context=context)
913 elif delegate_data['state'] == 'done':
914 self.do_close(cr, uid, task.id, context=context)
916 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
917 self.log(cr, uid, task.id, message)
918 delegated_tasks[task.id] = delegated_task_id
919 return delegated_tasks
921 def do_pending(self, cr, uid, ids, context={}):
922 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
923 for (id, name) in self.name_get(cr, uid, ids):
924 message = _("The task '%s' is pending.") % name
925 self.log(cr, uid, id, message)
928 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
929 for task in self.browse(cr, uid, ids, context=context):
930 if (task.state=='draft') or (task.planned_hours==0.0):
931 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
932 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
935 def set_remaining_time_1(self, cr, uid, ids, context=None):
936 return self.set_remaining_time(cr, uid, ids, 1.0, context)
938 def set_remaining_time_2(self, cr, uid, ids, context=None):
939 return self.set_remaining_time(cr, uid, ids, 2.0, context)
941 def set_remaining_time_5(self, cr, uid, ids, context=None):
942 return self.set_remaining_time(cr, uid, ids, 5.0, context)
944 def set_remaining_time_10(self, cr, uid, ids, context=None):
945 return self.set_remaining_time(cr, uid, ids, 10.0, context)
947 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
948 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
950 def set_kanban_state_normal(self, cr, uid, ids, context=None):
951 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
953 def set_kanban_state_done(self, cr, uid, ids, context=None):
954 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
956 def _change_type(self, cr, uid, ids, next, *args):
959 if next is False, go to previous stage
961 for task in self.browse(cr, uid, ids):
962 if task.project_id.type_ids:
963 typeid = task.type_id.id
965 for type in task.project_id.type_ids :
966 types_seq[type.id] = type.sequence
968 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
970 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
971 sorted_types = [x[0] for x in types]
973 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
974 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
975 index = sorted_types.index(typeid)
976 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
979 def next_type(self, cr, uid, ids, *args):
980 return self._change_type(cr, uid, ids, True, *args)
982 def prev_type(self, cr, uid, ids, *args):
983 return self._change_type(cr, uid, ids, False, *args)
985 def _store_history(self, cr, uid, ids, context=None):
986 for task in self.browse(cr, uid, ids, context=context):
987 self.pool.get('project.task.history').create(cr, uid, {
989 'remaining_hours': task.remaining_hours,
990 'planned_hours': task.planned_hours,
991 'kanban_state': task.kanban_state,
992 'type_id': task.type_id.id,
994 'user_id': task.user_id.id
999 def create(self, cr, uid, vals, context=None):
1000 result = super(task, self).create(cr, uid, vals, context=context)
1001 self._store_history(cr, uid, [result], context=context)
1004 # Overridden to reset the kanban_state to normal whenever
1005 # the stage (type_id) of the task changes.
1006 def write(self, cr, uid, ids, vals, context=None):
1007 if isinstance(ids, (int, long)):
1009 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1010 new_stage = vals.get('type_id')
1011 vals_reset_kstate = dict(vals, kanban_state='normal')
1012 for t in self.browse(cr, uid, ids, context=context):
1013 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1014 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1017 result = super(task,self).write(cr, uid, ids, vals, context=context)
1018 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1019 self._store_history(cr, uid, ids, context=context)
1022 def unlink(self, cr, uid, ids, context=None):
1025 self._check_child_task(cr, uid, ids, context=context)
1026 res = super(task, self).unlink(cr, uid, ids, context)
1029 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1030 context = context or {}
1034 if task.state in ('done','cancelled'):
1039 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1041 for t2 in task.parent_ids:
1042 start.append("up.Task_%s.end" % (t2.id,))
1046 ''' % (ident,','.join(start))
1051 ''' % (ident, 'User_'+str(task.user_id.id))
1058 class project_work(osv.osv):
1059 _name = "project.task.work"
1060 _description = "Project Task Work"
1062 'name': fields.char('Work summary', size=128),
1063 'date': fields.datetime('Date', select="1"),
1064 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1065 'hours': fields.float('Time Spent'),
1066 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1067 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1071 'user_id': lambda obj, cr, uid, context: uid,
1072 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1075 _order = "date desc"
1076 def create(self, cr, uid, vals, *args, **kwargs):
1077 if 'hours' in vals and (not vals['hours']):
1078 vals['hours'] = 0.00
1079 if 'task_id' in vals:
1080 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1081 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1083 def write(self, cr, uid, ids, vals, context=None):
1084 if 'hours' in vals and (not vals['hours']):
1085 vals['hours'] = 0.00
1087 for work in self.browse(cr, uid, ids, context=context):
1088 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))
1089 return super(project_work,self).write(cr, uid, ids, vals, context)
1091 def unlink(self, cr, uid, ids, *args, **kwargs):
1092 for work in self.browse(cr, uid, ids):
1093 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1094 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1097 class account_analytic_account(osv.osv):
1099 _inherit = 'account.analytic.account'
1100 _description = 'Analytic Account'
1102 def create(self, cr, uid, vals, context=None):
1105 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1106 vals['child_ids'] = []
1107 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1109 def unlink(self, cr, uid, ids, *args, **kwargs):
1110 project_obj = self.pool.get('project.project')
1111 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1113 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1114 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1116 account_analytic_account()
1119 # Tasks History, used for cumulative flow charts (Lean/Agile)
1122 class project_task_history(osv.osv):
1123 _name = 'project.task.history'
1124 _description = 'History of Tasks'
1125 _rec_name = 'task_id'
1127 def _get_date(self, cr, uid, ids, name, arg, context=None):
1129 for history in self.browse(cr, uid, ids, context=context):
1130 if history.state in ('done','cancelled'):
1131 result[history.id] = history.date
1133 cr.execute('''select
1136 project_task_history
1140 order by id limit 1''', (history.task_id.id, history.id))
1142 result[history.id] = res and res[0] or False
1145 def _get_related_date(self, cr, uid, ids, context=None):
1147 for history in self.browse(cr, uid, ids, context=context):
1148 cr.execute('''select
1151 project_task_history
1155 order by id desc limit 1''', (history.task_id.id, history.id))
1158 result.append(res[0])
1162 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1163 'type_id': fields.many2one('project.task.type', 'Stage'),
1164 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1165 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1166 'date': fields.date('Date', select=True),
1167 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1168 'project.task.history': (_get_related_date, None, 20)
1170 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1171 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1172 'user_id': fields.many2one('res.users', 'Responsible'),
1175 'date': lambda s,c,u,ctx: time.strftime('%Y-%m-%d')
1177 project_task_history()
1179 class project_task_history_cumulative(osv.osv):
1180 _name = 'project.task.history.cumulative'
1181 _table = 'project_task_history_cumulative'
1182 _inherit = 'project.task.history'
1185 'end_date': fields.date('End Date'),
1186 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1189 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1191 history.date::varchar||'-'||history.history_id::varchar as id,
1192 history.date as end_date,
1197 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1198 task_id, type_id, user_id, kanban_state, state,
1199 remaining_hours, planned_hours
1201 project_task_history
1205 project_task_history_cumulative()