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 dummy(self, cr, uid, ids, context):
256 def _get_type_common(self, cr, uid, context):
257 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
267 'type_ids': _get_type_common,
268 'alias_model': 'project.task',
269 'privacy_visibility': 'public',
270 'alias_domain': False, # always hide alias during creation
273 # TODO: Why not using a SQL contraints ?
274 def _check_dates(self, cr, uid, ids, context=None):
275 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
276 if leave['date_start'] and leave['date']:
277 if leave['date_start'] > leave['date']:
282 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
285 def set_template(self, cr, uid, ids, context=None):
286 res = self.setActive(cr, uid, ids, value=False, context=context)
289 def set_done(self, cr, uid, ids, context=None):
290 task_obj = self.pool.get('project.task')
291 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
292 task_obj.case_close(cr, uid, task_ids, context=context)
293 self.write(cr, uid, ids, {'state':'close'}, context=context)
294 self.set_close_send_note(cr, uid, ids, context=context)
297 def set_cancel(self, cr, uid, ids, context=None):
298 task_obj = self.pool.get('project.task')
299 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
300 task_obj.case_cancel(cr, uid, task_ids, context=context)
301 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
302 self.set_cancel_send_note(cr, uid, ids, context=context)
305 def set_pending(self, cr, uid, ids, context=None):
306 self.write(cr, uid, ids, {'state':'pending'}, context=context)
307 self.set_pending_send_note(cr, uid, ids, context=context)
310 def set_open(self, cr, uid, ids, context=None):
311 self.write(cr, uid, ids, {'state':'open'}, context=context)
312 self.set_open_send_note(cr, uid, ids, context=context)
315 def reset_project(self, cr, uid, ids, context=None):
316 res = self.setActive(cr, uid, ids, value=True, context=context)
317 self.set_open_send_note(cr, uid, ids, context=context)
320 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
321 """ copy and map tasks from old to new project """
325 task_obj = self.pool.get('project.task')
326 proj = self.browse(cr, uid, old_project_id, context=context)
327 for task in proj.tasks:
328 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
329 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
330 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
333 def copy(self, cr, uid, id, default={}, context=None):
337 default = default or {}
338 context['active_test'] = False
339 default['state'] = 'open'
340 default['tasks'] = []
341 default.pop('alias_name', None)
342 default.pop('alias_id', None)
343 proj = self.browse(cr, uid, id, context=context)
344 if not default.get('name', False):
345 default['name'] = proj.name + _(' (copy)')
346 res = super(project, self).copy(cr, uid, id, default, context)
347 self.map_tasks(cr,uid,id,res,context)
350 def duplicate_template(self, cr, uid, ids, context=None):
353 data_obj = self.pool.get('ir.model.data')
355 for proj in self.browse(cr, uid, ids, context=context):
356 parent_id = context.get('parent_id', False)
357 context.update({'analytic_project_copy': True})
358 new_date_start = time.strftime('%Y-%m-%d')
360 if proj.date_start and proj.date:
361 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
362 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
363 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
364 context.update({'copy':True})
365 new_id = self.copy(cr, uid, proj.id, default = {
366 'name': proj.name +_(' (copy)'),
368 'date_start':new_date_start,
370 'parent_id':parent_id}, context=context)
371 result.append(new_id)
373 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
374 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
376 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
378 if result and len(result):
380 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
381 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
382 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
383 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
384 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
385 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
387 'name': _('Projects'),
389 'view_mode': 'form,tree',
390 'res_model': 'project.project',
393 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
394 'type': 'ir.actions.act_window',
395 'search_view_id': search_view['res_id'],
399 # set active value for a project, its sub projects and its tasks
400 def setActive(self, cr, uid, ids, value=True, context=None):
401 task_obj = self.pool.get('project.task')
402 for proj in self.browse(cr, uid, ids, context=None):
403 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
404 cr.execute('select id from project_task where project_id=%s', (proj.id,))
405 tasks_id = [x[0] for x in cr.fetchall()]
407 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
408 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
410 self.setActive(cr, uid, child_ids, value, context=None)
413 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
414 context = context or {}
415 if type(ids) in (long, int,):
417 projects = self.browse(cr, uid, ids, context=context)
419 for project in projects:
420 if (not project.members) and force_members:
421 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
423 resource_pool = self.pool.get('resource.resource')
425 result = "from openerp.addons.resource.faces import *\n"
426 result += "import datetime\n"
427 for project in self.browse(cr, uid, ids, context=context):
428 u_ids = [i.id for i in project.members]
429 if project.user_id and (project.user_id.id not in u_ids):
430 u_ids.append(project.user_id.id)
431 for task in project.tasks:
432 if task.state in ('done','cancelled'):
434 if task.user_id and (task.user_id.id not in u_ids):
435 u_ids.append(task.user_id.id)
436 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
437 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
438 for key, vals in resource_objs.items():
440 class User_%s(Resource):
442 ''' % (key, vals.get('efficiency', False))
449 def _schedule_project(self, cr, uid, project, context=None):
450 resource_pool = self.pool.get('resource.resource')
451 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
452 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
453 # TODO: check if we need working_..., default values are ok.
454 puids = [x.id for x in project.members]
456 puids.append(project.user_id.id)
464 project.date_start, working_days,
465 '|'.join(['User_'+str(x) for x in puids])
467 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
474 #TODO: DO Resource allocation and compute availability
475 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
481 def schedule_tasks(self, cr, uid, ids, context=None):
482 context = context or {}
483 if type(ids) in (long, int,):
485 projects = self.browse(cr, uid, ids, context=context)
486 result = self._schedule_header(cr, uid, ids, False, context=context)
487 for project in projects:
488 result += self._schedule_project(cr, uid, project, context=context)
489 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
492 exec result in local_dict
493 projects_gantt = Task.BalancedProject(local_dict['Project'])
495 for project in projects:
496 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
497 for task in project.tasks:
498 if task.state in ('done','cancelled'):
501 p = getattr(project_gantt, 'Task_%d' % (task.id,))
503 self.pool.get('project.task').write(cr, uid, [task.id], {
504 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
505 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
507 if (not task.user_id) and (p.booked_resource):
508 self.pool.get('project.task').write(cr, uid, [task.id], {
509 'user_id': int(p.booked_resource[0].name[5:]),
513 # ------------------------------------------------
514 # OpenChatter methods and notifications
515 # ------------------------------------------------
517 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
518 """ Add 'user_id' to the monitored fields """
519 res = super(project, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
520 return res + ['user_id']
522 def create(self, cr, uid, vals, context=None):
523 if context is None: context = {}
524 # Prevent double project creation when 'use_tasks' is checked!
525 context = dict(context, project_creation_in_progress=True)
526 mail_alias = self.pool.get('mail.alias')
527 if not vals.get('alias_id'):
528 vals.pop('alias_name', None) # prevent errors during copy()
529 alias_id = mail_alias.create_unique_alias(cr, uid,
530 # Using '+' allows using subaddressing for those who don't
531 # have a catchall domain setup.
532 {'alias_name': "project+"+short_name(vals['name'])},
533 model_name=vals.get('alias_model', 'project.task'),
535 vals['alias_id'] = alias_id
536 project_id = super(project, self).create(cr, uid, vals, context)
537 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
538 self.create_send_note(cr, uid, [project_id], context=context)
541 def create_send_note(self, cr, uid, ids, context=None):
542 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
544 def set_open_send_note(self, cr, uid, ids, context=None):
545 message = _("Project has been <b>opened</b>.")
546 return self.message_append_note(cr, uid, ids, body=message, context=context)
548 def set_pending_send_note(self, cr, uid, ids, context=None):
549 message = _("Project is now <b>pending</b>.")
550 return self.message_append_note(cr, uid, ids, body=message, context=context)
552 def set_cancel_send_note(self, cr, uid, ids, context=None):
553 message = _("Project has been <b>cancelled</b>.")
554 return self.message_append_note(cr, uid, ids, body=message, context=context)
556 def set_close_send_note(self, cr, uid, ids, context=None):
557 message = _("Project has been <b>closed</b>.")
558 return self.message_append_note(cr, uid, ids, body=message, context=context)
560 def write(self, cr, uid, ids, vals, context=None):
561 # if alias_model has been changed, update alias_model_id accordingly
562 if vals.get('alias_model'):
563 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
564 vals.update(alias_model_id=model_ids[0])
565 return super(project, self).write(cr, uid, ids, vals, context=context)
567 class task(base_stage, osv.osv):
568 _name = "project.task"
569 _description = "Task"
570 _date_name = "date_start"
571 _inherit = ['ir.needaction_mixin', 'mail.thread']
573 def _get_default_project_id(self, cr, uid, context=None):
574 """ Gives default section by checking if present in the context """
575 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
577 def _get_default_stage_id(self, cr, uid, context=None):
578 """ Gives default stage_id """
579 project_id = self._get_default_project_id(cr, uid, context=context)
580 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
582 def _resolve_project_id_from_context(self, cr, uid, context=None):
583 """ Returns ID of project based on the value of 'default_project_id'
584 context key, or None if it cannot be resolved to a single
587 if context is None: context = {}
588 if type(context.get('default_project_id')) in (int, long):
589 return context['default_project_id']
590 if isinstance(context.get('default_project_id'), basestring):
591 project_name = context['default_project_id']
592 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
593 if len(project_ids) == 1:
594 return project_ids[0][0]
597 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
598 stage_obj = self.pool.get('project.task.type')
599 order = stage_obj._order
600 access_rights_uid = access_rights_uid or uid
601 # lame way to allow reverting search, should just work in the trivial case
602 if read_group_order == 'stage_id desc':
603 order = '%s desc' % order
604 # retrieve section_id from the context and write the domain
605 # - ('id', 'in', 'ids'): add columns that should be present
606 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
607 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
609 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
611 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
612 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
613 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
614 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
615 # restore order of the search
616 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
619 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
620 res_users = self.pool.get('res.users')
621 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
622 access_rights_uid = access_rights_uid or uid
624 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
625 order = res_users._order
626 # lame way to allow reverting search, should just work in the trivial case
627 if read_group_order == 'user_id desc':
628 order = '%s desc' % order
629 # de-duplicate and apply search order
630 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
631 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
632 # restore order of the search
633 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
637 'stage_id': _read_group_stage_ids,
638 'user_id': _read_group_user_id,
641 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
642 obj_project = self.pool.get('project.project')
644 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
645 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
646 if id and isinstance(id, (long, int)):
647 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
648 args.append(('active', '=', False))
649 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
651 def _str_get(self, task, level=0, border='***', context=None):
652 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'+ \
653 border[0]+' '+(task.name or '')+'\n'+ \
654 (task.description or '')+'\n\n'
656 # Compute: effective_hours, total_hours, progress
657 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
659 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
660 hours = dict(cr.fetchall())
661 for task in self.browse(cr, uid, ids, context=context):
662 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)}
663 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
664 res[task.id]['progress'] = 0.0
665 if (task.remaining_hours + hours.get(task.id, 0.0)):
666 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
667 if task.state in ('done','cancelled'):
668 res[task.id]['progress'] = 100.0
671 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
672 if remaining and not planned:
673 return {'value':{'planned_hours': remaining}}
676 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
677 return {'value':{'remaining_hours': planned - effective}}
679 def onchange_project(self, cr, uid, id, project_id):
682 data = self.pool.get('project.project').browse(cr, uid, [project_id])
683 partner_id=data and data[0].partner_id
685 return {'value':{'partner_id':partner_id.id}}
688 def duplicate_task(self, cr, uid, map_ids, context=None):
689 for new in map_ids.values():
690 task = self.browse(cr, uid, new, context)
691 child_ids = [ ch.id for ch in task.child_ids]
693 for child in task.child_ids:
694 if child.id in map_ids.keys():
695 child_ids.remove(child.id)
696 child_ids.append(map_ids[child.id])
698 parent_ids = [ ch.id for ch in task.parent_ids]
700 for parent in task.parent_ids:
701 if parent.id in map_ids.keys():
702 parent_ids.remove(parent.id)
703 parent_ids.append(map_ids[parent.id])
704 #FIXME why there is already the copy and the old one
705 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
707 def copy_data(self, cr, uid, id, default={}, context=None):
708 default = default or {}
709 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
710 if not default.get('remaining_hours', False):
711 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
712 default['active'] = True
713 default['stage_id'] = False
714 if not default.get('name', False):
715 default['name'] = self.browse(cr, uid, id, context=context).name or ''
716 if not context.get('copy',False):
717 new_name = _("%s (copy)")%default.get('name','')
718 default.update({'name':new_name})
719 return super(task, self).copy_data(cr, uid, id, default, context)
722 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
724 for task in self.browse(cr, uid, ids, context=context):
727 if task.project_id.active == False or task.project_id.state == 'template':
731 def _get_task(self, cr, uid, ids, context=None):
733 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
734 if work.task_id: result[work.task_id.id] = True
738 '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."),
739 'name': fields.char('Task Summary', size=128, required=True, select=True),
740 'description': fields.text('Description'),
741 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
742 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
743 'stage_id': fields.many2one('project.task.type', 'Stage',
744 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
745 'state': fields.related('stage_id', 'state', type="selection", store=True,
746 selection=_TASK_STATE, string="State", readonly=True,
747 help='The state is set to \'Draft\', when a case is created.\
748 If the case is in progress the state is set to \'Open\'.\
749 When the case is over, the state is set to \'Done\'.\
750 If the case needs to be reviewed then the state is \
751 set to \'Pending\'.'),
752 'categ_ids': fields.many2many('project.category', string='Categories'),
753 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
754 help="A task's kanban state indicates special situations affecting it:\n"
755 " * Normal is the default situation\n"
756 " * Blocked indicates something is preventing the progress of this task\n"
757 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
758 readonly=True, required=False),
759 'create_date': fields.datetime('Create Date', readonly=True,select=True),
760 'date_start': fields.datetime('Starting Date',select=True),
761 'date_end': fields.datetime('Ending Date',select=True),
762 'date_deadline': fields.date('Deadline',select=True),
763 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
764 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
765 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
766 'notes': fields.text('Notes'),
767 '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.'),
768 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
770 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
771 'project.task.work': (_get_task, ['hours'], 10),
773 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
774 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
776 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
777 'project.task.work': (_get_task, ['hours'], 10),
779 '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",
781 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
782 'project.task.work': (_get_task, ['hours'], 10),
784 '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.",
786 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
787 'project.task.work': (_get_task, ['hours'], 10),
789 'user_id': fields.many2one('res.users', 'Assigned to'),
790 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
791 'partner_id': fields.many2one('res.partner', 'Contact'),
792 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
793 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
794 'company_id': fields.many2one('res.company', 'Company'),
795 'id': fields.integer('ID', readonly=True),
796 'color': fields.integer('Color Index'),
797 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
801 'stage_id': _get_default_stage_id,
802 'project_id': _get_default_project_id,
804 'kanban_state': 'normal',
809 'user_id': lambda obj, cr, uid, context: uid,
810 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
813 _order = "priority, sequence, date_start, name, id"
815 def set_priority(self, cr, uid, ids, priority, *args):
818 return self.write(cr, uid, ids, {'priority' : priority})
820 def set_high_priority(self, cr, uid, ids, *args):
821 """Set task priority to high
823 return self.set_priority(cr, uid, ids, '1')
825 def set_normal_priority(self, cr, uid, ids, *args):
826 """Set task priority to normal
828 return self.set_priority(cr, uid, ids, '2')
830 def _check_recursion(self, cr, uid, ids, context=None):
832 visited_branch = set()
834 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
840 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
841 if id in visited_branch: #Cycle
844 if id in visited_node: #Already tested don't work one more time for nothing
847 visited_branch.add(id)
850 #visit child using DFS
851 task = self.browse(cr, uid, id, context=context)
852 for child in task.child_ids:
853 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
857 visited_branch.remove(id)
860 def _check_dates(self, cr, uid, ids, context=None):
863 obj_task = self.browse(cr, uid, ids[0], context=context)
864 start = obj_task.date_start or False
865 end = obj_task.date_end or False
872 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
873 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
876 # Override view according to the company definition
878 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
879 users_obj = self.pool.get('res.users')
880 if context is None: context = {}
881 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
882 # this should be safe (no context passed to avoid side-effects)
883 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
884 tm = obj_tm and obj_tm.name or 'Hours'
886 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
888 if tm in ['Hours','Hour']:
891 eview = etree.fromstring(res['arch'])
893 def _check_rec(eview):
894 if eview.attrib.get('widget','') == 'float_time':
895 eview.set('widget','float')
902 res['arch'] = etree.tostring(eview)
904 for f in res['fields']:
905 if 'Hours' in res['fields'][f]['string']:
906 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
909 # ****************************************
911 # ****************************************
913 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
914 """ Override of the base.stage method
915 Parameter of the stage search taken from the lead:
916 - section_id: if set, stages must belong to this section or
917 be a default stage; if not set, stages must be default
920 if isinstance(cases, (int, long)):
921 cases = self.browse(cr, uid, cases, context=context)
922 # collect all section_ids
925 section_ids.append(section_id)
928 section_ids.append(task.project_id.id)
929 # OR all section_ids and OR with case_default
932 search_domain += [('|')] * len(section_ids)
933 for section_id in section_ids:
934 search_domain.append(('project_ids', '=', section_id))
935 search_domain.append(('case_default', '=', True))
936 # AND with the domain in parameter
937 search_domain += list(domain)
938 # perform search, return the first found
939 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
944 def _check_child_task(self, cr, uid, ids, context=None):
947 tasks = self.browse(cr, uid, ids, context=context)
950 for child in task.child_ids:
951 if child.state in ['draft', 'open', 'pending']:
952 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
955 def action_close(self, cr, uid, ids, context=None):
956 """ This action closes the task
958 task_id = len(ids) and ids[0] or False
959 self._check_child_task(cr, uid, ids, context=context)
960 if not task_id: return False
961 return self.do_close(cr, uid, [task_id], context=context)
963 def do_close(self, cr, uid, ids, context=None):
964 """ Compatibility when changing to case_close. """
965 return self.case_close(cr, uid, ids, context=context)
967 def case_close(self, cr, uid, ids, context=None):
969 if not isinstance(ids, list): ids = [ids]
970 for task in self.browse(cr, uid, ids, context=context):
972 project = task.project_id
973 for parent_id in task.parent_ids:
974 if parent_id.state in ('pending','draft'):
976 for child in parent_id.child_ids:
977 if child.id != task.id and child.state not in ('done','cancelled'):
980 self.do_reopen(cr, uid, [parent_id.id], context=context)
982 vals['remaining_hours'] = 0.0
983 if not task.date_end:
984 vals['date_end'] = fields.datetime.now()
985 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
986 self.case_close_send_note(cr, uid, [task.id], context=context)
989 def do_reopen(self, cr, uid, ids, context=None):
990 for task in self.browse(cr, uid, ids, context=context):
991 project = task.project_id
992 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
993 self.case_open_send_note(cr, uid, [task.id], context)
996 def do_cancel(self, cr, uid, ids, context=None):
997 """ Compatibility when changing to case_cancel. """
998 return self.case_cancel(cr, uid, ids, context=context)
1000 def case_cancel(self, cr, uid, ids, context=None):
1001 tasks = self.browse(cr, uid, ids, context=context)
1002 self._check_child_task(cr, uid, ids, context=context)
1004 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1005 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1008 def do_open(self, cr, uid, ids, context=None):
1009 """ Compatibility when changing to case_open. """
1010 return self.case_open(cr, uid, ids, context=context)
1012 def case_open(self, cr, uid, ids, context=None):
1013 if not isinstance(ids,list): ids = [ids]
1014 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1015 self.case_open_send_note(cr, uid, ids, context)
1018 def do_draft(self, cr, uid, ids, context=None):
1019 """ Compatibility when changing to case_draft. """
1020 return self.case_draft(cr, uid, ids, context=context)
1022 def case_draft(self, cr, uid, ids, context=None):
1023 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1024 self.case_draft_send_note(cr, uid, ids, context=context)
1027 def do_pending(self, cr, uid, ids, context=None):
1028 """ Compatibility when changing to case_pending. """
1029 return self.case_pending(cr, uid, ids, context=context)
1031 def case_pending(self, cr, uid, ids, context=None):
1032 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1033 return self.case_pending_send_note(cr, uid, ids, context=context)
1035 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1036 attachment = self.pool.get('ir.attachment')
1037 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1038 new_attachment_ids = []
1039 for attachment_id in attachment_ids:
1040 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1041 return new_attachment_ids
1043 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1045 Delegate Task to another users.
1047 assert delegate_data['user_id'], _("Delegated User should be specified")
1048 delegated_tasks = {}
1049 for task in self.browse(cr, uid, ids, context=context):
1050 delegated_task_id = self.copy(cr, uid, task.id, {
1051 'name': delegate_data['name'],
1052 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1053 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1054 'planned_hours': delegate_data['planned_hours'] or 0.0,
1055 'parent_ids': [(6, 0, [task.id])],
1057 'description': delegate_data['new_task_description'] or '',
1061 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1062 newname = delegate_data['prefix'] or ''
1064 'remaining_hours': delegate_data['planned_hours_me'],
1065 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1068 if delegate_data['state'] == 'pending':
1069 self.do_pending(cr, uid, [task.id], context=context)
1070 elif delegate_data['state'] == 'done':
1071 self.do_close(cr, uid, [task.id], context=context)
1072 self.do_delegation_send_note(cr, uid, [task.id], context)
1073 delegated_tasks[task.id] = delegated_task_id
1074 return delegated_tasks
1076 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1077 for task in self.browse(cr, uid, ids, context=context):
1078 if (task.state=='draft') or (task.planned_hours==0.0):
1079 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1080 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1083 def set_remaining_time_1(self, cr, uid, ids, context=None):
1084 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1086 def set_remaining_time_2(self, cr, uid, ids, context=None):
1087 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1089 def set_remaining_time_5(self, cr, uid, ids, context=None):
1090 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1092 def set_remaining_time_10(self, cr, uid, ids, context=None):
1093 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1095 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1096 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1099 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1100 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1103 def set_kanban_state_done(self, cr, uid, ids, context=None):
1104 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1107 def _store_history(self, cr, uid, ids, context=None):
1108 for task in self.browse(cr, uid, ids, context=context):
1109 self.pool.get('project.task.history').create(cr, uid, {
1111 'remaining_hours': task.remaining_hours,
1112 'planned_hours': task.planned_hours,
1113 'kanban_state': task.kanban_state,
1114 'type_id': task.stage_id.id,
1115 'state': task.state,
1116 'user_id': task.user_id.id
1121 def create(self, cr, uid, vals, context=None):
1122 task_id = super(task, self).create(cr, uid, vals, context=context)
1123 self._store_history(cr, uid, [task_id], context=context)
1124 self.create_send_note(cr, uid, [task_id], context=context)
1127 # Overridden to reset the kanban_state to normal whenever
1128 # the stage (stage_id) of the task changes.
1129 def write(self, cr, uid, ids, vals, context=None):
1130 if isinstance(ids, (int, long)):
1132 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1133 new_stage = vals.get('stage_id')
1134 vals_reset_kstate = dict(vals, kanban_state='normal')
1135 for t in self.browse(cr, uid, ids, context=context):
1136 #TO FIX:Kanban view doesn't raise warning
1137 #stages = [stage.id for stage in t.project_id.type_ids]
1138 #if new_stage not in stages:
1139 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1140 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1141 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1142 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1145 result = super(task,self).write(cr, uid, ids, vals, context=context)
1146 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1147 self._store_history(cr, uid, ids, context=context)
1150 def unlink(self, cr, uid, ids, context=None):
1153 self._check_child_task(cr, uid, ids, context=context)
1154 res = super(task, self).unlink(cr, uid, ids, context)
1157 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1158 context = context or {}
1162 if task.state in ('done','cancelled'):
1167 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1169 for t2 in task.parent_ids:
1170 start.append("up.Task_%s.end" % (t2.id,))
1174 ''' % (ident,','.join(start))
1179 ''' % (ident, 'User_'+str(task.user_id.id))
1184 # ---------------------------------------------------
1185 # OpenChatter methods and notifications
1186 # ---------------------------------------------------
1188 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1189 """ Override of default prefix for notifications. """
1192 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1193 """ Returns the user_ids that have to perform an action.
1194 Add to the previous results given by super the document responsible
1196 :return: dict { record_id: [user_ids], }
1198 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1199 for obj in self.browse(cr, uid, ids, context=context):
1200 if obj.state == 'draft' and obj.user_id:
1201 result[obj.id].append(obj.user_id.id)
1204 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1205 """ Add 'user_id' and 'manager_id' to the monitored fields """
1206 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1207 return res + ['user_id', 'manager_id']
1209 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1210 """ Override of the (void) default notification method. """
1211 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1212 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1214 def create_send_note(self, cr, uid, ids, context=None):
1215 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1217 def case_draft_send_note(self, cr, uid, ids, context=None):
1218 msg = _('Task has been set as <b>draft</b>.')
1219 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1221 def do_delegation_send_note(self, cr, uid, ids, context=None):
1222 for task in self.browse(cr, uid, ids, context=context):
1223 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1224 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1228 class project_work(osv.osv):
1229 _name = "project.task.work"
1230 _description = "Project Task Work"
1232 'name': fields.char('Work summary', size=128),
1233 'date': fields.datetime('Date', select="1"),
1234 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1235 'hours': fields.float('Time Spent'),
1236 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1237 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1241 'user_id': lambda obj, cr, uid, context: uid,
1242 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1245 _order = "date desc"
1246 def create(self, cr, uid, vals, *args, **kwargs):
1247 if 'hours' in vals and (not vals['hours']):
1248 vals['hours'] = 0.00
1249 if 'task_id' in vals:
1250 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1251 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1253 def write(self, cr, uid, ids, vals, context=None):
1254 if 'hours' in vals and (not vals['hours']):
1255 vals['hours'] = 0.00
1257 for work in self.browse(cr, uid, ids, context=context):
1258 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))
1259 return super(project_work,self).write(cr, uid, ids, vals, context)
1261 def unlink(self, cr, uid, ids, *args, **kwargs):
1262 for work in self.browse(cr, uid, ids):
1263 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1264 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1267 class account_analytic_account(osv.osv):
1268 _inherit = 'account.analytic.account'
1269 _description = 'Analytic Account'
1271 '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"),
1272 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1275 def on_change_template(self, cr, uid, ids, template_id, context=None):
1276 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1277 if template_id and 'value' in res:
1278 template = self.browse(cr, uid, template_id, context=context)
1279 res['value']['use_tasks'] = template.use_tasks
1282 def _trigger_project_creation(self, cr, uid, vals, context=None):
1284 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.
1286 if context is None: context = {}
1287 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1289 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1291 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.
1293 project_pool = self.pool.get('project.project')
1294 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1295 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1297 'name': vals.get('name'),
1298 'analytic_account_id': analytic_account_id,
1300 return project_pool.create(cr, uid, project_values, context=context)
1303 def create(self, cr, uid, vals, context=None):
1306 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1307 vals['child_ids'] = []
1308 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1309 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1310 return analytic_account_id
1312 def write(self, cr, uid, ids, vals, context=None):
1313 name = vals.get('name')
1314 for account in self.browse(cr, uid, ids, context=context):
1316 vals['name'] = account.name
1317 self.project_create(cr, uid, account.id, vals, context=context)
1318 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1320 def unlink(self, cr, uid, ids, *args, **kwargs):
1321 project_obj = self.pool.get('project.project')
1322 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1324 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1325 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1327 class project_project(osv.osv):
1328 _inherit = 'project.project'
1335 # Tasks History, used for cumulative flow charts (Lean/Agile)
1338 class project_task_history(osv.osv):
1339 _name = 'project.task.history'
1340 _description = 'History of Tasks'
1341 _rec_name = 'task_id'
1343 def _get_date(self, cr, uid, ids, name, arg, context=None):
1345 for history in self.browse(cr, uid, ids, context=context):
1346 if history.state in ('done','cancelled'):
1347 result[history.id] = history.date
1349 cr.execute('''select
1352 project_task_history
1356 order by id limit 1''', (history.task_id.id, history.id))
1358 result[history.id] = res and res[0] or False
1361 def _get_related_date(self, cr, uid, ids, context=None):
1363 for history in self.browse(cr, uid, ids, context=context):
1364 cr.execute('''select
1367 project_task_history
1371 order by id desc limit 1''', (history.task_id.id, history.id))
1374 result.append(res[0])
1378 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1379 'type_id': fields.many2one('project.task.type', 'Stage'),
1380 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1381 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1382 'date': fields.date('Date', select=True),
1383 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1384 'project.task.history': (_get_related_date, None, 20)
1386 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1387 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1388 'user_id': fields.many2one('res.users', 'Responsible'),
1391 'date': fields.date.context_today,
1395 class project_task_history_cumulative(osv.osv):
1396 _name = 'project.task.history.cumulative'
1397 _table = 'project_task_history_cumulative'
1398 _inherit = 'project.task.history'
1401 'end_date': fields.date('End Date'),
1402 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1405 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1407 history.date::varchar||'-'||history.history_id::varchar as id,
1408 history.date as end_date,
1413 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1414 task_id, type_id, user_id, kanban_state, state,
1415 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1417 project_task_history
1423 class project_category(osv.osv):
1424 """ Category of project's task (or issue) """
1425 _name = "project.category"
1426 _description = "Category of project's task, issue, ..."
1428 'name': fields.char('Name', size=64, required=True, translate=True),