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
118 def _get_project_work(self, cr, uid, ids, context=None):
120 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
121 if work.task_id and work.task_id.project_id: result[work.task_id.project_id.id] = True
124 def unlink(self, cr, uid, ids, *args, **kwargs):
125 for proj in self.browse(cr, uid, ids):
127 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
128 return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
131 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
132 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
133 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
134 '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),
135 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
136 'warn_manager': fields.boolean('Warn Manager', help="If you check this field, the project manager will receive a request each time a task is completed by his team.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
138 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
139 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)]}),
140 'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
141 '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.",
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 '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."),
147 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
148 '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.",
150 'project.project': (lambda self, cr, uid, ids, c={}: ids, ['tasks'], 10),
151 'project.task': (_get_project_task, ['planned_hours', 'effective_hours', 'remaining_hours', 'total_hours', 'progress', 'delay_hours','state'], 10),
153 '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."),
154 '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)]}),
155 '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)]}),
156 '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)]}),
157 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
159 def _get_type_common(self, cr, uid, context):
160 ids = self.pool.get('project.task.type').search(cr, uid, [('project_default','=',1)], context=context)
168 'type_ids': _get_type_common
171 # TODO: Why not using a SQL contraints ?
172 def _check_dates(self, cr, uid, ids, context=None):
173 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
174 if leave['date_start'] and leave['date']:
175 if leave['date_start'] > leave['date']:
180 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
183 def set_template(self, cr, uid, ids, context=None):
184 res = self.setActive(cr, uid, ids, value=False, context=context)
187 def set_done(self, cr, uid, ids, context=None):
188 task_obj = self.pool.get('project.task')
189 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
190 task_obj.write(cr, uid, task_ids, {'state': 'done', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
191 self.write(cr, uid, ids, {'state':'close'}, context=context)
192 for (id, name) in self.name_get(cr, uid, ids):
193 message = _("The project '%s' has been closed.") % name
194 self.log(cr, uid, id, message)
197 def set_cancel(self, cr, uid, ids, context=None):
198 task_obj = self.pool.get('project.task')
199 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
200 task_obj.write(cr, uid, task_ids, {'state': 'cancelled', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
201 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
204 def set_pending(self, cr, uid, ids, context=None):
205 self.write(cr, uid, ids, {'state':'pending'}, context=context)
208 def set_open(self, cr, uid, ids, context=None):
209 self.write(cr, uid, ids, {'state':'open'}, context=context)
212 def reset_project(self, cr, uid, ids, context=None):
213 res = self.setActive(cr, uid, ids, value=True, context=context)
214 for (id, name) in self.name_get(cr, uid, ids):
215 message = _("The project '%s' has been opened.") % name
216 self.log(cr, uid, id, message)
219 def copy(self, cr, uid, id, default={}, context=None):
223 default = default or {}
224 context['active_test'] = False
225 default['state'] = 'open'
226 proj = self.browse(cr, uid, id, context=context)
227 if not default.get('name', False):
228 default['name'] = proj.name + _(' (copy)')
230 res = super(project, self).copy(cr, uid, id, default, context)
234 def template_copy(self, cr, uid, id, default={}, context=None):
235 task_obj = self.pool.get('project.task')
236 proj = self.browse(cr, uid, id, context=context)
238 default['tasks'] = [] #avoid to copy all the task automaticly
239 res = self.copy(cr, uid, id, default=default, context=context)
241 #copy all the task manually
243 for task in proj.tasks:
244 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
246 self.write(cr, uid, res, {'tasks':[(6,0, map_task_id.values())]})
247 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
251 def duplicate_template(self, cr, uid, ids, context=None):
254 data_obj = self.pool.get('ir.model.data')
256 for proj in self.browse(cr, uid, ids, context=context):
257 parent_id = context.get('parent_id', False)
258 context.update({'analytic_project_copy': True})
259 new_date_start = time.strftime('%Y-%m-%d')
261 if proj.date_start and proj.date:
262 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
263 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
264 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
265 context.update({'copy':True})
266 new_id = self.template_copy(cr, uid, proj.id, default = {
267 'name': proj.name +_(' (copy)'),
269 'date_start':new_date_start,
271 'parent_id':parent_id}, context=context)
272 result.append(new_id)
274 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
275 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
277 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
279 if result and len(result):
281 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
282 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
283 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
284 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
285 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
286 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
288 'name': _('Projects'),
290 'view_mode': 'form,tree',
291 'res_model': 'project.project',
294 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
295 'type': 'ir.actions.act_window',
296 'search_view_id': search_view['res_id'],
300 # set active value for a project, its sub projects and its tasks
301 def setActive(self, cr, uid, ids, value=True, context=None):
302 task_obj = self.pool.get('project.task')
303 for proj in self.browse(cr, uid, ids, context=None):
304 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
305 cr.execute('select id from project_task where project_id=%s', (proj.id,))
306 tasks_id = [x[0] for x in cr.fetchall()]
308 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
309 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
311 self.setActive(cr, uid, child_ids, value, context=None)
314 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
315 context = context or {}
316 if type(ids) in (long, int,):
318 projects = self.browse(cr, uid, ids, context=context)
320 for project in projects:
321 if (not project.members) and force_members:
322 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
324 resource_pool = self.pool.get('resource.resource')
326 result = "from resource.faces import *\n"
327 result += "import datetime\n"
328 for project in self.browse(cr, uid, ids, context=context):
329 u_ids = [i.id for i in project.members]
330 if project.user_id and (project.user_id.id not in u_ids):
331 u_ids.append(project.user_id.id)
332 for task in project.tasks:
333 if task.state in ('done','cancelled'):
335 if task.user_id and (task.user_id.id not in u_ids):
336 u_ids.append(task.user_id.id)
337 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
338 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
339 for key, vals in resource_objs.items():
341 class User_%s(Resource):
343 ''' % (key, vals.get('efficiency', False))
350 def _schedule_project(self, cr, uid, project, context=None):
351 resource_pool = self.pool.get('resource.resource')
352 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
353 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
354 # TODO: check if we need working_..., default values are ok.
355 puids = [x.id for x in project.members]
357 puids.append(project.user_id.id)
365 project.date_start, working_days,
366 '|'.join(['User_'+str(x) for x in puids])
368 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
375 #TODO: DO Resource allocation and compute availability
376 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
382 def schedule_tasks(self, cr, uid, ids, context=None):
383 context = context or {}
384 if type(ids) in (long, int,):
386 projects = self.browse(cr, uid, ids, context=context)
387 result = self._schedule_header(cr, uid, ids, False, context=context)
388 for project in projects:
389 result += self._schedule_project(cr, uid, project, context=context)
390 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
393 exec result in local_dict
394 projects_gantt = Task.BalancedProject(local_dict['Project'])
396 for project in projects:
397 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
398 for task in project.tasks:
399 if task.state in ('done','cancelled'):
402 p = getattr(project_gantt, 'Task_%d' % (task.id,))
404 self.pool.get('project.task').write(cr, uid, [task.id], {
405 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
406 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
408 if (not task.user_id) and (p.booked_resource):
409 self.pool.get('project.task').write(cr, uid, [task.id], {
410 'user_id': int(p.booked_resource[0].name[5:]),
415 class users(osv.osv):
416 _inherit = 'res.users'
418 'context_project_id': fields.many2one('project.project', 'Project')
423 _name = "project.task"
424 _description = "Task"
426 _date_name = "date_start"
429 def _resolve_project_id_from_context(self, cr, uid, context=None):
430 """Return ID of project based on the value of 'project_id'
431 context key, or None if it cannot be resolved to a single project.
433 if context is None: context = {}
434 if type(context.get('project_id')) in (int, long):
435 project_id = context['project_id']
437 if isinstance(context.get('project_id'), basestring):
438 project_name = context['project_id']
439 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name)
440 if len(project_ids) == 1:
441 return project_ids[0][0]
443 def _read_group_type_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
444 stage_obj = self.pool.get('project.task.type')
445 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
446 order = stage_obj._order
447 access_rights_uid = access_rights_uid or uid
448 if read_group_order == 'type_id desc':
449 # lame way to allow reverting search, should just work in the trivial case
450 order = '%s desc' % order
452 domain = ['|', ('id','in',ids), ('project_ids','in',project_id)]
454 domain = ['|', ('id','in',ids), ('project_default','=',1)]
455 stage_ids = stage_obj._search(cr, uid, domain, order=order, access_rights_uid=access_rights_uid, context=context)
456 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
457 # restore order of the search
458 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
461 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
462 res_users = self.pool.get('res.users')
463 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
464 access_rights_uid = access_rights_uid or uid
466 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
467 order = res_users._order
468 # lame way to allow reverting search, should just work in the trivial case
469 if read_group_order == 'user_id desc':
470 order = '%s desc' % order
471 # de-duplicate and apply search order
472 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
473 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
474 # restore order of the search
475 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
479 'type_id': _read_group_type_id,
480 'user_id': _read_group_user_id
484 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
485 obj_project = self.pool.get('project.project')
487 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
488 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
489 if id and isinstance(id, (long, int)):
490 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
491 args.append(('active', '=', False))
492 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
494 def _str_get(self, task, level=0, border='***', context=None):
495 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'+ \
496 border[0]+' '+(task.name or '')+'\n'+ \
497 (task.description or '')+'\n\n'
499 # Compute: effective_hours, total_hours, progress
500 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
502 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
503 hours = dict(cr.fetchall())
504 for task in self.browse(cr, uid, ids, context=context):
505 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)}
506 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
507 res[task.id]['progress'] = 0.0
508 if (task.remaining_hours + hours.get(task.id, 0.0)):
509 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
510 if task.state in ('done','cancelled'):
511 res[task.id]['progress'] = 100.0
515 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
516 if remaining and not planned:
517 return {'value':{'planned_hours': remaining}}
520 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
521 return {'value':{'remaining_hours': planned - effective}}
523 def onchange_project(self, cr, uid, id, project_id):
526 data = self.pool.get('project.project').browse(cr, uid, [project_id])
527 partner_id=data and data[0].parent_id.partner_id
529 return {'value':{'partner_id':partner_id.id}}
532 def _default_project(self, cr, uid, context=None):
535 if 'project_id' in context and context['project_id']:
536 return int(context['project_id'])
539 def duplicate_task(self, cr, uid, map_ids, context=None):
540 for new in map_ids.values():
541 task = self.browse(cr, uid, new, context)
542 child_ids = [ ch.id for ch in task.child_ids]
544 for child in task.child_ids:
545 if child.id in map_ids.keys():
546 child_ids.remove(child.id)
547 child_ids.append(map_ids[child.id])
549 parent_ids = [ ch.id for ch in task.parent_ids]
551 for parent in task.parent_ids:
552 if parent.id in map_ids.keys():
553 parent_ids.remove(parent.id)
554 parent_ids.append(map_ids[parent.id])
555 #FIXME why there is already the copy and the old one
556 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
558 def copy_data(self, cr, uid, id, default={}, context=None):
559 default = default or {}
560 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
561 if not default.get('remaining_hours', False):
562 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
563 default['active'] = True
564 default['type_id'] = False
565 if not default.get('name', False):
566 default['name'] = self.browse(cr, uid, id, context=context).name or ''
567 if not context.get('copy',False):
568 new_name = _("%s (copy)")%default.get('name','')
569 default.update({'name':new_name})
570 return super(task, self).copy_data(cr, uid, id, default, context)
573 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
575 for task in self.browse(cr, uid, ids, context=context):
578 if task.project_id.active == False or task.project_id.state == 'template':
582 def _get_task(self, cr, uid, ids, context=None):
584 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
585 if work.task_id: result[work.task_id.id] = True
589 '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."),
590 'name': fields.char('Task Summary', size=128, required=True),
591 'description': fields.text('Description'),
592 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority'),
593 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of tasks."),
594 'type_id': fields.many2one('project.task.type', 'Stage'),
595 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State', readonly=True, required=True,
596 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.\
597 \n If the task is over, the states is set to \'Done\'.'),
598 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
599 help="A task's kanban state indicates special situations affecting it:\n"
600 " * Normal is the default situation\n"
601 " * Blocked indicates something is preventing the progress of this task\n"
602 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
603 readonly=True, required=False),
604 'create_date': fields.datetime('Create Date', readonly=True,select=True),
605 'date_start': fields.datetime('Starting Date',select=True),
606 'date_end': fields.datetime('Ending Date',select=True),
607 'date_deadline': fields.date('Deadline',select=True),
608 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
609 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
610 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
611 'notes': fields.text('Notes'),
612 '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.'),
613 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
615 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
616 'project.task.work': (_get_task, ['hours'], 10),
618 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
619 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
621 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
622 'project.task.work': (_get_task, ['hours'], 10),
624 '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",
626 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
627 'project.task.work': (_get_task, ['hours'], 10),
629 '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.",
631 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
632 'project.task.work': (_get_task, ['hours'], 10),
634 'user_id': fields.many2one('res.users', 'Assigned to'),
635 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
636 'partner_id': fields.many2one('res.partner', 'Partner'),
637 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
638 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
639 'company_id': fields.many2one('res.company', 'Company'),
640 'id': fields.integer('ID', readonly=True),
641 'color': fields.integer('Color Index'),
642 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
647 'kanban_state': 'normal',
652 'project_id': _default_project,
653 'user_id': lambda obj, cr, uid, context: uid,
654 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
657 _order = "priority, sequence, date_start, name, id"
659 def set_priority(self, cr, uid, ids, priority):
662 return self.write(cr, uid, ids, {'priority' : priority})
664 def set_high_priority(self, cr, uid, ids, *args):
665 """Set task priority to high
667 return self.set_priority(cr, uid, ids, '1')
669 def set_normal_priority(self, cr, uid, ids, *args):
670 """Set task priority to normal
672 return self.set_priority(cr, uid, ids, '3')
674 def _check_recursion(self, cr, uid, ids, context=None):
676 visited_branch = set()
678 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
684 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
685 if id in visited_branch: #Cycle
688 if id in visited_node: #Already tested don't work one more time for nothing
691 visited_branch.add(id)
694 #visit child using DFS
695 task = self.browse(cr, uid, id, context=context)
696 for child in task.child_ids:
697 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
701 visited_branch.remove(id)
704 def _check_dates(self, cr, uid, ids, context=None):
707 obj_task = self.browse(cr, uid, ids[0], context=context)
708 start = obj_task.date_start or False
709 end = obj_task.date_end or False
716 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
717 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
720 # Override view according to the company definition
722 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
723 users_obj = self.pool.get('res.users')
725 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
726 # this should be safe (no context passed to avoid side-effects)
727 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
728 tm = obj_tm and obj_tm.name or 'Hours'
730 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
732 if tm in ['Hours','Hour']:
735 eview = etree.fromstring(res['arch'])
737 def _check_rec(eview):
738 if eview.attrib.get('widget','') == 'float_time':
739 eview.set('widget','float')
746 res['arch'] = etree.tostring(eview)
748 for f in res['fields']:
749 if 'Hours' in res['fields'][f]['string']:
750 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
753 def _check_child_task(self, cr, uid, ids, context=None):
756 tasks = self.browse(cr, uid, ids, context=context)
759 for child in task.child_ids:
760 if child.state in ['draft', 'open', 'pending']:
761 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
764 def action_close(self, cr, uid, ids, context=None):
765 # This action open wizard to send email to partner or project manager after close task.
768 task_id = len(ids) and ids[0] or False
769 self._check_child_task(cr, uid, ids, context=context)
770 if not task_id: return False
771 task = self.browse(cr, uid, task_id, context=context)
772 project = task.project_id
773 res = self.do_close(cr, uid, [task_id], context=context)
774 if project.warn_manager or project.warn_customer:
776 'name': _('Send Email after close task'),
779 'res_model': 'mail.compose.message',
780 'type': 'ir.actions.act_window',
783 'context': {'active_id': task.id,
784 'active_model': 'project.task'}
788 def do_close(self, cr, uid, ids, context={}):
792 request = self.pool.get('res.request')
793 if not isinstance(ids,list): ids = [ids]
794 for task in self.browse(cr, uid, ids, context=context):
796 project = task.project_id
798 # Send request to project manager
799 if project.warn_manager and project.user_id and (project.user_id.id != uid):
800 request.create(cr, uid, {
801 'name': _("Task '%s' closed") % task.name,
804 'act_to': project.user_id.id,
805 'ref_partner_id': task.partner_id.id,
806 'ref_doc1': 'project.task,%d'% (task.id,),
807 'ref_doc2': 'project.project,%d'% (project.id,),
810 for parent_id in task.parent_ids:
811 if parent_id.state in ('pending','draft'):
813 for child in parent_id.child_ids:
814 if child.id != task.id and child.state not in ('done','cancelled'):
817 self.do_reopen(cr, uid, [parent_id.id], context=context)
818 vals.update({'state': 'done'})
819 vals.update({'remaining_hours': 0.0})
820 if not task.date_end:
821 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
822 self.write(cr, uid, [task.id],vals, context=context)
823 message = _("The task '%s' is done") % (task.name,)
824 self.log(cr, uid, task.id, message)
827 def do_reopen(self, cr, uid, ids, context=None):
828 request = self.pool.get('res.request')
830 for task in self.browse(cr, uid, ids, context=context):
831 project = task.project_id
832 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
833 request.create(cr, uid, {
834 'name': _("Task '%s' set in progress") % task.name,
837 'act_to': project.user_id.id,
838 'ref_partner_id': task.partner_id.id,
839 'ref_doc1': 'project.task,%d' % task.id,
840 'ref_doc2': 'project.project,%d' % project.id,
843 self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
846 def do_cancel(self, cr, uid, ids, context={}):
847 request = self.pool.get('res.request')
848 tasks = self.browse(cr, uid, ids, context=context)
849 self._check_child_task(cr, uid, ids, context=context)
851 project = task.project_id
852 if project.warn_manager and project.user_id and (project.user_id.id != uid):
853 request.create(cr, uid, {
854 'name': _("Task '%s' cancelled") % task.name,
857 'act_to': project.user_id.id,
858 'ref_partner_id': task.partner_id.id,
859 'ref_doc1': 'project.task,%d' % task.id,
860 'ref_doc2': 'project.project,%d' % project.id,
862 message = _("The task '%s' is cancelled.") % (task.name,)
863 self.log(cr, uid, task.id, message)
864 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
867 def do_open(self, cr, uid, ids, context={}):
868 if not isinstance(ids,list): ids = [ids]
869 tasks= self.browse(cr, uid, ids, context=context)
871 data = {'state': 'open'}
873 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
874 self.write(cr, uid, [t.id], data, context=context)
875 message = _("The task '%s' is opened.") % (t.name,)
876 self.log(cr, uid, t.id, message)
879 def do_draft(self, cr, uid, ids, context={}):
880 self.write(cr, uid, ids, {'state': 'draft'}, context=context)
884 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
885 attachment = self.pool.get('ir.attachment')
886 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
887 new_attachment_ids = []
888 for attachment_id in attachment_ids:
889 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
890 return new_attachment_ids
893 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
895 Delegate Task to another users.
897 assert delegate_data['user_id'], _("Delegated User should be specified")
899 for task in self.browse(cr, uid, ids, context=context):
900 delegated_task_id = self.copy(cr, uid, task.id, {
901 'name': delegate_data['name'],
902 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
903 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
904 'planned_hours': delegate_data['planned_hours'] or 0.0,
905 'parent_ids': [(6, 0, [task.id])],
907 'description': delegate_data['new_task_description'] or '',
911 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
912 newname = delegate_data['prefix'] or ''
914 'remaining_hours': delegate_data['planned_hours_me'],
915 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
918 if delegate_data['state'] == 'pending':
919 self.do_pending(cr, uid, task.id, context=context)
920 elif delegate_data['state'] == 'done':
921 self.do_close(cr, uid, task.id, context=context)
923 message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_data['user_id'][1])
924 self.log(cr, uid, task.id, message)
925 delegated_tasks[task.id] = delegated_task_id
926 return delegated_tasks
928 def do_pending(self, cr, uid, ids, context={}):
929 self.write(cr, uid, ids, {'state': 'pending'}, context=context)
930 for (id, name) in self.name_get(cr, uid, ids):
931 message = _("The task '%s' is pending.") % name
932 self.log(cr, uid, id, message)
935 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
936 for task in self.browse(cr, uid, ids, context=context):
937 if (task.state=='draft') or (task.planned_hours==0.0):
938 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
939 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
942 def set_remaining_time_1(self, cr, uid, ids, context=None):
943 return self.set_remaining_time(cr, uid, ids, 1.0, context)
945 def set_remaining_time_2(self, cr, uid, ids, context=None):
946 return self.set_remaining_time(cr, uid, ids, 2.0, context)
948 def set_remaining_time_5(self, cr, uid, ids, context=None):
949 return self.set_remaining_time(cr, uid, ids, 5.0, context)
951 def set_remaining_time_10(self, cr, uid, ids, context=None):
952 return self.set_remaining_time(cr, uid, ids, 10.0, context)
954 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
955 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
957 def set_kanban_state_normal(self, cr, uid, ids, context=None):
958 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
960 def set_kanban_state_done(self, cr, uid, ids, context=None):
961 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
963 def _change_type(self, cr, uid, ids, next, *args):
966 if next is False, go to previous stage
968 for task in self.browse(cr, uid, ids):
969 if task.project_id.type_ids:
970 typeid = task.type_id.id
972 for type in task.project_id.type_ids :
973 types_seq[type.id] = type.sequence
975 types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
977 types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
978 sorted_types = [x[0] for x in types]
980 self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
981 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
982 index = sorted_types.index(typeid)
983 self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
986 def next_type(self, cr, uid, ids, *args):
987 return self._change_type(cr, uid, ids, True, *args)
989 def prev_type(self, cr, uid, ids, *args):
990 return self._change_type(cr, uid, ids, False, *args)
992 def _store_history(self, cr, uid, ids, context=None):
993 for task in self.browse(cr, uid, ids, context=context):
994 self.pool.get('project.task.history').create(cr, uid, {
996 'remaining_hours': task.remaining_hours,
997 'planned_hours': task.planned_hours,
998 'kanban_state': task.kanban_state,
999 'type_id': task.type_id.id,
1000 'state': task.state,
1001 'user_id': task.user_id.id
1006 def create(self, cr, uid, vals, context=None):
1007 result = super(task, self).create(cr, uid, vals, context=context)
1008 self._store_history(cr, uid, [result], context=context)
1011 # Overridden to reset the kanban_state to normal whenever
1012 # the stage (type_id) of the task changes.
1013 def write(self, cr, uid, ids, vals, context=None):
1014 if isinstance(ids, (int, long)):
1016 if vals and not 'kanban_state' in vals and 'type_id' in vals:
1017 new_stage = vals.get('type_id')
1018 vals_reset_kstate = dict(vals, kanban_state='normal')
1019 for t in self.browse(cr, uid, ids, context=context):
1020 write_vals = vals_reset_kstate if t.type_id != new_stage else vals
1021 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1024 result = super(task,self).write(cr, uid, ids, vals, context=context)
1025 if ('type_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1026 self._store_history(cr, uid, ids, context=context)
1029 def unlink(self, cr, uid, ids, context=None):
1032 self._check_child_task(cr, uid, ids, context=context)
1033 res = super(task, self).unlink(cr, uid, ids, context)
1036 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1037 context = context or {}
1041 if task.state in ('done','cancelled'):
1046 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1048 for t2 in task.parent_ids:
1049 start.append("up.Task_%s.end" % (t2.id,))
1053 ''' % (ident,','.join(start))
1058 ''' % (ident, 'User_'+str(task.user_id.id))
1065 class project_work(osv.osv):
1066 _name = "project.task.work"
1067 _description = "Project Task Work"
1069 'name': fields.char('Work summary', size=128),
1070 'date': fields.datetime('Date', select="1"),
1071 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1072 'hours': fields.float('Time Spent'),
1073 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1074 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1078 'user_id': lambda obj, cr, uid, context: uid,
1079 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1082 _order = "date desc"
1083 def create(self, cr, uid, vals, *args, **kwargs):
1084 if 'hours' in vals and (not vals['hours']):
1085 vals['hours'] = 0.00
1086 if 'task_id' in vals:
1087 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1088 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1090 def write(self, cr, uid, ids, vals, context=None):
1091 if 'hours' in vals and (not vals['hours']):
1092 vals['hours'] = 0.00
1094 for work in self.browse(cr, uid, ids, context=context):
1095 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))
1096 return super(project_work,self).write(cr, uid, ids, vals, context)
1098 def unlink(self, cr, uid, ids, *args, **kwargs):
1099 for work in self.browse(cr, uid, ids):
1100 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1101 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1104 class account_analytic_account(osv.osv):
1106 _inherit = 'account.analytic.account'
1107 _description = 'Analytic Account'
1109 def create(self, cr, uid, vals, context=None):
1112 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1113 vals['child_ids'] = []
1114 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1116 def unlink(self, cr, uid, ids, *args, **kwargs):
1117 project_obj = self.pool.get('project.project')
1118 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1120 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1121 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1123 account_analytic_account()
1126 # Tasks History, used for cumulative flow charts (Lean/Agile)
1129 class project_task_history(osv.osv):
1130 _name = 'project.task.history'
1131 _description = 'History of Tasks'
1132 _rec_name = 'task_id'
1134 def _get_date(self, cr, uid, ids, name, arg, context=None):
1136 for history in self.browse(cr, uid, ids, context=context):
1137 if history.state in ('done','cancelled'):
1138 result[history.id] = history.date
1140 cr.execute('''select
1143 project_task_history
1147 order by id limit 1''', (history.task_id.id, history.id))
1149 result[history.id] = res and res[0] or False
1152 def _get_related_date(self, cr, uid, ids, context=None):
1154 for history in self.browse(cr, uid, ids, context=context):
1155 cr.execute('''select
1158 project_task_history
1162 order by id desc limit 1''', (history.task_id.id, history.id))
1165 result.append(res[0])
1169 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1170 'type_id': fields.many2one('project.task.type', 'Stage'),
1171 'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')], 'State'),
1172 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1173 'date': fields.date('Date', select=True),
1174 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1175 'project.task.history': (_get_related_date, None, 20)
1177 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1178 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1179 'user_id': fields.many2one('res.users', 'Responsible'),
1182 'date': lambda s,c,u,ctx: time.strftime('%Y-%m-%d')
1184 project_task_history()
1186 class project_task_history_cumulative(osv.osv):
1187 _name = 'project.task.history.cumulative'
1188 _table = 'project_task_history_cumulative'
1189 _inherit = 'project.task.history'
1192 'end_date': fields.date('End Date'),
1193 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1196 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1198 history.date::varchar||'-'||history.history_id::varchar as id,
1199 history.date as end_date,
1204 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1205 task_id, type_id, user_id, kanban_state, state,
1206 remaining_hours, planned_hours
1208 project_task_history
1212 project_task_history_cumulative()