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, *args, **kwargs):
171 mail_alias = self.pool.get('mail.alias')
172 for proj in self.browse(cr, uid, ids):
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, *args, **kwargs)
179 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
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)
314 self.set_cancel_send_note(cr, uid, ids, context=context)
317 def set_pending(self, cr, uid, ids, context=None):
318 self.write(cr, uid, ids, {'state':'pending'}, context=context)
319 self.set_pending_send_note(cr, uid, ids, context=context)
322 def set_open(self, cr, uid, ids, context=None):
323 self.write(cr, uid, ids, {'state':'open'}, context=context)
324 self.set_open_send_note(cr, uid, ids, context=context)
327 def reset_project(self, cr, uid, ids, context=None):
328 res = self.setActive(cr, uid, ids, value=True, context=context)
329 self.set_open_send_note(cr, uid, ids, context=context)
332 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
333 """ copy and map tasks from old to new project """
337 task_obj = self.pool.get('project.task')
338 proj = self.browse(cr, uid, old_project_id, context=context)
339 for task in proj.tasks:
340 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
341 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
342 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
345 def copy(self, cr, uid, id, default=None, context=None):
351 context['active_test'] = False
352 default['state'] = 'open'
353 default['tasks'] = []
354 default.pop('alias_name', None)
355 default.pop('alias_id', None)
356 proj = self.browse(cr, uid, id, context=context)
357 if not default.get('name', False):
358 default.update(name=_("%s (copy)") % (proj.name))
359 res = super(project, self).copy(cr, uid, id, default, context)
360 self.map_tasks(cr,uid,id,res,context)
363 def duplicate_template(self, cr, uid, ids, context=None):
366 data_obj = self.pool.get('ir.model.data')
368 for proj in self.browse(cr, uid, ids, context=context):
369 parent_id = context.get('parent_id', False)
370 context.update({'analytic_project_copy': True})
371 new_date_start = time.strftime('%Y-%m-%d')
373 if proj.date_start and proj.date:
374 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
375 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
376 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
377 context.update({'copy':True})
378 new_id = self.copy(cr, uid, proj.id, default = {
379 'name':_("%s (copy)") % (proj.name),
381 'date_start':new_date_start,
383 'parent_id':parent_id}, context=context)
384 result.append(new_id)
386 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
387 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
389 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
391 if result and len(result):
393 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
394 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
395 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
396 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
397 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
398 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
400 'name': _('Projects'),
402 'view_mode': 'form,tree',
403 'res_model': 'project.project',
406 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
407 'type': 'ir.actions.act_window',
408 'search_view_id': search_view['res_id'],
412 # set active value for a project, its sub projects and its tasks
413 def setActive(self, cr, uid, ids, value=True, context=None):
414 task_obj = self.pool.get('project.task')
415 for proj in self.browse(cr, uid, ids, context=None):
416 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
417 cr.execute('select id from project_task where project_id=%s', (proj.id,))
418 tasks_id = [x[0] for x in cr.fetchall()]
420 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
421 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
423 self.setActive(cr, uid, child_ids, value, context=None)
426 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
427 context = context or {}
428 if type(ids) in (long, int,):
430 projects = self.browse(cr, uid, ids, context=context)
432 for project in projects:
433 if (not project.members) and force_members:
434 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
436 resource_pool = self.pool.get('resource.resource')
438 result = "from openerp.addons.resource.faces import *\n"
439 result += "import datetime\n"
440 for project in self.browse(cr, uid, ids, context=context):
441 u_ids = [i.id for i in project.members]
442 if project.user_id and (project.user_id.id not in u_ids):
443 u_ids.append(project.user_id.id)
444 for task in project.tasks:
445 if task.state in ('done','cancelled'):
447 if task.user_id and (task.user_id.id not in u_ids):
448 u_ids.append(task.user_id.id)
449 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
450 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
451 for key, vals in resource_objs.items():
453 class User_%s(Resource):
455 ''' % (key, vals.get('efficiency', False))
462 def _schedule_project(self, cr, uid, project, context=None):
463 resource_pool = self.pool.get('resource.resource')
464 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
465 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
466 # TODO: check if we need working_..., default values are ok.
467 puids = [x.id for x in project.members]
469 puids.append(project.user_id.id)
477 project.date_start, working_days,
478 '|'.join(['User_'+str(x) for x in puids])
480 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
487 #TODO: DO Resource allocation and compute availability
488 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
494 def schedule_tasks(self, cr, uid, ids, context=None):
495 context = context or {}
496 if type(ids) in (long, int,):
498 projects = self.browse(cr, uid, ids, context=context)
499 result = self._schedule_header(cr, uid, ids, False, context=context)
500 for project in projects:
501 result += self._schedule_project(cr, uid, project, context=context)
502 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
505 exec result in local_dict
506 projects_gantt = Task.BalancedProject(local_dict['Project'])
508 for project in projects:
509 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
510 for task in project.tasks:
511 if task.state in ('done','cancelled'):
514 p = getattr(project_gantt, 'Task_%d' % (task.id,))
516 self.pool.get('project.task').write(cr, uid, [task.id], {
517 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
518 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
520 if (not task.user_id) and (p.booked_resource):
521 self.pool.get('project.task').write(cr, uid, [task.id], {
522 'user_id': int(p.booked_resource[0].name[5:]),
526 # ------------------------------------------------
527 # OpenChatter methods and notifications
528 # ------------------------------------------------
530 def create(self, cr, uid, vals, context=None):
531 if context is None: context = {}
532 # Prevent double project creation when 'use_tasks' is checked!
533 context = dict(context, project_creation_in_progress=True)
534 mail_alias = self.pool.get('mail.alias')
535 if not vals.get('alias_id'):
536 vals.pop('alias_name', None) # prevent errors during copy()
537 alias_id = mail_alias.create_unique_alias(cr, uid,
538 # Using '+' allows using subaddressing for those who don't
539 # have a catchall domain setup.
540 {'alias_name': "project+"+short_name(vals['name'])},
541 model_name=vals.get('alias_model', 'project.task'),
543 vals['alias_id'] = alias_id
544 vals['type'] = 'contract'
545 project_id = super(project, self).create(cr, uid, vals, context)
546 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
547 self.create_send_note(cr, uid, [project_id], context=context)
550 def create_send_note(self, cr, uid, ids, context=None):
551 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
553 def set_open_send_note(self, cr, uid, ids, context=None):
554 return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
556 def set_pending_send_note(self, cr, uid, ids, context=None):
557 return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
559 def set_cancel_send_note(self, cr, uid, ids, context=None):
560 return self.message_post(cr, uid, ids, body=_("Project has been <b>canceled</b>."), context=context)
562 def set_close_send_note(self, cr, uid, ids, context=None):
563 return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), context=context)
565 def write(self, cr, uid, ids, vals, context=None):
566 # if alias_model has been changed, update alias_model_id accordingly
567 if vals.get('alias_model'):
568 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
569 vals.update(alias_model_id=model_ids[0])
570 return super(project, self).write(cr, uid, ids, vals, context=context)
572 class task(base_stage, osv.osv):
573 _name = "project.task"
574 _description = "Task"
575 _date_name = "date_start"
576 _inherit = ['mail.thread', 'ir.needaction_mixin']
578 def _get_default_project_id(self, cr, uid, context=None):
579 """ Gives default section by checking if present in the context """
580 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
582 def _get_default_stage_id(self, cr, uid, context=None):
583 """ Gives default stage_id """
584 project_id = self._get_default_project_id(cr, uid, context=context)
585 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
587 def _resolve_project_id_from_context(self, cr, uid, context=None):
588 """ Returns ID of project based on the value of 'default_project_id'
589 context key, or None if it cannot be resolved to a single
592 if context is None: context = {}
593 if type(context.get('default_project_id')) in (int, long):
594 return context['default_project_id']
595 if isinstance(context.get('default_project_id'), basestring):
596 project_name = context['default_project_id']
597 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
598 if len(project_ids) == 1:
599 return project_ids[0][0]
602 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
603 stage_obj = self.pool.get('project.task.type')
604 order = stage_obj._order
605 access_rights_uid = access_rights_uid or uid
606 # lame way to allow reverting search, should just work in the trivial case
607 if read_group_order == 'stage_id desc':
608 order = '%s desc' % order
609 # retrieve section_id from the context and write the domain
610 # - ('id', 'in', 'ids'): add columns that should be present
611 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
612 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
614 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
616 search_domain += ['|', ('project_ids', '=', project_id)]
617 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
618 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
619 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
620 # restore order of the search
621 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
624 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
625 fold[stage.id] = stage.fold or False
628 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
629 res_users = self.pool.get('res.users')
630 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
631 access_rights_uid = access_rights_uid or uid
633 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
634 order = res_users._order
635 # lame way to allow reverting search, should just work in the trivial case
636 if read_group_order == 'user_id desc':
637 order = '%s desc' % order
638 # de-duplicate and apply search order
639 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
640 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
641 # restore order of the search
642 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
646 'stage_id': _read_group_stage_ids,
647 'user_id': _read_group_user_id,
650 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
651 obj_project = self.pool.get('project.project')
653 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
654 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
655 if id and isinstance(id, (long, int)):
656 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
657 args.append(('active', '=', False))
658 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
660 def _str_get(self, task, level=0, border='***', context=None):
661 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'+ \
662 border[0]+' '+(task.name or '')+'\n'+ \
663 (task.description or '')+'\n\n'
665 # Compute: effective_hours, total_hours, progress
666 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
668 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
669 hours = dict(cr.fetchall())
670 for task in self.browse(cr, uid, ids, context=context):
671 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)}
672 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
673 res[task.id]['progress'] = 0.0
674 if (task.remaining_hours + hours.get(task.id, 0.0)):
675 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
676 if task.state in ('done','cancelled'):
677 res[task.id]['progress'] = 100.0
680 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
681 if remaining and not planned:
682 return {'value':{'planned_hours': remaining}}
685 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
686 return {'value':{'remaining_hours': planned - effective}}
688 def onchange_project(self, cr, uid, id, project_id):
691 data = self.pool.get('project.project').browse(cr, uid, [project_id])
692 partner_id=data and data[0].partner_id
694 return {'value':{'partner_id':partner_id.id}}
697 def duplicate_task(self, cr, uid, map_ids, context=None):
698 for new in map_ids.values():
699 task = self.browse(cr, uid, new, context)
700 child_ids = [ ch.id for ch in task.child_ids]
702 for child in task.child_ids:
703 if child.id in map_ids.keys():
704 child_ids.remove(child.id)
705 child_ids.append(map_ids[child.id])
707 parent_ids = [ ch.id for ch in task.parent_ids]
709 for parent in task.parent_ids:
710 if parent.id in map_ids.keys():
711 parent_ids.remove(parent.id)
712 parent_ids.append(map_ids[parent.id])
713 #FIXME why there is already the copy and the old one
714 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
716 def copy_data(self, cr, uid, id, default=None, context=None):
719 default = default or {}
720 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
721 if not default.get('remaining_hours', False):
722 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
723 default['active'] = True
724 if not default.get('name', False):
725 default['name'] = self.browse(cr, uid, id, context=context).name or ''
726 if not context.get('copy',False):
727 new_name = _("%s (copy)") % (default.get('name', ''))
728 default.update({'name':new_name})
729 return super(task, self).copy_data(cr, uid, id, default, context)
731 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
733 for task in self.browse(cr, uid, ids, context=context):
736 if task.project_id.active == False or task.project_id.state == 'template':
740 def _get_task(self, cr, uid, ids, context=None):
742 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
743 if work.task_id: result[work.task_id.id] = True
747 '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."),
748 'name': fields.char('Task Summary', size=128, required=True, select=True),
749 'description': fields.text('Description'),
750 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
751 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
752 'stage_id': fields.many2one('project.task.type', 'Stage',
753 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
754 'state': fields.related('stage_id', 'state', type="selection", store=True,
755 selection=_TASK_STATE, string="Status", readonly=True,
756 help='The status is set to \'Draft\', when a case is created.\
757 If the case is in progress the status is set to \'Open\'.\
758 When the case is over, the status is set to \'Done\'.\
759 If the case needs to be reviewed then the status is \
760 set to \'Pending\'.'),
761 'categ_ids': fields.many2many('project.category', string='Tags'),
762 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
763 help="A task's kanban state indicates special situations affecting it:\n"
764 " * Normal is the default situation\n"
765 " * Blocked indicates something is preventing the progress of this task\n"
766 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
767 readonly=True, required=False),
768 'create_date': fields.datetime('Create Date', readonly=True,select=True),
769 'date_start': fields.datetime('Starting Date',select=True),
770 'date_end': fields.datetime('Ending Date',select=True),
771 'date_deadline': fields.date('Deadline',select=True),
772 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
773 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
774 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
775 'notes': fields.text('Notes'),
776 '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.'),
777 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
779 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
780 'project.task.work': (_get_task, ['hours'], 10),
782 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
783 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
785 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
786 'project.task.work': (_get_task, ['hours'], 10),
788 '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",
790 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
791 'project.task.work': (_get_task, ['hours'], 10),
793 '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.",
795 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
796 'project.task.work': (_get_task, ['hours'], 10),
798 'user_id': fields.many2one('res.users', 'Assigned to'),
799 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
800 'partner_id': fields.many2one('res.partner', 'Customer'),
801 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
802 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
803 'company_id': fields.many2one('res.company', 'Company'),
804 'id': fields.integer('ID', readonly=True),
805 'color': fields.integer('Color Index'),
806 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
809 'stage_id': _get_default_stage_id,
810 'project_id': _get_default_project_id,
811 'kanban_state': 'normal',
816 'user_id': lambda obj, cr, uid, context: uid,
817 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
819 _order = "priority, sequence, date_start, name, id"
821 def set_priority(self, cr, uid, ids, priority, *args):
824 return self.write(cr, uid, ids, {'priority' : priority})
826 def set_very_high_priority(self, cr, uid, ids, *args):
827 """Set task priority to very high
829 return self.set_priority(cr, uid, ids, '0')
831 def set_high_priority(self, cr, uid, ids, *args):
832 """Set task priority to high
834 return self.set_priority(cr, uid, ids, '1')
836 def set_normal_priority(self, cr, uid, ids, *args):
837 """Set task priority to normal
839 return self.set_priority(cr, uid, ids, '2')
841 def _check_recursion(self, cr, uid, ids, context=None):
843 visited_branch = set()
845 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
851 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
852 if id in visited_branch: #Cycle
855 if id in visited_node: #Already tested don't work one more time for nothing
858 visited_branch.add(id)
861 #visit child using DFS
862 task = self.browse(cr, uid, id, context=context)
863 for child in task.child_ids:
864 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
868 visited_branch.remove(id)
871 def _check_dates(self, cr, uid, ids, context=None):
874 obj_task = self.browse(cr, uid, ids[0], context=context)
875 start = obj_task.date_start or False
876 end = obj_task.date_end or False
883 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
884 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
887 # Override view according to the company definition
888 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
889 users_obj = self.pool.get('res.users')
890 if context is None: context = {}
891 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
892 # this should be safe (no context passed to avoid side-effects)
893 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
894 tm = obj_tm and obj_tm.name or 'Hours'
896 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
898 if tm in ['Hours','Hour']:
901 eview = etree.fromstring(res['arch'])
903 def _check_rec(eview):
904 if eview.attrib.get('widget','') == 'float_time':
905 eview.set('widget','float')
912 res['arch'] = etree.tostring(eview)
914 for f in res['fields']:
915 if 'Hours' in res['fields'][f]['string']:
916 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
919 # ----------------------------------------
921 # ----------------------------------------
923 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
924 """ Override of the base.stage method
925 Parameter of the stage search taken from the lead:
926 - section_id: if set, stages must belong to this section or
927 be a default stage; if not set, stages must be default
930 if isinstance(cases, (int, long)):
931 cases = self.browse(cr, uid, cases, context=context)
932 # collect all section_ids
935 section_ids.append(section_id)
938 section_ids.append(task.project_id.id)
939 # OR all section_ids and OR with case_default
942 search_domain += [('|')] * len(section_ids)
943 for section_id in section_ids:
944 search_domain.append(('project_ids', '=', section_id))
945 search_domain.append(('case_default', '=', True))
946 # AND with the domain in parameter
947 search_domain += list(domain)
948 # perform search, return the first found
949 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
954 def _check_child_task(self, cr, uid, ids, context=None):
957 tasks = self.browse(cr, uid, ids, context=context)
960 for child in task.child_ids:
961 if child.state in ['draft', 'open', 'pending']:
962 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
965 def action_close(self, cr, uid, ids, context=None):
966 """ This action closes the task
968 task_id = len(ids) and ids[0] or False
969 self._check_child_task(cr, uid, ids, context=context)
970 if not task_id: return False
971 return self.do_close(cr, uid, [task_id], context=context)
973 def do_close(self, cr, uid, ids, context=None):
974 """ Compatibility when changing to case_close. """
975 return self.case_close(cr, uid, ids, context=context)
977 def case_close(self, cr, uid, ids, context=None):
979 if not isinstance(ids, list): ids = [ids]
980 for task in self.browse(cr, uid, ids, context=context):
982 project = task.project_id
983 for parent_id in task.parent_ids:
984 if parent_id.state in ('pending','draft'):
986 for child in parent_id.child_ids:
987 if child.id != task.id and child.state not in ('done','cancelled'):
990 self.do_reopen(cr, uid, [parent_id.id], context=context)
992 vals['remaining_hours'] = 0.0
993 if not task.date_end:
994 vals['date_end'] = fields.datetime.now()
995 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
996 self.case_close_send_note(cr, uid, [task.id], context=context)
999 def do_reopen(self, cr, uid, ids, context=None):
1000 for task in self.browse(cr, uid, ids, context=context):
1001 project = task.project_id
1002 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1003 self.case_open_send_note(cr, uid, [task.id], context)
1006 def do_cancel(self, cr, uid, ids, context=None):
1007 """ Compatibility when changing to case_cancel. """
1008 return self.case_cancel(cr, uid, ids, context=context)
1010 def case_cancel(self, cr, uid, ids, context=None):
1011 tasks = self.browse(cr, uid, ids, context=context)
1012 self._check_child_task(cr, uid, ids, context=context)
1014 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1015 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1018 def do_open(self, cr, uid, ids, context=None):
1019 """ Compatibility when changing to case_open. """
1020 return self.case_open(cr, uid, ids, context=context)
1022 def case_open(self, cr, uid, ids, context=None):
1023 if not isinstance(ids,list): ids = [ids]
1024 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1025 self.case_open_send_note(cr, uid, ids, context)
1028 def do_draft(self, cr, uid, ids, context=None):
1029 """ Compatibility when changing to case_draft. """
1030 return self.case_draft(cr, uid, ids, context=context)
1032 def case_draft(self, cr, uid, ids, context=None):
1033 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1034 self.case_draft_send_note(cr, uid, ids, context=context)
1037 def do_pending(self, cr, uid, ids, context=None):
1038 """ Compatibility when changing to case_pending. """
1039 return self.case_pending(cr, uid, ids, context=context)
1041 def case_pending(self, cr, uid, ids, context=None):
1042 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1043 return self.case_pending_send_note(cr, uid, ids, context=context)
1045 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1046 attachment = self.pool.get('ir.attachment')
1047 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1048 new_attachment_ids = []
1049 for attachment_id in attachment_ids:
1050 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1051 return new_attachment_ids
1053 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1055 Delegate Task to another users.
1057 if delegate_data is None:
1059 assert delegate_data['user_id'], _("Delegated User should be specified")
1060 delegated_tasks = {}
1061 for task in self.browse(cr, uid, ids, context=context):
1062 delegated_task_id = self.copy(cr, uid, task.id, {
1063 'name': delegate_data['name'],
1064 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1065 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1066 'planned_hours': delegate_data['planned_hours'] or 0.0,
1067 'parent_ids': [(6, 0, [task.id])],
1068 'description': delegate_data['new_task_description'] or '',
1072 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1073 newname = delegate_data['prefix'] or ''
1075 'remaining_hours': delegate_data['planned_hours_me'],
1076 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1079 if delegate_data['state'] == 'pending':
1080 self.do_pending(cr, uid, [task.id], context=context)
1081 elif delegate_data['state'] == 'done':
1082 self.do_close(cr, uid, [task.id], context=context)
1083 self.do_delegation_send_note(cr, uid, [task.id], context)
1084 delegated_tasks[task.id] = delegated_task_id
1085 return delegated_tasks
1087 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1088 for task in self.browse(cr, uid, ids, context=context):
1089 if (task.state=='draft') or (task.planned_hours==0.0):
1090 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1091 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1094 def set_remaining_time_1(self, cr, uid, ids, context=None):
1095 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1097 def set_remaining_time_2(self, cr, uid, ids, context=None):
1098 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1100 def set_remaining_time_5(self, cr, uid, ids, context=None):
1101 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1103 def set_remaining_time_10(self, cr, uid, ids, context=None):
1104 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1106 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1107 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1110 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1111 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1114 def set_kanban_state_done(self, cr, uid, ids, context=None):
1115 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1118 def _store_history(self, cr, uid, ids, context=None):
1119 for task in self.browse(cr, uid, ids, context=context):
1120 self.pool.get('project.task.history').create(cr, uid, {
1122 'remaining_hours': task.remaining_hours,
1123 'planned_hours': task.planned_hours,
1124 'kanban_state': task.kanban_state,
1125 'type_id': task.stage_id.id,
1126 'state': task.state,
1127 'user_id': task.user_id.id
1132 def _subscribe_project_followers_to_task(self, cr, uid, task_id, context=None):
1133 """ TDE note: not the best way to do this, we could override _get_followers
1134 of task, and perform a better mapping of subtypes than a mapping
1136 However we will keep this implementation, maybe to be refactored
1137 in 7.1 of future versions. """
1138 # task followers are project followers, with matching subtypes
1139 task_record = self.browse(cr, uid, task_id, context=context)
1140 subtype_obj = self.pool.get('mail.message.subtype')
1141 follower_obj = self.pool.get('mail.followers')
1142 if task_record.project_id:
1144 task_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('res_model', '=', self._name)], context=context)
1145 task_subtypes = subtype_obj.browse(cr, uid, task_subtype_ids, context=context)
1146 # fetch subscriptions
1147 follower_ids = follower_obj.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', task_record.project_id.id)], context=context)
1149 for follower in follower_obj.browse(cr, uid, follower_ids, context=context):
1150 if not follower.subtype_ids:
1152 project_subtype_names = [project_subtype.name for project_subtype in follower.subtype_ids]
1153 task_subtype_ids = [task_subtype.id for task_subtype in task_subtypes if task_subtype.name in project_subtype_names]
1154 self.message_subscribe(cr, uid, [task_id], [follower.partner_id.id],
1155 subtype_ids=task_subtype_ids, context=context)
1157 def create(self, cr, uid, vals, context=None):
1158 task_id = super(task, self).create(cr, uid, vals, context=context)
1159 # subscribe project followers to the task
1160 self._subscribe_project_followers_to_task(cr, uid, task_id, context=context)
1162 self._store_history(cr, uid, [task_id], context=context)
1163 self.create_send_note(cr, uid, [task_id], context=context)
1166 # Overridden to reset the kanban_state to normal whenever
1167 # the stage (stage_id) of the task changes.
1168 def write(self, cr, uid, ids, vals, context=None):
1169 if isinstance(ids, (int, long)):
1171 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1172 new_stage = vals.get('stage_id')
1173 vals_reset_kstate = dict(vals, kanban_state='normal')
1174 for t in self.browse(cr, uid, ids, context=context):
1175 #TO FIX:Kanban view doesn't raise warning
1176 #stages = [stage.id for stage in t.project_id.type_ids]
1177 #if new_stage not in stages:
1178 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1179 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1180 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1181 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1184 result = super(task, self).write(cr, uid, ids, vals, context=context)
1185 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1186 self._store_history(cr, uid, ids, context=context)
1188 # subscribe new project followers to the task
1189 if vals.get('project_id'):
1191 self._subscribe_project_followers_to_task(cr, uid, id, context=context)
1194 def unlink(self, cr, uid, ids, context=None):
1197 self._check_child_task(cr, uid, ids, context=context)
1198 res = super(task, self).unlink(cr, uid, ids, context)
1201 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1202 context = context or {}
1206 if task.state in ('done','cancelled'):
1211 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1213 for t2 in task.parent_ids:
1214 start.append("up.Task_%s.end" % (t2.id,))
1218 ''' % (ident,','.join(start))
1223 ''' % (ident, 'User_'+str(task.user_id.id))
1228 # ---------------------------------------------------
1230 # ---------------------------------------------------
1232 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1233 """ Override to updates the document according to the email. """
1234 if custom_values is None: custom_values = {}
1235 custom_values.update({
1236 'name': msg.get('subject'),
1237 'planned_hours': 0.0,
1239 return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1241 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1242 """ Override to update the task according to the email. """
1243 if update_vals is None: update_vals = {}
1246 'cost':'planned_hours',
1248 for line in msg['body'].split('\n'):
1250 res = tools.misc.command_re.match(line)
1252 match = res.group(1).lower()
1253 field = maps.get(match)
1256 update_vals[field] = float(res.group(2).lower())
1257 except (ValueError, TypeError):
1259 elif match.lower() == 'state' \
1260 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1261 act = 'do_%s' % res.group(2).lower()
1263 getattr(self,act)(cr, uid, ids, context=context)
1264 return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1266 # ---------------------------------------------------
1267 # OpenChatter methods and notifications
1268 # ---------------------------------------------------
1270 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1271 """ Override of default prefix for notifications. """
1274 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1275 """ Returns the user_ids that have to perform an action.
1276 Add to the previous results given by super the document responsible
1278 :return: dict { record_id: [user_ids], }
1280 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1281 for obj in self.browse(cr, uid, ids, context=context):
1282 if obj.state == 'draft' and obj.user_id:
1283 result[obj.id].append(obj.user_id.id)
1286 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1287 """ Add 'user_id' and 'manager_id' to the monitored fields """
1288 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1289 return res + ['user_id', 'manager_id']
1291 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1292 """ Override of the (void) default notification method. """
1293 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1294 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1297 def create_send_note(self, cr, uid, ids, context=None):
1298 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1300 def case_draft_send_note(self, cr, uid, ids, context=None):
1301 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1303 def do_delegation_send_note(self, cr, uid, ids, context=None):
1304 for task in self.browse(cr, uid, ids, context=context):
1305 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1306 self.message_post(cr, uid, [task.id], body=msg, context=context)
1309 def project_task_reevaluate(self, cr, uid, ids, context=None):
1311 cr.execute('SELECT max(id) FROM project_config_settings' )
1313 setting_id = res and res[0] or None
1314 if setting_id and setting_id != None:
1315 config_id = self.pool.get('project.config.settings').browse(cr, uid, setting_id, context=context)
1316 if config_id.group_time_work_estimation_tasks:
1318 'view_type': 'form',
1319 "view_mode": 'form',
1320 'res_model': 'project.task.reevaluate',
1321 'type': 'ir.actions.act_window',
1325 return self.do_reopen(cr, uid, ids, context=context)
1327 class project_work(osv.osv):
1328 _name = "project.task.work"
1329 _description = "Project Task Work"
1331 'name': fields.char('Work summary', size=128),
1332 'date': fields.datetime('Date', select="1"),
1333 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1334 'hours': fields.float('Time Spent'),
1335 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1336 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1340 'user_id': lambda obj, cr, uid, context: uid,
1341 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1344 _order = "date desc"
1345 def create(self, cr, uid, vals, *args, **kwargs):
1346 if 'hours' in vals and (not vals['hours']):
1347 vals['hours'] = 0.00
1348 if 'task_id' in vals:
1349 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1350 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1352 def write(self, cr, uid, ids, vals, context=None):
1353 if 'hours' in vals and (not vals['hours']):
1354 vals['hours'] = 0.00
1356 for work in self.browse(cr, uid, ids, context=context):
1357 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))
1358 return super(project_work,self).write(cr, uid, ids, vals, context)
1360 def unlink(self, cr, uid, ids, *args, **kwargs):
1361 for work in self.browse(cr, uid, ids):
1362 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1363 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1366 class account_analytic_account(osv.osv):
1367 _inherit = 'account.analytic.account'
1368 _description = 'Analytic Account'
1370 '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"),
1371 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1374 def on_change_template(self, cr, uid, ids, template_id, context=None):
1375 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1376 if template_id and 'value' in res:
1377 template = self.browse(cr, uid, template_id, context=context)
1378 res['value']['use_tasks'] = template.use_tasks
1381 def _trigger_project_creation(self, cr, uid, vals, context=None):
1383 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.
1385 if context is None: context = {}
1386 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1388 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1390 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.
1392 project_pool = self.pool.get('project.project')
1393 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1394 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1396 'name': vals.get('name'),
1397 'analytic_account_id': analytic_account_id,
1399 return project_pool.create(cr, uid, project_values, context=context)
1402 def create(self, cr, uid, vals, context=None):
1405 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1406 vals['child_ids'] = []
1407 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1408 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1409 return analytic_account_id
1411 def write(self, cr, uid, ids, vals, context=None):
1412 name = vals.get('name')
1413 for account in self.browse(cr, uid, ids, context=context):
1415 vals['name'] = account.name
1416 self.project_create(cr, uid, account.id, vals, context=context)
1417 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1419 def unlink(self, cr, uid, ids, *args, **kwargs):
1420 project_obj = self.pool.get('project.project')
1421 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1423 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1424 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1426 class project_project(osv.osv):
1427 _inherit = 'project.project'
1432 class project_task_history(osv.osv):
1434 Tasks History, used for cumulative flow charts (Lean/Agile)
1436 _name = 'project.task.history'
1437 _description = 'History of Tasks'
1438 _rec_name = 'task_id'
1441 def _get_date(self, cr, uid, ids, name, arg, context=None):
1443 for history in self.browse(cr, uid, ids, context=context):
1444 if history.state in ('done','cancelled'):
1445 result[history.id] = history.date
1447 cr.execute('''select
1450 project_task_history
1454 order by id limit 1''', (history.task_id.id, history.id))
1456 result[history.id] = res and res[0] or False
1459 def _get_related_date(self, cr, uid, ids, context=None):
1461 for history in self.browse(cr, uid, ids, context=context):
1462 cr.execute('''select
1465 project_task_history
1469 order by id desc limit 1''', (history.task_id.id, history.id))
1472 result.append(res[0])
1476 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1477 'type_id': fields.many2one('project.task.type', 'Stage'),
1478 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1479 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1480 'date': fields.date('Date', select=True),
1481 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1482 'project.task.history': (_get_related_date, None, 20)
1484 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1485 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1486 'user_id': fields.many2one('res.users', 'Responsible'),
1489 'date': fields.date.context_today,
1492 class project_task_history_cumulative(osv.osv):
1493 _name = 'project.task.history.cumulative'
1494 _table = 'project_task_history_cumulative'
1495 _inherit = 'project.task.history'
1499 'end_date': fields.date('End Date'),
1500 'project_id': fields.many2one('project.project', 'Project'),
1504 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1506 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1508 history.date::varchar||'-'||history.history_id::varchar AS id,
1509 history.date AS end_date,
1514 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1515 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1516 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1519 project_task_history AS h
1520 JOIN project_task AS t ON (h.task_id = t.id)
1526 class project_category(osv.osv):
1527 """ Category of project's task (or issue) """
1528 _name = "project.category"
1529 _description = "Category of project's task, issue, ..."
1531 'name': fields.char('Name', size=64, required=True, translate=True),