1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
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 ##############################################################################
23 from lxml import etree
24 from datetime import datetime, date
27 from base_status.base_stage import base_stage
28 from osv import fields, osv
29 from openerp.addons.resource.faces import task as Task
30 from tools.translate import _
31 from openerp import SUPERUSER_ID
33 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
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 'case_default': fields.boolean('Common to All Projects',
44 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."),
45 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
46 'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
47 help="The status of your document is automatically changed regarding the selected stage. " \
48 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
49 'fold': fields.boolean('Hide in views if empty',
50 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
61 """Keep first word(s) of name to make it small enough
63 if not name: return name
64 # keep 7 chars + end of the last word
65 keep_words = name[:7].strip().split()
66 return ' '.join(name.split()[:len(keep_words)])
68 class project(osv.osv):
69 _name = "project.project"
70 _description = "Project"
71 _inherits = {'account.analytic.account': "analytic_account_id",
72 "mail.alias": "alias_id"}
73 _inherit = ['mail.thread', 'ir.needaction_mixin']
75 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
77 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
78 if context and context.get('user_preference'):
79 cr.execute("""SELECT project.id FROM project_project project
80 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
81 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
82 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
83 return [(r[0]) for r in cr.fetchall()]
84 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
85 context=context, count=count)
87 def _complete_name(self, cr, uid, ids, name, args, context=None):
89 for m in self.browse(cr, uid, ids, context=context):
90 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
93 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
94 partner_obj = self.pool.get('res.partner')
98 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
99 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
100 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
101 val['pricelist_id'] = pricelist_id
102 return {'value': val}
104 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
105 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
106 project_ids = [task.project_id.id for task in tasks if task.project_id]
107 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
109 def _get_project_and_parents(self, cr, uid, ids, context=None):
110 """ return the project ids and all their parent projects """
114 SELECT DISTINCT parent.id
115 FROM project_project project, project_project parent, account_analytic_account account
116 WHERE project.analytic_account_id = account.id
117 AND parent.analytic_account_id = account.parent_id
120 ids = [t[0] for t in cr.fetchall()]
124 def _get_project_and_children(self, cr, uid, ids, context=None):
125 """ retrieve all children projects of project ids;
126 return a dictionary mapping each project to its parent project (or None)
128 res = dict.fromkeys(ids, None)
131 SELECT project.id, parent.id
132 FROM project_project project, project_project parent, account_analytic_account account
133 WHERE project.analytic_account_id = account.id
134 AND parent.analytic_account_id = account.parent_id
137 dic = dict(cr.fetchall())
142 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
143 child_parent = self._get_project_and_children(cr, uid, ids, context)
144 # compute planned_hours, total_hours, effective_hours specific to each project
146 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
147 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
148 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
150 """, (tuple(child_parent.keys()),))
151 # aggregate results into res
152 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
153 for id, planned, total, effective in cr.fetchall():
154 # add the values specific to id to all parent projects of id in the result
157 res[id]['planned_hours'] += planned
158 res[id]['total_hours'] += total
159 res[id]['effective_hours'] += effective
160 id = child_parent[id]
161 # compute progress rates
163 if res[id]['total_hours']:
164 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
166 res[id]['progress_rate'] = 0.0
169 def unlink(self, cr, uid, ids, context=None):
171 mail_alias = self.pool.get('mail.alias')
172 for proj in self.browse(cr, uid, ids, context=context):
174 raise osv.except_osv(_('Invalid Action!'),
175 _('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.'))
177 alias_ids.append(proj.alias_id.id)
178 res = super(project, self).unlink(cr, uid, ids, context=context)
179 mail_alias.unlink(cr, uid, alias_ids, context=context)
182 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
184 attachment = self.pool.get('ir.attachment')
185 task = self.pool.get('project.task')
187 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', 'in', [id])], context=context, count=True)
188 task_ids = task.search(cr, uid, [('project_id', 'in', [id])], context=context)
189 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
190 res[id] = project_attachments or 0 + task_attachments or 0
193 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
194 res = dict.fromkeys(ids, 0)
195 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
196 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
197 res[task.project_id.id] += 1
200 def _get_alias_models(self, cr, uid, context=None):
201 """Overriden in project_issue to offer more options"""
202 return [('project.task', "Tasks")]
204 def attachment_tree_view(self, cr, uid, ids, context):
205 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
208 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
209 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)
211 res_id = ids and ids[0] or False
213 'name': _('Attachments'),
215 'res_model': 'ir.attachment',
216 'type': 'ir.actions.act_window',
218 'view_mode': 'tree,form',
221 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
223 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
224 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
226 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
227 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
228 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
229 'analytic_account_id': fields.many2one('account.analytic.account', 'Contract/Analytic', 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),
230 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
231 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
232 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)]}),
233 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
234 '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.",
236 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
237 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
239 '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.",
241 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
242 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
244 '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.",
246 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
247 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
249 '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.",
251 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
252 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
254 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
255 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
256 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
257 'color': fields.integer('Color Index'),
258 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
259 help="Internal email associated with this project. Incoming emails are automatically synchronized"
260 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
261 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
262 help="The kind of document created when an email is received on this project's email alias"),
263 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
264 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
265 'doc_count':fields.function(_get_attached_docs, string="Number of documents attached", type='int')
268 def _get_type_common(self, cr, uid, context):
269 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
279 'type_ids': _get_type_common,
280 'alias_model': 'project.task',
281 'privacy_visibility': 'public',
282 'alias_domain': False, # always hide alias during creation
285 # TODO: Why not using a SQL contraints ?
286 def _check_dates(self, cr, uid, ids, context=None):
287 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
288 if leave['date_start'] and leave['date']:
289 if leave['date_start'] > leave['date']:
294 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
297 def set_template(self, cr, uid, ids, context=None):
298 res = self.setActive(cr, uid, ids, value=False, context=context)
301 def set_done(self, cr, uid, ids, context=None):
302 task_obj = self.pool.get('project.task')
303 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
304 task_obj.case_close(cr, uid, task_ids, context=context)
305 self.write(cr, uid, ids, {'state':'close'}, context=context)
306 self.set_close_send_note(cr, uid, ids, context=context)
309 def set_cancel(self, cr, uid, ids, context=None):
310 task_obj = self.pool.get('project.task')
311 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
312 task_obj.case_cancel(cr, uid, task_ids, context=context)
313 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
316 def set_pending(self, cr, uid, ids, context=None):
317 self.write(cr, uid, ids, {'state':'pending'}, context=context)
318 self.set_pending_send_note(cr, uid, ids, context=context)
321 def set_open(self, cr, uid, ids, context=None):
322 self.write(cr, uid, ids, {'state':'open'}, context=context)
323 self.set_open_send_note(cr, uid, ids, context=context)
326 def reset_project(self, cr, uid, ids, context=None):
327 res = self.setActive(cr, uid, ids, value=True, context=context)
328 self.set_open_send_note(cr, uid, ids, context=context)
331 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
332 """ copy and map tasks from old to new project """
336 task_obj = self.pool.get('project.task')
337 proj = self.browse(cr, uid, old_project_id, context=context)
338 for task in proj.tasks:
339 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
340 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
341 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
344 def copy(self, cr, uid, id, default=None, context=None):
350 context['active_test'] = False
351 default['state'] = 'open'
352 default['tasks'] = []
353 default.pop('alias_name', None)
354 default.pop('alias_id', None)
355 proj = self.browse(cr, uid, id, context=context)
356 if not default.get('name', False):
357 default.update(name=_("%s (copy)") % (proj.name))
358 res = super(project, self).copy(cr, uid, id, default, context)
359 self.map_tasks(cr,uid,id,res,context)
362 def duplicate_template(self, cr, uid, ids, context=None):
365 data_obj = self.pool.get('ir.model.data')
367 for proj in self.browse(cr, uid, ids, context=context):
368 parent_id = context.get('parent_id', False)
369 context.update({'analytic_project_copy': True})
370 new_date_start = time.strftime('%Y-%m-%d')
372 if proj.date_start and proj.date:
373 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
374 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
375 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
376 context.update({'copy':True})
377 new_id = self.copy(cr, uid, proj.id, default = {
378 'name':_("%s (copy)") % (proj.name),
380 'date_start':new_date_start,
382 'parent_id':parent_id}, context=context)
383 result.append(new_id)
385 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
386 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
388 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
390 if result and len(result):
392 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
393 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
394 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
395 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
396 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
397 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
399 'name': _('Projects'),
401 'view_mode': 'form,tree',
402 'res_model': 'project.project',
405 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
406 'type': 'ir.actions.act_window',
407 'search_view_id': search_view['res_id'],
411 # set active value for a project, its sub projects and its tasks
412 def setActive(self, cr, uid, ids, value=True, context=None):
413 task_obj = self.pool.get('project.task')
414 for proj in self.browse(cr, uid, ids, context=None):
415 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
416 cr.execute('select id from project_task where project_id=%s', (proj.id,))
417 tasks_id = [x[0] for x in cr.fetchall()]
419 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
420 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
422 self.setActive(cr, uid, child_ids, value, context=None)
425 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
426 context = context or {}
427 if type(ids) in (long, int,):
429 projects = self.browse(cr, uid, ids, context=context)
431 for project in projects:
432 if (not project.members) and force_members:
433 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
435 resource_pool = self.pool.get('resource.resource')
437 result = "from openerp.addons.resource.faces import *\n"
438 result += "import datetime\n"
439 for project in self.browse(cr, uid, ids, context=context):
440 u_ids = [i.id for i in project.members]
441 if project.user_id and (project.user_id.id not in u_ids):
442 u_ids.append(project.user_id.id)
443 for task in project.tasks:
444 if task.state in ('done','cancelled'):
446 if task.user_id and (task.user_id.id not in u_ids):
447 u_ids.append(task.user_id.id)
448 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
449 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
450 for key, vals in resource_objs.items():
452 class User_%s(Resource):
454 ''' % (key, vals.get('efficiency', False))
461 def _schedule_project(self, cr, uid, project, context=None):
462 resource_pool = self.pool.get('resource.resource')
463 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
464 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
465 # TODO: check if we need working_..., default values are ok.
466 puids = [x.id for x in project.members]
468 puids.append(project.user_id.id)
476 project.date_start or time.strftime('%Y-%m-%d'), working_days,
477 '|'.join(['User_'+str(x) for x in puids])
479 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
486 #TODO: DO Resource allocation and compute availability
487 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
493 def schedule_tasks(self, cr, uid, ids, context=None):
494 context = context or {}
495 if type(ids) in (long, int,):
497 projects = self.browse(cr, uid, ids, context=context)
498 result = self._schedule_header(cr, uid, ids, False, context=context)
499 for project in projects:
500 result += self._schedule_project(cr, uid, project, context=context)
501 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
504 exec result in local_dict
505 projects_gantt = Task.BalancedProject(local_dict['Project'])
507 for project in projects:
508 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
509 for task in project.tasks:
510 if task.state in ('done','cancelled'):
513 p = getattr(project_gantt, 'Task_%d' % (task.id,))
515 self.pool.get('project.task').write(cr, uid, [task.id], {
516 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
517 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
519 if (not task.user_id) and (p.booked_resource):
520 self.pool.get('project.task').write(cr, uid, [task.id], {
521 'user_id': int(p.booked_resource[0].name[5:]),
525 # ------------------------------------------------
526 # OpenChatter methods and notifications
527 # ------------------------------------------------
529 def create(self, cr, uid, vals, context=None):
530 if context is None: context = {}
531 # Prevent double project creation when 'use_tasks' is checked!
532 context = dict(context, project_creation_in_progress=True)
533 mail_alias = self.pool.get('mail.alias')
534 if not vals.get('alias_id'):
535 vals.pop('alias_name', None) # prevent errors during copy()
536 alias_id = mail_alias.create_unique_alias(cr, uid,
537 # Using '+' allows using subaddressing for those who don't
538 # have a catchall domain setup.
539 {'alias_name': "project+"+short_name(vals['name'])},
540 model_name=vals.get('alias_model', 'project.task'),
542 vals['alias_id'] = alias_id
543 vals['type'] = 'contract'
544 project_id = super(project, self).create(cr, uid, vals, context)
545 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
546 self.create_send_note(cr, uid, [project_id], context=context)
549 def create_send_note(self, cr, uid, ids, context=None):
550 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
552 def set_open_send_note(self, cr, uid, ids, context=None):
553 return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
555 def set_pending_send_note(self, cr, uid, ids, context=None):
556 return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
558 def set_close_send_note(self, cr, uid, ids, context=None):
559 return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), context=context)
561 def write(self, cr, uid, ids, vals, context=None):
562 # if alias_model has been changed, update alias_model_id accordingly
563 if vals.get('alias_model'):
564 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
565 vals.update(alias_model_id=model_ids[0])
566 return super(project, self).write(cr, uid, ids, vals, context=context)
568 class task(base_stage, osv.osv):
569 _name = "project.task"
570 _description = "Task"
571 _date_name = "date_start"
572 _inherit = ['mail.thread', 'ir.needaction_mixin']
574 def _get_default_project_id(self, cr, uid, context=None):
575 """ Gives default section by checking if present in the context """
576 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
578 def _get_default_stage_id(self, cr, uid, context=None):
579 """ Gives default stage_id """
580 project_id = self._get_default_project_id(cr, uid, context=context)
581 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
583 def _resolve_project_id_from_context(self, cr, uid, context=None):
584 """ Returns ID of project based on the value of 'default_project_id'
585 context key, or None if it cannot be resolved to a single
588 if context is None: context = {}
589 if type(context.get('default_project_id')) in (int, long):
590 return context['default_project_id']
591 if isinstance(context.get('default_project_id'), basestring):
592 project_name = context['default_project_id']
593 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
594 if len(project_ids) == 1:
595 return project_ids[0][0]
598 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
599 stage_obj = self.pool.get('project.task.type')
600 order = stage_obj._order
601 access_rights_uid = access_rights_uid or uid
602 # lame way to allow reverting search, should just work in the trivial case
603 if read_group_order == 'stage_id desc':
604 order = '%s desc' % order
605 # retrieve section_id from the context and write the domain
606 # - ('id', 'in', 'ids'): add columns that should be present
607 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
608 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
610 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
612 search_domain += ['|', ('project_ids', '=', project_id)]
613 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
614 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
615 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
616 # restore order of the search
617 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
620 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
621 fold[stage.id] = stage.fold or False
624 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
625 res_users = self.pool.get('res.users')
626 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
627 access_rights_uid = access_rights_uid or uid
629 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
630 order = res_users._order
631 # lame way to allow reverting search, should just work in the trivial case
632 if read_group_order == 'user_id desc':
633 order = '%s desc' % order
634 # de-duplicate and apply search order
635 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
636 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
637 # restore order of the search
638 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
642 'stage_id': _read_group_stage_ids,
643 'user_id': _read_group_user_id,
646 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
647 obj_project = self.pool.get('project.project')
649 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
650 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
651 if id and isinstance(id, (long, int)):
652 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
653 args.append(('active', '=', False))
654 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
656 def _str_get(self, task, level=0, border='***', context=None):
657 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'+ \
658 border[0]+' '+(task.name or '')+'\n'+ \
659 (task.description or '')+'\n\n'
661 # Compute: effective_hours, total_hours, progress
662 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
664 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
665 hours = dict(cr.fetchall())
666 for task in self.browse(cr, uid, ids, context=context):
667 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)}
668 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
669 res[task.id]['progress'] = 0.0
670 if (task.remaining_hours + hours.get(task.id, 0.0)):
671 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
672 if task.state in ('done','cancelled'):
673 res[task.id]['progress'] = 100.0
676 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
677 if remaining and not planned:
678 return {'value':{'planned_hours': remaining}}
681 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
682 return {'value':{'remaining_hours': planned - effective}}
684 def onchange_project(self, cr, uid, id, project_id):
687 data = self.pool.get('project.project').browse(cr, uid, [project_id])
688 partner_id=data and data[0].partner_id
690 return {'value':{'partner_id':partner_id.id}}
693 def duplicate_task(self, cr, uid, map_ids, context=None):
694 for new in map_ids.values():
695 task = self.browse(cr, uid, new, context)
696 child_ids = [ ch.id for ch in task.child_ids]
698 for child in task.child_ids:
699 if child.id in map_ids.keys():
700 child_ids.remove(child.id)
701 child_ids.append(map_ids[child.id])
703 parent_ids = [ ch.id for ch in task.parent_ids]
705 for parent in task.parent_ids:
706 if parent.id in map_ids.keys():
707 parent_ids.remove(parent.id)
708 parent_ids.append(map_ids[parent.id])
709 #FIXME why there is already the copy and the old one
710 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
712 def copy_data(self, cr, uid, id, default=None, context=None):
715 default = default or {}
716 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
717 if not default.get('remaining_hours', False):
718 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
719 default['active'] = True
720 if not default.get('name', False):
721 default['name'] = self.browse(cr, uid, id, context=context).name or ''
722 if not context.get('copy',False):
723 new_name = _("%s (copy)") % (default.get('name', ''))
724 default.update({'name':new_name})
725 return super(task, self).copy_data(cr, uid, id, default, context)
727 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
729 for task in self.browse(cr, uid, ids, context=context):
732 if task.project_id.active == False or task.project_id.state == 'template':
736 def _get_task(self, cr, uid, ids, context=None):
738 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
739 if work.task_id: result[work.task_id.id] = True
743 '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."),
744 'name': fields.char('Task Summary', size=128, required=True, select=True),
745 'description': fields.text('Description'),
746 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
747 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
748 'stage_id': fields.many2one('project.task.type', 'Stage',
749 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
750 'state': fields.related('stage_id', 'state', type="selection", store=True,
751 selection=_TASK_STATE, string="Status", readonly=True,
752 help='The status is set to \'Draft\', when a case is created.\
753 If the case is in progress the status is set to \'Open\'.\
754 When the case is over, the status is set to \'Done\'.\
755 If the case needs to be reviewed then the status is \
756 set to \'Pending\'.'),
757 'categ_ids': fields.many2many('project.category', string='Tags'),
758 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
759 help="A task's kanban state indicates special situations affecting it:\n"
760 " * Normal is the default situation\n"
761 " * Blocked indicates something is preventing the progress of this task\n"
762 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
763 readonly=True, required=False),
764 'create_date': fields.datetime('Create Date', readonly=True,select=True),
765 'date_start': fields.datetime('Starting Date',select=True),
766 'date_end': fields.datetime('Ending Date',select=True),
767 'date_deadline': fields.date('Deadline',select=True),
768 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
769 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
770 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
771 'notes': fields.text('Notes'),
772 'planned_hours': fields.float('Initially Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
773 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
775 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
776 'project.task.work': (_get_task, ['hours'], 10),
778 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
779 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
781 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
782 'project.task.work': (_get_task, ['hours'], 10),
784 '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",
786 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
787 'project.task.work': (_get_task, ['hours'], 10),
789 'delay_hours': fields.function(_hours_get, string='Delay Hours', multi='hours', help="Computed as difference between planned hours by the project manager and the total hours of the task.",
791 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
792 'project.task.work': (_get_task, ['hours'], 10),
794 'user_id': fields.many2one('res.users', 'Assigned to'),
795 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
796 'partner_id': fields.many2one('res.partner', 'Customer'),
797 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
798 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
799 'company_id': fields.many2one('res.company', 'Company'),
800 'id': fields.integer('ID', readonly=True),
801 'color': fields.integer('Color Index'),
802 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
805 'stage_id': _get_default_stage_id,
806 'project_id': _get_default_project_id,
807 'kanban_state': 'normal',
812 'user_id': lambda obj, cr, uid, context: uid,
813 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
815 _order = "priority, sequence, date_start, name, id"
817 def set_priority(self, cr, uid, ids, priority, *args):
820 return self.write(cr, uid, ids, {'priority' : priority})
822 def set_very_high_priority(self, cr, uid, ids, *args):
823 """Set task priority to very high
825 return self.set_priority(cr, uid, ids, '0')
827 def set_high_priority(self, cr, uid, ids, *args):
828 """Set task priority to high
830 return self.set_priority(cr, uid, ids, '1')
832 def set_normal_priority(self, cr, uid, ids, *args):
833 """Set task priority to normal
835 return self.set_priority(cr, uid, ids, '2')
837 def _check_recursion(self, cr, uid, ids, context=None):
839 visited_branch = set()
841 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
847 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
848 if id in visited_branch: #Cycle
851 if id in visited_node: #Already tested don't work one more time for nothing
854 visited_branch.add(id)
857 #visit child using DFS
858 task = self.browse(cr, uid, id, context=context)
859 for child in task.child_ids:
860 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
864 visited_branch.remove(id)
867 def _check_dates(self, cr, uid, ids, context=None):
870 obj_task = self.browse(cr, uid, ids[0], context=context)
871 start = obj_task.date_start or False
872 end = obj_task.date_end or False
879 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
880 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
883 # Override view according to the company definition
884 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
885 users_obj = self.pool.get('res.users')
886 if context is None: context = {}
887 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
888 # this should be safe (no context passed to avoid side-effects)
889 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
890 tm = obj_tm and obj_tm.name or 'Hours'
892 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
894 if tm in ['Hours','Hour']:
897 eview = etree.fromstring(res['arch'])
899 def _check_rec(eview):
900 if eview.attrib.get('widget','') == 'float_time':
901 eview.set('widget','float')
908 res['arch'] = etree.tostring(eview)
910 for f in res['fields']:
911 if 'Hours' in res['fields'][f]['string']:
912 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
915 # ----------------------------------------
917 # ----------------------------------------
919 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
920 """ Override of the base.stage method
921 Parameter of the stage search taken from the lead:
922 - section_id: if set, stages must belong to this section or
923 be a default stage; if not set, stages must be default
926 if isinstance(cases, (int, long)):
927 cases = self.browse(cr, uid, cases, context=context)
928 # collect all section_ids
931 section_ids.append(section_id)
934 section_ids.append(task.project_id.id)
935 # OR all section_ids and OR with case_default
938 search_domain += [('|')] * len(section_ids)
939 for section_id in section_ids:
940 search_domain.append(('project_ids', '=', section_id))
941 search_domain.append(('case_default', '=', True))
942 # AND with the domain in parameter
943 search_domain += list(domain)
944 # perform search, return the first found
945 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
950 def _check_child_task(self, cr, uid, ids, context=None):
953 tasks = self.browse(cr, uid, ids, context=context)
956 for child in task.child_ids:
957 if child.state in ['draft', 'open', 'pending']:
958 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
961 def action_close(self, cr, uid, ids, context=None):
962 """ This action closes the task
964 task_id = len(ids) and ids[0] or False
965 self._check_child_task(cr, uid, ids, context=context)
966 if not task_id: return False
967 return self.do_close(cr, uid, [task_id], context=context)
969 def do_close(self, cr, uid, ids, context=None):
970 """ Compatibility when changing to case_close. """
971 return self.case_close(cr, uid, ids, context=context)
973 def case_close(self, cr, uid, ids, context=None):
975 if not isinstance(ids, list): ids = [ids]
976 for task in self.browse(cr, uid, ids, context=context):
978 project = task.project_id
979 for parent_id in task.parent_ids:
980 if parent_id.state in ('pending','draft'):
982 for child in parent_id.child_ids:
983 if child.id != task.id and child.state not in ('done','cancelled'):
986 self.do_reopen(cr, uid, [parent_id.id], context=context)
988 vals['remaining_hours'] = 0.0
989 if not task.date_end:
990 vals['date_end'] = fields.datetime.now()
991 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
992 self.case_close_send_note(cr, uid, [task.id], context=context)
995 def do_reopen(self, cr, uid, ids, context=None):
996 for task in self.browse(cr, uid, ids, context=context):
997 project = task.project_id
998 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
999 self.case_open_send_note(cr, uid, [task.id], context)
1002 def do_cancel(self, cr, uid, ids, context=None):
1003 """ Compatibility when changing to case_cancel. """
1004 return self.case_cancel(cr, uid, ids, context=context)
1006 def case_cancel(self, cr, uid, ids, context=None):
1007 tasks = self.browse(cr, uid, ids, context=context)
1008 self._check_child_task(cr, uid, ids, context=context)
1010 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1013 def do_open(self, cr, uid, ids, context=None):
1014 """ Compatibility when changing to case_open. """
1015 return self.case_open(cr, uid, ids, context=context)
1017 def case_open(self, cr, uid, ids, context=None):
1018 if not isinstance(ids,list): ids = [ids]
1019 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1020 self.case_open_send_note(cr, uid, ids, context)
1023 def do_draft(self, cr, uid, ids, context=None):
1024 """ Compatibility when changing to case_draft. """
1025 return self.case_draft(cr, uid, ids, context=context)
1027 def case_draft(self, cr, uid, ids, context=None):
1028 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1029 self.case_draft_send_note(cr, uid, ids, context=context)
1032 def do_pending(self, cr, uid, ids, context=None):
1033 """ Compatibility when changing to case_pending. """
1034 return self.case_pending(cr, uid, ids, context=context)
1036 def case_pending(self, cr, uid, ids, context=None):
1037 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1038 return self.case_pending_send_note(cr, uid, ids, context=context)
1040 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1041 attachment = self.pool.get('ir.attachment')
1042 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1043 new_attachment_ids = []
1044 for attachment_id in attachment_ids:
1045 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1046 return new_attachment_ids
1048 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1050 Delegate Task to another users.
1052 if delegate_data is None:
1054 assert delegate_data['user_id'], _("Delegated User should be specified")
1055 delegated_tasks = {}
1056 for task in self.browse(cr, uid, ids, context=context):
1057 delegated_task_id = self.copy(cr, uid, task.id, {
1058 'name': delegate_data['name'],
1059 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1060 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1061 'planned_hours': delegate_data['planned_hours'] or 0.0,
1062 'parent_ids': [(6, 0, [task.id])],
1063 'description': delegate_data['new_task_description'] or '',
1067 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1068 newname = delegate_data['prefix'] or ''
1070 'remaining_hours': delegate_data['planned_hours_me'],
1071 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1074 if delegate_data['state'] == 'pending':
1075 self.do_pending(cr, uid, [task.id], context=context)
1076 elif delegate_data['state'] == 'done':
1077 self.do_close(cr, uid, [task.id], context=context)
1078 self.do_delegation_send_note(cr, uid, [task.id], context)
1079 delegated_tasks[task.id] = delegated_task_id
1080 return delegated_tasks
1082 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1083 for task in self.browse(cr, uid, ids, context=context):
1084 if (task.state=='draft') or (task.planned_hours==0.0):
1085 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1086 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1089 def set_remaining_time_1(self, cr, uid, ids, context=None):
1090 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1092 def set_remaining_time_2(self, cr, uid, ids, context=None):
1093 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1095 def set_remaining_time_5(self, cr, uid, ids, context=None):
1096 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1098 def set_remaining_time_10(self, cr, uid, ids, context=None):
1099 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1101 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1102 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1103 self.case_block_send_note(cr, uid, ids, context=context)
1106 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1107 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1108 self.case_open_send_note(cr, uid, ids, context=context)
1111 def set_kanban_state_done(self, cr, uid, ids, context=None):
1112 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1115 def _store_history(self, cr, uid, ids, context=None):
1116 for task in self.browse(cr, uid, ids, context=context):
1117 self.pool.get('project.task.history').create(cr, uid, {
1119 'remaining_hours': task.remaining_hours,
1120 'planned_hours': task.planned_hours,
1121 'kanban_state': task.kanban_state,
1122 'type_id': task.stage_id.id,
1123 'state': task.state,
1124 'user_id': task.user_id.id
1129 def _subscribe_project_followers_to_task(self, cr, uid, task_id, context=None):
1130 """ TDE note: not the best way to do this, we could override _get_followers
1131 of task, and perform a better mapping of subtypes than a mapping
1133 However we will keep this implementation, maybe to be refactored
1134 in 7.1 of future versions. """
1135 # task followers are project followers, with matching subtypes
1136 task_record = self.browse(cr, uid, task_id, context=context)
1137 subtype_obj = self.pool.get('mail.message.subtype')
1138 follower_obj = self.pool.get('mail.followers')
1139 if task_record.project_id:
1141 task_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('res_model', '=', self._name)], context=context)
1142 task_subtypes = subtype_obj.browse(cr, uid, task_subtype_ids, context=context)
1143 # fetch subscriptions
1144 follower_ids = follower_obj.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', task_record.project_id.id)], context=context)
1146 for follower in follower_obj.browse(cr, uid, follower_ids, context=context):
1147 if not follower.subtype_ids:
1149 project_subtype_names = [project_subtype.name for project_subtype in follower.subtype_ids]
1150 task_subtype_ids = [task_subtype.id for task_subtype in task_subtypes if task_subtype.name in project_subtype_names]
1151 self.message_subscribe(cr, uid, [task_id], [follower.partner_id.id],
1152 subtype_ids=task_subtype_ids, context=context)
1154 def create(self, cr, uid, vals, context=None):
1155 task_id = super(task, self).create(cr, uid, vals, context=context)
1156 # subscribe project followers to the task
1157 self._subscribe_project_followers_to_task(cr, uid, task_id, context=context)
1159 self._store_history(cr, uid, [task_id], context=context)
1160 self.create_send_note(cr, uid, [task_id], context=context)
1163 # Overridden to reset the kanban_state to normal whenever
1164 # the stage (stage_id) of the task changes.
1165 def write(self, cr, uid, ids, vals, context=None):
1166 if isinstance(ids, (int, long)):
1168 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1169 new_stage = vals.get('stage_id')
1170 vals_reset_kstate = dict(vals, kanban_state='normal')
1171 for t in self.browse(cr, uid, ids, context=context):
1172 #TO FIX:Kanban view doesn't raise warning
1173 #stages = [stage.id for stage in t.project_id.type_ids]
1174 #if new_stage not in stages:
1175 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1176 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1177 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1180 result = super(task, self).write(cr, uid, ids, vals, context=context)
1181 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1182 self._store_history(cr, uid, ids, context=context)
1184 # subscribe new project followers to the task
1185 if vals.get('project_id'):
1187 self._subscribe_project_followers_to_task(cr, uid, id, context=context)
1190 def unlink(self, cr, uid, ids, context=None):
1193 self._check_child_task(cr, uid, ids, context=context)
1194 res = super(task, self).unlink(cr, uid, ids, context)
1197 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1198 context = context or {}
1202 if task.state in ('done','cancelled'):
1207 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1209 for t2 in task.parent_ids:
1210 start.append("up.Task_%s.end" % (t2.id,))
1214 ''' % (ident,','.join(start))
1219 ''' % (ident, 'User_'+str(task.user_id.id))
1224 # ---------------------------------------------------
1226 # ---------------------------------------------------
1228 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1229 """ Override to updates the document according to the email. """
1230 if custom_values is None: custom_values = {}
1231 custom_values.update({
1232 'name': msg.get('subject'),
1233 'planned_hours': 0.0,
1235 return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1237 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1238 """ Override to update the task according to the email. """
1239 if update_vals is None: update_vals = {}
1242 'cost':'planned_hours',
1244 for line in msg['body'].split('\n'):
1246 res = tools.command_re.match(line)
1248 match = res.group(1).lower()
1249 field = maps.get(match)
1252 update_vals[field] = float(res.group(2).lower())
1253 except (ValueError, TypeError):
1255 elif match.lower() == 'state' \
1256 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1257 act = 'do_%s' % res.group(2).lower()
1259 getattr(self,act)(cr, uid, ids, context=context)
1260 return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1262 # ---------------------------------------------------
1263 # OpenChatter methods and notifications
1264 # ---------------------------------------------------
1266 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1267 """ Override of default prefix for notifications. """
1270 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1271 """ Returns the user_ids that have to perform an action.
1272 Add to the previous results given by super the document responsible
1274 :return: dict { record_id: [user_ids], }
1276 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1277 for obj in self.browse(cr, uid, ids, context=context):
1278 if obj.state == 'draft' and obj.user_id:
1279 result[obj.id].append(obj.user_id.id)
1282 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1283 """ Add 'user_id' and 'manager_id' to the monitored fields """
1284 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1285 return res + ['user_id', 'manager_id']
1287 def create_send_note(self, cr, uid, ids, context=None):
1288 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), subtype="project.mt_task_new", context=context)
1289 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1290 """ Override of the (void) default notification method. """
1291 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1292 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1294 def case_open_send_note(self, cr, uid, ids, context=None):
1295 return self.message_post(cr, uid, ids, body=_("Task has been <b>started</b>."), subtype="project.mt_task_started", context=context)
1297 def case_close_send_note(self, cr, uid, ids, context=None):
1298 return self.message_post(cr, uid, ids, body=_("Task has been <b>done</b>."), subtype="project.mt_task_closed", context=context)
1300 def case_block_send_note(self, cr, uid, ids, context=None):
1301 return self.message_post(cr, uid, ids, body=_("Task has been <b>blocked</b>."), subtype="project.mt_task_blocked", context=context)
1303 def case_draft_send_note(self, cr, uid, ids, context=None):
1304 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1306 def do_delegation_send_note(self, cr, uid, ids, context=None):
1307 for task in self.browse(cr, uid, ids, context=context):
1308 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1309 self.message_post(cr, uid, [task.id], body=msg, context=context)
1312 def project_task_reevaluate(self, cr, uid, ids, context=None):
1313 if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1315 'view_type': 'form',
1316 "view_mode": 'form',
1317 'res_model': 'project.task.reevaluate',
1318 'type': 'ir.actions.act_window',
1321 return self.do_reopen(cr, uid, ids, context=context)
1323 class project_work(osv.osv):
1324 _name = "project.task.work"
1325 _description = "Project Task Work"
1327 'name': fields.char('Work summary', size=128),
1328 'date': fields.datetime('Date', select="1"),
1329 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1330 'hours': fields.float('Time Spent'),
1331 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1332 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1336 'user_id': lambda obj, cr, uid, context: uid,
1337 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1340 _order = "date desc"
1341 def create(self, cr, uid, vals, *args, **kwargs):
1342 if 'hours' in vals and (not vals['hours']):
1343 vals['hours'] = 0.00
1344 if 'task_id' in vals:
1345 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1346 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1348 def write(self, cr, uid, ids, vals, context=None):
1349 if 'hours' in vals and (not vals['hours']):
1350 vals['hours'] = 0.00
1352 for work in self.browse(cr, uid, ids, context=context):
1353 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))
1354 return super(project_work,self).write(cr, uid, ids, vals, context)
1356 def unlink(self, cr, uid, ids, *args, **kwargs):
1357 for work in self.browse(cr, uid, ids):
1358 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1359 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1362 class account_analytic_account(osv.osv):
1363 _inherit = 'account.analytic.account'
1364 _description = 'Analytic Account'
1366 'use_tasks': fields.boolean('Tasks',help="If checked, this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1367 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1370 def on_change_template(self, cr, uid, ids, template_id, context=None):
1371 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1372 if template_id and 'value' in res:
1373 template = self.browse(cr, uid, template_id, context=context)
1374 res['value']['use_tasks'] = template.use_tasks
1377 def _trigger_project_creation(self, cr, uid, vals, context=None):
1379 This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise.
1381 if context is None: context = {}
1382 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1384 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1386 This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet.
1388 project_pool = self.pool.get('project.project')
1389 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1390 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1392 'name': vals.get('name'),
1393 'analytic_account_id': analytic_account_id,
1395 return project_pool.create(cr, uid, project_values, context=context)
1398 def create(self, cr, uid, vals, context=None):
1401 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1402 vals['child_ids'] = []
1403 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1404 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1405 return analytic_account_id
1407 def write(self, cr, uid, ids, vals, context=None):
1408 name = vals.get('name')
1409 for account in self.browse(cr, uid, ids, context=context):
1411 vals['name'] = account.name
1412 self.project_create(cr, uid, account.id, vals, context=context)
1413 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1415 def unlink(self, cr, uid, ids, *args, **kwargs):
1416 project_obj = self.pool.get('project.project')
1417 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1419 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1420 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1422 class project_project(osv.osv):
1423 _inherit = 'project.project'
1428 class project_task_history(osv.osv):
1430 Tasks History, used for cumulative flow charts (Lean/Agile)
1432 _name = 'project.task.history'
1433 _description = 'History of Tasks'
1434 _rec_name = 'task_id'
1437 def _get_date(self, cr, uid, ids, name, arg, context=None):
1439 for history in self.browse(cr, uid, ids, context=context):
1440 if history.state in ('done','cancelled'):
1441 result[history.id] = history.date
1443 cr.execute('''select
1446 project_task_history
1450 order by id limit 1''', (history.task_id.id, history.id))
1452 result[history.id] = res and res[0] or False
1455 def _get_related_date(self, cr, uid, ids, context=None):
1457 for history in self.browse(cr, uid, ids, context=context):
1458 cr.execute('''select
1461 project_task_history
1465 order by id desc limit 1''', (history.task_id.id, history.id))
1468 result.append(res[0])
1472 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1473 'type_id': fields.many2one('project.task.type', 'Stage'),
1474 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1475 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1476 'date': fields.date('Date', select=True),
1477 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1478 'project.task.history': (_get_related_date, None, 20)
1480 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1481 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1482 'user_id': fields.many2one('res.users', 'Responsible'),
1485 'date': fields.date.context_today,
1488 class project_task_history_cumulative(osv.osv):
1489 _name = 'project.task.history.cumulative'
1490 _table = 'project_task_history_cumulative'
1491 _inherit = 'project.task.history'
1495 'end_date': fields.date('End Date'),
1496 'project_id': fields.many2one('project.project', 'Project'),
1500 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1502 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1504 history.date::varchar||'-'||history.history_id::varchar AS id,
1505 history.date AS end_date,
1510 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1511 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1512 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1515 project_task_history AS h
1516 JOIN project_task AS t ON (h.task_id = t.id)
1522 class project_category(osv.osv):
1523 """ Category of project's task (or issue) """
1524 _name = "project.category"
1525 _description = "Category of project's task, issue, ..."
1527 'name': fields.char('Name', size=64, required=True, translate=True),