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 ##############################################################################
22 from base_status.base_stage import base_stage
23 from datetime import datetime, date
24 from lxml import etree
25 from osv import fields, osv
26 from openerp.addons.resource.faces import task as Task
28 from tools.translate import _
30 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
32 class project_task_type(osv.osv):
33 _name = 'project.task.type'
34 _description = 'Task Stage'
37 'name': fields.char('Stage Name', required=True, size=64, translate=True),
38 'description': fields.text('Description'),
39 'sequence': fields.integer('Sequence'),
40 'case_default': fields.boolean('Common to All Projects',
41 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."),
42 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
43 'state': fields.selection(_TASK_STATE, 'State', required=True,
44 help="The related state for the stage. The state of your document will automatically change regarding the selected stage. Example, a stage is related to the state 'Close', when your document reach this stage, it will be automatically closed."),
45 'fold': fields.boolean('Hide in views if empty',
46 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
57 """Keep first word(s) of name to make it small enough
59 if not name: return name
60 # keep 7 chars + end of the last word
61 keep_words = name[:7].strip().split()
62 return ' '.join(name.split()[:len(keep_words)])
64 class project(osv.osv):
65 _name = "project.project"
66 _description = "Project"
67 _inherits = {'account.analytic.account': "analytic_account_id",
68 "mail.alias": "alias_id"}
69 _inherit = ['ir.needaction_mixin', 'mail.thread']
71 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
73 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
74 if context and context.get('user_preference'):
75 cr.execute("""SELECT project.id FROM project_project project
76 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
77 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
78 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
79 return [(r[0]) for r in cr.fetchall()]
80 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
81 context=context, count=count)
83 def _complete_name(self, cr, uid, ids, name, args, context=None):
85 for m in self.browse(cr, uid, ids, context=context):
86 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
89 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
90 partner_obj = self.pool.get('res.partner')
94 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
95 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
96 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
97 val['pricelist_id'] = pricelist_id
100 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
101 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
102 project_ids = [task.project_id.id for task in tasks if task.project_id]
103 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
105 def _get_project_and_parents(self, cr, uid, ids, context=None):
106 """ return the project ids and all their parent projects """
110 SELECT DISTINCT parent.id
111 FROM project_project project, project_project parent, account_analytic_account account
112 WHERE project.analytic_account_id = account.id
113 AND parent.analytic_account_id = account.parent_id
116 ids = [t[0] for t in cr.fetchall()]
120 def _get_project_and_children(self, cr, uid, ids, context=None):
121 """ retrieve all children projects of project ids;
122 return a dictionary mapping each project to its parent project (or None)
124 res = dict.fromkeys(ids, None)
127 SELECT project.id, parent.id
128 FROM project_project project, project_project parent, account_analytic_account account
129 WHERE project.analytic_account_id = account.id
130 AND parent.analytic_account_id = account.parent_id
133 dic = dict(cr.fetchall())
138 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
139 child_parent = self._get_project_and_children(cr, uid, ids, context)
140 # compute planned_hours, total_hours, effective_hours specific to each project
142 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
143 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
144 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
146 """, (tuple(child_parent.keys()),))
147 # aggregate results into res
148 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
149 for id, planned, total, effective in cr.fetchall():
150 # add the values specific to id to all parent projects of id in the result
153 res[id]['planned_hours'] += planned
154 res[id]['total_hours'] += total
155 res[id]['effective_hours'] += effective
156 id = child_parent[id]
157 # compute progress rates
159 if res[id]['total_hours']:
160 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
162 res[id]['progress_rate'] = 0.0
165 def unlink(self, cr, uid, ids, *args, **kwargs):
167 mail_alias = self.pool.get('mail.alias')
168 for proj in self.browse(cr, uid, ids):
170 raise osv.except_osv(_('Invalid Action!'),
171 _('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.'))
173 alias_ids.append(proj.alias_id.id)
174 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
175 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
178 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
179 res = dict.fromkeys(ids, 0)
180 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
181 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
182 res[task.project_id.id] += 1
185 def _get_alias_models(self, cr, uid, context=None):
186 """Overriden in project_issue to offer more options"""
187 return [('project.task', "Tasks")]
189 def _get_followers(self, cr, uid, ids, name, arg, context=None):
191 Functional field that computes the users that are 'following' a thread.
194 for project in self.browse(cr, uid, ids, context=context):
196 for message in project.message_ids:
197 l.add(message.user_id and message.user_id.id or False)
198 res[project.id] = list(filter(None, l))
201 def _search_followers(self, cr, uid, obj, name, args, context=None):
202 project_obj = self.pool.get('project.project')
203 project_ids = project_obj.search(cr, uid, [('message_ids.user_id.id', 'in', args[0][2])], context=context)
204 return [('id', 'in', project_ids)]
206 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
207 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
210 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
211 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
212 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
213 'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account', help="Link this project to an analytic account if you need financial management on projects. It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True),
214 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
215 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
216 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)]}),
217 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
218 '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.",
220 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
221 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
223 '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.",
225 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
226 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
228 '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.",
230 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
231 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
233 '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.",
235 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
236 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
238 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
239 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
240 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
241 'color': fields.integer('Color Index'),
242 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
243 help="Internal email associated with this project. Incoming emails are automatically synchronized"
244 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
245 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
246 help="The kind of document created when an email is received on this project's email alias"),
247 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
248 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
249 'followers': fields.function(_get_followers, method=True, fnct_search=_search_followers,
250 type='many2many', relation='res.users', string='Followers'),
253 def _get_type_common(self, cr, uid, context):
254 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
264 'type_ids': _get_type_common,
265 'alias_model': 'project.task',
266 'privacy_visibility': 'public',
267 'alias_domain': False, # always hide alias during creation
270 # TODO: Why not using a SQL contraints ?
271 def _check_dates(self, cr, uid, ids, context=None):
272 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
273 if leave['date_start'] and leave['date']:
274 if leave['date_start'] > leave['date']:
279 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
282 def set_template(self, cr, uid, ids, context=None):
283 res = self.setActive(cr, uid, ids, value=False, context=context)
286 def set_done(self, cr, uid, ids, context=None):
287 task_obj = self.pool.get('project.task')
288 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
289 task_obj.case_close(cr, uid, task_ids, context=context)
290 self.write(cr, uid, ids, {'state':'close'}, context=context)
291 self.set_close_send_note(cr, uid, ids, context=context)
294 def set_cancel(self, cr, uid, ids, context=None):
295 task_obj = self.pool.get('project.task')
296 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
297 task_obj.case_cancel(cr, uid, task_ids, context=context)
298 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
299 self.set_cancel_send_note(cr, uid, ids, context=context)
302 def set_pending(self, cr, uid, ids, context=None):
303 self.write(cr, uid, ids, {'state':'pending'}, context=context)
304 self.set_pending_send_note(cr, uid, ids, context=context)
307 def set_open(self, cr, uid, ids, context=None):
308 self.write(cr, uid, ids, {'state':'open'}, context=context)
309 self.set_open_send_note(cr, uid, ids, context=context)
312 def reset_project(self, cr, uid, ids, context=None):
313 res = self.setActive(cr, uid, ids, value=True, context=context)
314 self.set_open_send_note(cr, uid, ids, context=context)
317 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
318 """ copy and map tasks from old to new project """
322 task_obj = self.pool.get('project.task')
323 proj = self.browse(cr, uid, old_project_id, context=context)
324 for task in proj.tasks:
325 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
326 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
327 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
330 def copy(self, cr, uid, id, default={}, context=None):
334 default = default or {}
335 context['active_test'] = False
336 default['state'] = 'open'
337 default['tasks'] = []
338 default.pop('alias_name', None)
339 default.pop('alias_id', None)
340 proj = self.browse(cr, uid, id, context=context)
341 if not default.get('name', False):
342 default['name'] = proj.name + _(' (copy)')
343 res = super(project, self).copy(cr, uid, id, default, context)
344 self.map_tasks(cr,uid,id,res,context)
347 def duplicate_template(self, cr, uid, ids, context=None):
350 data_obj = self.pool.get('ir.model.data')
352 for proj in self.browse(cr, uid, ids, context=context):
353 parent_id = context.get('parent_id', False)
354 context.update({'analytic_project_copy': True})
355 new_date_start = time.strftime('%Y-%m-%d')
357 if proj.date_start and proj.date:
358 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
359 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
360 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
361 context.update({'copy':True})
362 new_id = self.copy(cr, uid, proj.id, default = {
363 'name': proj.name +_(' (copy)'),
365 'date_start':new_date_start,
367 'parent_id':parent_id}, context=context)
368 result.append(new_id)
370 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
371 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
373 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
375 if result and len(result):
377 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
378 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
379 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
380 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
381 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
382 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
384 'name': _('Projects'),
386 'view_mode': 'form,tree',
387 'res_model': 'project.project',
390 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
391 'type': 'ir.actions.act_window',
392 'search_view_id': search_view['res_id'],
396 # set active value for a project, its sub projects and its tasks
397 def setActive(self, cr, uid, ids, value=True, context=None):
398 task_obj = self.pool.get('project.task')
399 for proj in self.browse(cr, uid, ids, context=None):
400 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
401 cr.execute('select id from project_task where project_id=%s', (proj.id,))
402 tasks_id = [x[0] for x in cr.fetchall()]
404 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
405 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
407 self.setActive(cr, uid, child_ids, value, context=None)
410 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
411 context = context or {}
412 if type(ids) in (long, int,):
414 projects = self.browse(cr, uid, ids, context=context)
416 for project in projects:
417 if (not project.members) and force_members:
418 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
420 resource_pool = self.pool.get('resource.resource')
422 result = "from openerp.addons.resource.faces import *\n"
423 result += "import datetime\n"
424 for project in self.browse(cr, uid, ids, context=context):
425 u_ids = [i.id for i in project.members]
426 if project.user_id and (project.user_id.id not in u_ids):
427 u_ids.append(project.user_id.id)
428 for task in project.tasks:
429 if task.state in ('done','cancelled'):
431 if task.user_id and (task.user_id.id not in u_ids):
432 u_ids.append(task.user_id.id)
433 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
434 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
435 for key, vals in resource_objs.items():
437 class User_%s(Resource):
439 ''' % (key, vals.get('efficiency', False))
446 def _schedule_project(self, cr, uid, project, context=None):
447 resource_pool = self.pool.get('resource.resource')
448 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
449 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
450 # TODO: check if we need working_..., default values are ok.
451 puids = [x.id for x in project.members]
453 puids.append(project.user_id.id)
461 project.date_start, working_days,
462 '|'.join(['User_'+str(x) for x in puids])
464 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
471 #TODO: DO Resource allocation and compute availability
472 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
478 def schedule_tasks(self, cr, uid, ids, context=None):
479 context = context or {}
480 if type(ids) in (long, int,):
482 projects = self.browse(cr, uid, ids, context=context)
483 result = self._schedule_header(cr, uid, ids, False, context=context)
484 for project in projects:
485 result += self._schedule_project(cr, uid, project, context=context)
486 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
489 exec result in local_dict
490 projects_gantt = Task.BalancedProject(local_dict['Project'])
492 for project in projects:
493 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
494 for task in project.tasks:
495 if task.state in ('done','cancelled'):
498 p = getattr(project_gantt, 'Task_%d' % (task.id,))
500 self.pool.get('project.task').write(cr, uid, [task.id], {
501 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
502 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
504 if (not task.user_id) and (p.booked_resource):
505 self.pool.get('project.task').write(cr, uid, [task.id], {
506 'user_id': int(p.booked_resource[0].name[5:]),
510 # ------------------------------------------------
511 # OpenChatter methods and notifications
512 # ------------------------------------------------
514 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
515 """ Add 'user_id' to the monitored fields """
516 res = super(project, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
517 return res + ['user_id']
519 def create(self, cr, uid, vals, context=None):
520 if context is None: context = {}
521 # Prevent double project creation when 'use_tasks' is checked!
522 context = dict(context, project_creation_in_progress=True)
523 mail_alias = self.pool.get('mail.alias')
524 if not vals.get('alias_id'):
525 vals.pop('alias_name', None) # prevent errors during copy()
526 alias_id = mail_alias.create_unique_alias(cr, uid,
527 # Using '+' allows using subaddressing for those who don't
528 # have a catchall domain setup.
529 {'alias_name': "project+"+short_name(vals['name'])},
530 model_name=vals.get('alias_model', 'project.task'),
532 vals['alias_id'] = alias_id
533 project_id = super(project, self).create(cr, uid, vals, context)
534 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
535 self.create_send_note(cr, uid, [project_id], context=context)
538 def create_send_note(self, cr, uid, ids, context=None):
539 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
541 def set_open_send_note(self, cr, uid, ids, context=None):
542 message = _("Project has been <b>opened</b>.")
543 return self.message_append_note(cr, uid, ids, body=message, context=context)
545 def set_pending_send_note(self, cr, uid, ids, context=None):
546 message = _("Project is now <b>pending</b>.")
547 return self.message_append_note(cr, uid, ids, body=message, context=context)
549 def set_cancel_send_note(self, cr, uid, ids, context=None):
550 message = _("Project has been <b>cancelled</b>.")
551 return self.message_append_note(cr, uid, ids, body=message, context=context)
553 def set_close_send_note(self, cr, uid, ids, context=None):
554 message = _("Project has been <b>closed</b>.")
555 return self.message_append_note(cr, uid, ids, body=message, context=context)
557 def write(self, cr, uid, ids, vals, context=None):
558 # if alias_model has been changed, update alias_model_id accordingly
559 if vals.get('alias_model'):
560 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
561 vals.update(alias_model_id=model_ids[0])
562 return super(project, self).write(cr, uid, ids, vals, context=context)
564 class task(base_stage, osv.osv):
565 _name = "project.task"
566 _description = "Task"
567 _date_name = "date_start"
568 _inherit = ['ir.needaction_mixin', 'mail.thread']
570 def _get_default_project_id(self, cr, uid, context=None):
571 """ Gives default section by checking if present in the context """
572 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
574 def _get_default_stage_id(self, cr, uid, context=None):
575 """ Gives default stage_id """
576 project_id = self._get_default_project_id(cr, uid, context=context)
577 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
579 def _resolve_project_id_from_context(self, cr, uid, context=None):
580 """ Returns ID of project based on the value of 'default_project_id'
581 context key, or None if it cannot be resolved to a single
584 if context is None: context = {}
585 if type(context.get('default_project_id')) in (int, long):
586 return context['default_project_id']
587 if isinstance(context.get('default_project_id'), basestring):
588 project_name = context['default_project_id']
589 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
590 if len(project_ids) == 1:
591 return project_ids[0][0]
594 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
595 stage_obj = self.pool.get('project.task.type')
596 order = stage_obj._order
597 access_rights_uid = access_rights_uid or uid
598 # lame way to allow reverting search, should just work in the trivial case
599 if read_group_order == 'stage_id desc':
600 order = '%s desc' % order
601 # retrieve section_id from the context and write the domain
602 # - ('id', 'in', 'ids'): add columns that should be present
603 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
604 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
606 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
608 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
609 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
610 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
611 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
612 # restore order of the search
613 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
616 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
617 res_users = self.pool.get('res.users')
618 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
619 access_rights_uid = access_rights_uid or uid
621 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
622 order = res_users._order
623 # lame way to allow reverting search, should just work in the trivial case
624 if read_group_order == 'user_id desc':
625 order = '%s desc' % order
626 # de-duplicate and apply search order
627 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
628 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
629 # restore order of the search
630 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
634 'stage_id': _read_group_stage_ids,
635 'user_id': _read_group_user_id,
638 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
639 obj_project = self.pool.get('project.project')
641 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
642 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
643 if id and isinstance(id, (long, int)):
644 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
645 args.append(('active', '=', False))
646 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
648 def _str_get(self, task, level=0, border='***', context=None):
649 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'+ \
650 border[0]+' '+(task.name or '')+'\n'+ \
651 (task.description or '')+'\n\n'
653 # Compute: effective_hours, total_hours, progress
654 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
656 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
657 hours = dict(cr.fetchall())
658 for task in self.browse(cr, uid, ids, context=context):
659 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)}
660 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
661 res[task.id]['progress'] = 0.0
662 if (task.remaining_hours + hours.get(task.id, 0.0)):
663 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
664 if task.state in ('done','cancelled'):
665 res[task.id]['progress'] = 100.0
668 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
669 if remaining and not planned:
670 return {'value':{'planned_hours': remaining}}
673 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
674 return {'value':{'remaining_hours': planned - effective}}
676 def onchange_project(self, cr, uid, id, project_id):
679 data = self.pool.get('project.project').browse(cr, uid, [project_id])
680 partner_id=data and data[0].partner_id
682 return {'value':{'partner_id':partner_id.id}}
685 def duplicate_task(self, cr, uid, map_ids, context=None):
686 for new in map_ids.values():
687 task = self.browse(cr, uid, new, context)
688 child_ids = [ ch.id for ch in task.child_ids]
690 for child in task.child_ids:
691 if child.id in map_ids.keys():
692 child_ids.remove(child.id)
693 child_ids.append(map_ids[child.id])
695 parent_ids = [ ch.id for ch in task.parent_ids]
697 for parent in task.parent_ids:
698 if parent.id in map_ids.keys():
699 parent_ids.remove(parent.id)
700 parent_ids.append(map_ids[parent.id])
701 #FIXME why there is already the copy and the old one
702 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
704 def copy_data(self, cr, uid, id, default={}, context=None):
705 default = default or {}
706 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
707 if not default.get('remaining_hours', False):
708 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
709 default['active'] = True
710 default['stage_id'] = False
711 if not default.get('name', False):
712 default['name'] = self.browse(cr, uid, id, context=context).name or ''
713 if not context.get('copy',False):
714 new_name = _("%s (copy)")%default.get('name','')
715 default.update({'name':new_name})
716 return super(task, self).copy_data(cr, uid, id, default, context)
719 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
721 for task in self.browse(cr, uid, ids, context=context):
724 if task.project_id.active == False or task.project_id.state == 'template':
728 def _get_task(self, cr, uid, ids, context=None):
730 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
731 if work.task_id: result[work.task_id.id] = True
735 '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."),
736 'name': fields.char('Task Summary', size=128, required=True, select=True),
737 'description': fields.text('Description'),
738 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
739 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
740 'stage_id': fields.many2one('project.task.type', 'Stage',
741 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
742 'state': fields.related('stage_id', 'state', type="selection", store=True,
743 selection=_TASK_STATE, string="State", readonly=True,
744 help='The state is set to \'Draft\', when a case is created.\
745 If the case is in progress the state is set to \'Open\'.\
746 When the case is over, the state is set to \'Done\'.\
747 If the case needs to be reviewed then the state is \
748 set to \'Pending\'.'),
749 'categ_ids': fields.many2many('project.category', string='Categories'),
750 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
751 help="A task's kanban state indicates special situations affecting it:\n"
752 " * Normal is the default situation\n"
753 " * Blocked indicates something is preventing the progress of this task\n"
754 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
755 readonly=True, required=False),
756 'create_date': fields.datetime('Create Date', readonly=True,select=True),
757 'date_start': fields.datetime('Starting Date',select=True),
758 'date_end': fields.datetime('Ending Date',select=True),
759 'date_deadline': fields.date('Deadline',select=True),
760 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
761 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
762 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
763 'notes': fields.text('Notes'),
764 '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.'),
765 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
767 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
768 'project.task.work': (_get_task, ['hours'], 10),
770 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
771 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
773 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
774 'project.task.work': (_get_task, ['hours'], 10),
776 '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",
778 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
779 'project.task.work': (_get_task, ['hours'], 10),
781 '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.",
783 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
784 'project.task.work': (_get_task, ['hours'], 10),
786 'user_id': fields.many2one('res.users', 'Assigned to'),
787 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
788 'partner_id': fields.many2one('res.partner', 'Contact'),
789 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
790 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
791 'company_id': fields.many2one('res.company', 'Company'),
792 'id': fields.integer('ID', readonly=True),
793 'color': fields.integer('Color Index'),
794 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
798 'stage_id': _get_default_stage_id,
799 'project_id': _get_default_project_id,
801 'kanban_state': 'normal',
806 'user_id': lambda obj, cr, uid, context: uid,
807 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
810 _order = "priority, sequence, date_start, name, id"
812 def set_priority(self, cr, uid, ids, priority, *args):
815 return self.write(cr, uid, ids, {'priority' : priority})
817 def set_high_priority(self, cr, uid, ids, *args):
818 """Set task priority to high
820 return self.set_priority(cr, uid, ids, '1')
822 def set_normal_priority(self, cr, uid, ids, *args):
823 """Set task priority to normal
825 return self.set_priority(cr, uid, ids, '2')
827 def _check_recursion(self, cr, uid, ids, context=None):
829 visited_branch = set()
831 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
837 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
838 if id in visited_branch: #Cycle
841 if id in visited_node: #Already tested don't work one more time for nothing
844 visited_branch.add(id)
847 #visit child using DFS
848 task = self.browse(cr, uid, id, context=context)
849 for child in task.child_ids:
850 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
854 visited_branch.remove(id)
857 def _check_dates(self, cr, uid, ids, context=None):
860 obj_task = self.browse(cr, uid, ids[0], context=context)
861 start = obj_task.date_start or False
862 end = obj_task.date_end or False
869 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
870 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
873 # Override view according to the company definition
875 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
876 users_obj = self.pool.get('res.users')
877 if context is None: context = {}
878 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
879 # this should be safe (no context passed to avoid side-effects)
880 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
881 tm = obj_tm and obj_tm.name or 'Hours'
883 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
885 if tm in ['Hours','Hour']:
888 eview = etree.fromstring(res['arch'])
890 def _check_rec(eview):
891 if eview.attrib.get('widget','') == 'float_time':
892 eview.set('widget','float')
899 res['arch'] = etree.tostring(eview)
901 for f in res['fields']:
902 if 'Hours' in res['fields'][f]['string']:
903 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
906 # ****************************************
908 # ****************************************
910 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
911 """ Override of the base.stage method
912 Parameter of the stage search taken from the lead:
913 - section_id: if set, stages must belong to this section or
914 be a default stage; if not set, stages must be default
917 if isinstance(cases, (int, long)):
918 cases = self.browse(cr, uid, cases, context=context)
919 # collect all section_ids
922 section_ids.append(section_id)
925 section_ids.append(task.project_id.id)
926 # OR all section_ids and OR with case_default
929 search_domain += [('|')] * len(section_ids)
930 for section_id in section_ids:
931 search_domain.append(('project_ids', '=', section_id))
932 search_domain.append(('case_default', '=', True))
933 # AND with the domain in parameter
934 search_domain += list(domain)
935 # perform search, return the first found
936 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
941 def _check_child_task(self, cr, uid, ids, context=None):
944 tasks = self.browse(cr, uid, ids, context=context)
947 for child in task.child_ids:
948 if child.state in ['draft', 'open', 'pending']:
949 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
952 def action_close(self, cr, uid, ids, context=None):
953 """ This action closes the task
955 task_id = len(ids) and ids[0] or False
956 self._check_child_task(cr, uid, ids, context=context)
957 if not task_id: return False
958 return self.do_close(cr, uid, [task_id], context=context)
960 def do_close(self, cr, uid, ids, context=None):
961 """ Compatibility when changing to case_close. """
962 return self.case_close(cr, uid, ids, context=context)
964 def case_close(self, cr, uid, ids, context=None):
966 if not isinstance(ids, list): ids = [ids]
967 for task in self.browse(cr, uid, ids, context=context):
969 project = task.project_id
970 for parent_id in task.parent_ids:
971 if parent_id.state in ('pending','draft'):
973 for child in parent_id.child_ids:
974 if child.id != task.id and child.state not in ('done','cancelled'):
977 self.do_reopen(cr, uid, [parent_id.id], context=context)
979 vals['remaining_hours'] = 0.0
980 if not task.date_end:
981 vals['date_end'] = fields.datetime.now()
982 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
983 self.case_close_send_note(cr, uid, [task.id], context=context)
986 def do_reopen(self, cr, uid, ids, context=None):
987 for task in self.browse(cr, uid, ids, context=context):
988 project = task.project_id
989 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
990 self.case_open_send_note(cr, uid, [task.id], context)
993 def do_cancel(self, cr, uid, ids, context=None):
994 """ Compatibility when changing to case_cancel. """
995 return self.case_cancel(cr, uid, ids, context=context)
997 def case_cancel(self, cr, uid, ids, context=None):
998 tasks = self.browse(cr, uid, ids, context=context)
999 self._check_child_task(cr, uid, ids, context=context)
1001 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1002 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1005 def do_open(self, cr, uid, ids, context=None):
1006 """ Compatibility when changing to case_open. """
1007 return self.case_open(cr, uid, ids, context=context)
1009 def case_open(self, cr, uid, ids, context=None):
1010 if not isinstance(ids,list): ids = [ids]
1011 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1012 self.case_open_send_note(cr, uid, ids, context)
1015 def do_draft(self, cr, uid, ids, context=None):
1016 """ Compatibility when changing to case_draft. """
1017 return self.case_draft(cr, uid, ids, context=context)
1019 def case_draft(self, cr, uid, ids, context=None):
1020 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1021 self.case_draft_send_note(cr, uid, ids, context=context)
1024 def do_pending(self, cr, uid, ids, context=None):
1025 """ Compatibility when changing to case_pending. """
1026 return self.case_pending(cr, uid, ids, context=context)
1028 def case_pending(self, cr, uid, ids, context=None):
1029 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1030 return self.case_pending_send_note(cr, uid, ids, context=context)
1032 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1033 attachment = self.pool.get('ir.attachment')
1034 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1035 new_attachment_ids = []
1036 for attachment_id in attachment_ids:
1037 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1038 return new_attachment_ids
1040 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1042 Delegate Task to another users.
1044 assert delegate_data['user_id'], _("Delegated User should be specified")
1045 delegated_tasks = {}
1046 for task in self.browse(cr, uid, ids, context=context):
1047 delegated_task_id = self.copy(cr, uid, task.id, {
1048 'name': delegate_data['name'],
1049 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1050 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1051 'planned_hours': delegate_data['planned_hours'] or 0.0,
1052 'parent_ids': [(6, 0, [task.id])],
1054 'description': delegate_data['new_task_description'] or '',
1058 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1059 newname = delegate_data['prefix'] or ''
1061 'remaining_hours': delegate_data['planned_hours_me'],
1062 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1065 if delegate_data['state'] == 'pending':
1066 self.do_pending(cr, uid, [task.id], context=context)
1067 elif delegate_data['state'] == 'done':
1068 self.do_close(cr, uid, [task.id], context=context)
1069 self.do_delegation_send_note(cr, uid, [task.id], context)
1070 delegated_tasks[task.id] = delegated_task_id
1071 return delegated_tasks
1073 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1074 for task in self.browse(cr, uid, ids, context=context):
1075 if (task.state=='draft') or (task.planned_hours==0.0):
1076 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1077 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1080 def set_remaining_time_1(self, cr, uid, ids, context=None):
1081 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1083 def set_remaining_time_2(self, cr, uid, ids, context=None):
1084 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1086 def set_remaining_time_5(self, cr, uid, ids, context=None):
1087 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1089 def set_remaining_time_10(self, cr, uid, ids, context=None):
1090 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1092 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1093 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1096 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1097 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1100 def set_kanban_state_done(self, cr, uid, ids, context=None):
1101 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1104 def _store_history(self, cr, uid, ids, context=None):
1105 for task in self.browse(cr, uid, ids, context=context):
1106 self.pool.get('project.task.history').create(cr, uid, {
1108 'remaining_hours': task.remaining_hours,
1109 'planned_hours': task.planned_hours,
1110 'kanban_state': task.kanban_state,
1111 'type_id': task.stage_id.id,
1112 'state': task.state,
1113 'user_id': task.user_id.id
1118 def create(self, cr, uid, vals, context=None):
1119 task_id = super(task, self).create(cr, uid, vals, context=context)
1120 self._store_history(cr, uid, [task_id], context=context)
1121 self.create_send_note(cr, uid, [task_id], context=context)
1124 # Overridden to reset the kanban_state to normal whenever
1125 # the stage (stage_id) of the task changes.
1126 def write(self, cr, uid, ids, vals, context=None):
1127 if isinstance(ids, (int, long)):
1129 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1130 new_stage = vals.get('stage_id')
1131 vals_reset_kstate = dict(vals, kanban_state='normal')
1132 for t in self.browse(cr, uid, ids, context=context):
1133 #TO FIX:Kanban view doesn't raise warning
1134 #stages = [stage.id for stage in t.project_id.type_ids]
1135 #if new_stage not in stages:
1136 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1137 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1138 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1139 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1142 result = super(task,self).write(cr, uid, ids, vals, context=context)
1143 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1144 self._store_history(cr, uid, ids, context=context)
1147 def unlink(self, cr, uid, ids, context=None):
1150 self._check_child_task(cr, uid, ids, context=context)
1151 res = super(task, self).unlink(cr, uid, ids, context)
1154 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1155 context = context or {}
1159 if task.state in ('done','cancelled'):
1164 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1166 for t2 in task.parent_ids:
1167 start.append("up.Task_%s.end" % (t2.id,))
1171 ''' % (ident,','.join(start))
1176 ''' % (ident, 'User_'+str(task.user_id.id))
1181 # ---------------------------------------------------
1182 # OpenChatter methods and notifications
1183 # ---------------------------------------------------
1185 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1186 """ Override of default prefix for notifications. """
1189 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1190 """ Returns the user_ids that have to perform an action.
1191 Add to the previous results given by super the document responsible
1193 :return: dict { record_id: [user_ids], }
1195 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1196 for obj in self.browse(cr, uid, ids, context=context):
1197 if obj.state == 'draft' and obj.user_id:
1198 result[obj.id].append(obj.user_id.id)
1201 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1202 """ Add 'user_id' and 'manager_id' to the monitored fields """
1203 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1204 return res + ['user_id', 'manager_id']
1206 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1207 """ Override of the (void) default notification method. """
1208 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1209 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1211 def create_send_note(self, cr, uid, ids, context=None):
1212 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1214 def case_draft_send_note(self, cr, uid, ids, context=None):
1215 msg = _('Task has been set as <b>draft</b>.')
1216 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1218 def do_delegation_send_note(self, cr, uid, ids, context=None):
1219 for task in self.browse(cr, uid, ids, context=context):
1220 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1221 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1225 class project_work(osv.osv):
1226 _name = "project.task.work"
1227 _description = "Project Task Work"
1229 'name': fields.char('Work summary', size=128),
1230 'date': fields.datetime('Date', select="1"),
1231 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1232 'hours': fields.float('Time Spent'),
1233 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1234 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1238 'user_id': lambda obj, cr, uid, context: uid,
1239 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1242 _order = "date desc"
1243 def create(self, cr, uid, vals, *args, **kwargs):
1244 if 'hours' in vals and (not vals['hours']):
1245 vals['hours'] = 0.00
1246 if 'task_id' in vals:
1247 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1248 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1250 def write(self, cr, uid, ids, vals, context=None):
1251 if 'hours' in vals and (not vals['hours']):
1252 vals['hours'] = 0.00
1254 for work in self.browse(cr, uid, ids, context=context):
1255 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))
1256 return super(project_work,self).write(cr, uid, ids, vals, context)
1258 def unlink(self, cr, uid, ids, *args, **kwargs):
1259 for work in self.browse(cr, uid, ids):
1260 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1261 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1264 class account_analytic_account(osv.osv):
1265 _inherit = 'account.analytic.account'
1266 _description = 'Analytic Account'
1268 'use_tasks': fields.boolean('Tasks Mgmt.',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1269 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1272 def on_change_template(self, cr, uid, ids, template_id, context=None):
1273 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1274 if template_id and 'value' in res:
1275 template = self.browse(cr, uid, template_id, context=context)
1276 res['value']['use_tasks'] = template.use_tasks
1279 def _trigger_project_creation(self, cr, uid, vals, context=None):
1281 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.
1283 if context is None: context = {}
1284 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1286 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1288 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.
1290 project_pool = self.pool.get('project.project')
1291 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1292 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1294 'name': vals.get('name'),
1295 'analytic_account_id': analytic_account_id,
1297 return project_pool.create(cr, uid, project_values, context=context)
1300 def create(self, cr, uid, vals, context=None):
1303 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1304 vals['child_ids'] = []
1305 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1306 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1307 return analytic_account_id
1309 def write(self, cr, uid, ids, vals, context=None):
1310 name = vals.get('name')
1311 for account in self.browse(cr, uid, ids, context=context):
1313 vals['name'] = account.name
1314 self.project_create(cr, uid, account.id, vals, context=context)
1315 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1317 def unlink(self, cr, uid, ids, *args, **kwargs):
1318 project_obj = self.pool.get('project.project')
1319 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1321 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1322 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1324 class project_project(osv.osv):
1325 _inherit = 'project.project'
1332 # Tasks History, used for cumulative flow charts (Lean/Agile)
1335 class project_task_history(osv.osv):
1336 _name = 'project.task.history'
1337 _description = 'History of Tasks'
1338 _rec_name = 'task_id'
1340 def _get_date(self, cr, uid, ids, name, arg, context=None):
1342 for history in self.browse(cr, uid, ids, context=context):
1343 if history.state in ('done','cancelled'):
1344 result[history.id] = history.date
1346 cr.execute('''select
1349 project_task_history
1353 order by id limit 1''', (history.task_id.id, history.id))
1355 result[history.id] = res and res[0] or False
1358 def _get_related_date(self, cr, uid, ids, context=None):
1360 for history in self.browse(cr, uid, ids, context=context):
1361 cr.execute('''select
1364 project_task_history
1368 order by id desc limit 1''', (history.task_id.id, history.id))
1371 result.append(res[0])
1375 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1376 'type_id': fields.many2one('project.task.type', 'Stage'),
1377 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1378 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1379 'date': fields.date('Date', select=True),
1380 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1381 'project.task.history': (_get_related_date, None, 20)
1383 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1384 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1385 'user_id': fields.many2one('res.users', 'Responsible'),
1388 'date': fields.date.context_today,
1392 class project_task_history_cumulative(osv.osv):
1393 _name = 'project.task.history.cumulative'
1394 _table = 'project_task_history_cumulative'
1395 _inherit = 'project.task.history'
1398 'end_date': fields.date('End Date'),
1399 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1402 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1404 history.date::varchar||'-'||history.history_id::varchar as id,
1405 history.date as end_date,
1410 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1411 task_id, type_id, user_id, kanban_state, state,
1412 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1414 project_task_history
1420 class project_category(osv.osv):
1421 """ Category of project's task (or issue) """
1422 _name = "project.category"
1423 _description = "Category of project's task, issue, ..."
1425 'name': fields.char('Name', size=64, required=True, translate=True),