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',
272 # TODO: Why not using a SQL contraints ?
273 def _check_dates(self, cr, uid, ids, context=None):
274 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
275 if leave['date_start'] and leave['date']:
276 if leave['date_start'] > leave['date']:
281 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
284 def set_template(self, cr, uid, ids, context=None):
285 res = self.setActive(cr, uid, ids, value=False, context=context)
288 def set_done(self, cr, uid, ids, context=None):
289 task_obj = self.pool.get('project.task')
290 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
291 task_obj.case_close(cr, uid, task_ids, context=context)
292 self.write(cr, uid, ids, {'state':'close'}, context=context)
293 self.set_close_send_note(cr, uid, ids, context=context)
296 def set_cancel(self, cr, uid, ids, context=None):
297 task_obj = self.pool.get('project.task')
298 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
299 task_obj.case_cancel(cr, uid, task_ids, context=context)
300 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
301 self.set_cancel_send_note(cr, uid, ids, context=context)
304 def set_pending(self, cr, uid, ids, context=None):
305 self.write(cr, uid, ids, {'state':'pending'}, context=context)
306 self.set_pending_send_note(cr, uid, ids, context=context)
309 def set_open(self, cr, uid, ids, context=None):
310 self.write(cr, uid, ids, {'state':'open'}, context=context)
311 self.set_open_send_note(cr, uid, ids, context=context)
314 def reset_project(self, cr, uid, ids, context=None):
315 res = self.setActive(cr, uid, ids, value=True, context=context)
316 self.set_open_send_note(cr, uid, ids, context=context)
319 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
320 """ copy and map tasks from old to new project """
324 task_obj = self.pool.get('project.task')
325 proj = self.browse(cr, uid, old_project_id, context=context)
326 for task in proj.tasks:
327 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
328 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
329 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
332 def copy(self, cr, uid, id, default={}, context=None):
336 default = default or {}
337 context['active_test'] = False
338 default['state'] = 'open'
339 default['tasks'] = []
340 default.pop('alias_name', None)
341 default.pop('alias_id', None)
342 proj = self.browse(cr, uid, id, context=context)
343 if not default.get('name', False):
344 default['name'] = proj.name + _(' (copy)')
345 res = super(project, self).copy(cr, uid, id, default, context)
346 self.map_tasks(cr,uid,id,res,context)
349 def duplicate_template(self, cr, uid, ids, context=None):
352 data_obj = self.pool.get('ir.model.data')
354 for proj in self.browse(cr, uid, ids, context=context):
355 parent_id = context.get('parent_id', False)
356 context.update({'analytic_project_copy': True})
357 new_date_start = time.strftime('%Y-%m-%d')
359 if proj.date_start and proj.date:
360 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
361 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
362 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
363 context.update({'copy':True})
364 new_id = self.copy(cr, uid, proj.id, default = {
365 'name': proj.name +_(' (copy)'),
367 'date_start':new_date_start,
369 'parent_id':parent_id}, context=context)
370 result.append(new_id)
372 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
373 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
375 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
377 if result and len(result):
379 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
380 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
381 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
382 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
383 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
384 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
386 'name': _('Projects'),
388 'view_mode': 'form,tree',
389 'res_model': 'project.project',
392 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
393 'type': 'ir.actions.act_window',
394 'search_view_id': search_view['res_id'],
398 # set active value for a project, its sub projects and its tasks
399 def setActive(self, cr, uid, ids, value=True, context=None):
400 task_obj = self.pool.get('project.task')
401 for proj in self.browse(cr, uid, ids, context=None):
402 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
403 cr.execute('select id from project_task where project_id=%s', (proj.id,))
404 tasks_id = [x[0] for x in cr.fetchall()]
406 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
407 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
409 self.setActive(cr, uid, child_ids, value, context=None)
412 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
413 context = context or {}
414 if type(ids) in (long, int,):
416 projects = self.browse(cr, uid, ids, context=context)
418 for project in projects:
419 if (not project.members) and force_members:
420 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
422 resource_pool = self.pool.get('resource.resource')
424 result = "from openerp.addons.resource.faces import *\n"
425 result += "import datetime\n"
426 for project in self.browse(cr, uid, ids, context=context):
427 u_ids = [i.id for i in project.members]
428 if project.user_id and (project.user_id.id not in u_ids):
429 u_ids.append(project.user_id.id)
430 for task in project.tasks:
431 if task.state in ('done','cancelled'):
433 if task.user_id and (task.user_id.id not in u_ids):
434 u_ids.append(task.user_id.id)
435 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
436 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
437 for key, vals in resource_objs.items():
439 class User_%s(Resource):
441 ''' % (key, vals.get('efficiency', False))
448 def _schedule_project(self, cr, uid, project, context=None):
449 resource_pool = self.pool.get('resource.resource')
450 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
451 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
452 # TODO: check if we need working_..., default values are ok.
453 puids = [x.id for x in project.members]
455 puids.append(project.user_id.id)
463 project.date_start, working_days,
464 '|'.join(['User_'+str(x) for x in puids])
466 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
473 #TODO: DO Resource allocation and compute availability
474 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
480 def schedule_tasks(self, cr, uid, ids, context=None):
481 context = context or {}
482 if type(ids) in (long, int,):
484 projects = self.browse(cr, uid, ids, context=context)
485 result = self._schedule_header(cr, uid, ids, False, context=context)
486 for project in projects:
487 result += self._schedule_project(cr, uid, project, context=context)
488 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
491 exec result in local_dict
492 projects_gantt = Task.BalancedProject(local_dict['Project'])
494 for project in projects:
495 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
496 for task in project.tasks:
497 if task.state in ('done','cancelled'):
500 p = getattr(project_gantt, 'Task_%d' % (task.id,))
502 self.pool.get('project.task').write(cr, uid, [task.id], {
503 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
504 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
506 if (not task.user_id) and (p.booked_resource):
507 self.pool.get('project.task').write(cr, uid, [task.id], {
508 'user_id': int(p.booked_resource[0].name[5:]),
512 # ------------------------------------------------
513 # OpenChatter methods and notifications
514 # ------------------------------------------------
516 def message_get_subscribers(self, cr, uid, ids, context=None):
517 """ Override to add responsible user. """
518 user_ids = super(project, self).message_get_subscribers(cr, uid, ids, context=context)
519 for obj in self.browse(cr, uid, ids, context=context):
520 if obj.user_id and not obj.user_id.id in user_ids:
521 user_ids.append(obj.user_id.id)
524 def create(self, cr, uid, vals, context=None):
525 if context is None: context = {}
526 # Prevent double project creation when 'use_tasks' is checked!
527 context = dict(context, project_creation_in_progress=True)
528 mail_alias = self.pool.get('mail.alias')
529 if not vals.get('alias_id'):
530 vals.pop('alias_name', None) # prevent errors during copy()
531 alias_id = mail_alias.create_unique_alias(cr, uid,
532 # Using '+' allows using subaddressing for those who don't
533 # have a catchall domain setup.
534 {'alias_name': "project+"+short_name(vals['name'])},
535 model_name=vals.get('alias_model', 'project.task'),
537 vals['alias_id'] = alias_id
538 project_id = super(project, self).create(cr, uid, vals, context)
539 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
540 self.create_send_note(cr, uid, [project_id], context=context)
543 def create_send_note(self, cr, uid, ids, context=None):
544 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
546 def set_open_send_note(self, cr, uid, ids, context=None):
547 message = _("Project has been <b>opened</b>.")
548 return self.message_append_note(cr, uid, ids, body=message, context=context)
550 def set_pending_send_note(self, cr, uid, ids, context=None):
551 message = _("Project is now <b>pending</b>.")
552 return self.message_append_note(cr, uid, ids, body=message, context=context)
554 def set_cancel_send_note(self, cr, uid, ids, context=None):
555 message = _("Project has been <b>cancelled</b>.")
556 return self.message_append_note(cr, uid, ids, body=message, context=context)
558 def set_close_send_note(self, cr, uid, ids, context=None):
559 message = _("Project has been <b>closed</b>.")
560 return self.message_append_note(cr, uid, ids, body=message, context=context)
562 def write(self, cr, uid, ids, vals, context=None):
563 # if alias_model has been changed, update alias_model_id accordingly
564 if vals.get('alias_model'):
565 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
566 vals.update(alias_model_id=model_ids[0])
567 return super(project, self).write(cr, uid, ids, vals, context=context)
569 class task(base_stage, osv.osv):
570 _name = "project.task"
571 _description = "Task"
572 _date_name = "date_start"
573 _inherit = ['ir.needaction_mixin', 'mail.thread']
575 def _get_default_project_id(self, cr, uid, context=None):
576 """ Gives default section by checking if present in the context """
577 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
579 def _get_default_stage_id(self, cr, uid, context=None):
580 """ Gives default stage_id """
581 project_id = self._get_default_project_id(cr, uid, context=context)
582 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
584 def _resolve_project_id_from_context(self, cr, uid, context=None):
585 """ Returns ID of project based on the value of 'default_project_id'
586 context key, or None if it cannot be resolved to a single
589 if context is None: context = {}
590 if type(context.get('default_project_id')) in (int, long):
591 return context['default_project_id']
592 if isinstance(context.get('default_project_id'), basestring):
593 project_name = context['default_project_id']
594 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
595 if len(project_ids) == 1:
596 return project_ids[0][0]
599 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
600 stage_obj = self.pool.get('project.task.type')
601 order = stage_obj._order
602 access_rights_uid = access_rights_uid or uid
603 # lame way to allow reverting search, should just work in the trivial case
604 if read_group_order == 'stage_id desc':
605 order = '%s desc' % order
606 # retrieve section_id from the context and write the domain
607 # - ('id', 'in', 'ids'): add columns that should be present
608 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
609 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
611 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
613 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
614 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
615 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
616 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
617 # restore order of the search
618 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
621 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
622 res_users = self.pool.get('res.users')
623 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
624 access_rights_uid = access_rights_uid or uid
626 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
627 order = res_users._order
628 # lame way to allow reverting search, should just work in the trivial case
629 if read_group_order == 'user_id desc':
630 order = '%s desc' % order
631 # de-duplicate and apply search order
632 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
633 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
634 # restore order of the search
635 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
639 'stage_id': _read_group_stage_ids,
640 'user_id': _read_group_user_id,
643 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
644 obj_project = self.pool.get('project.project')
646 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
647 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
648 if id and isinstance(id, (long, int)):
649 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
650 args.append(('active', '=', False))
651 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
653 def _str_get(self, task, level=0, border='***', context=None):
654 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'+ \
655 border[0]+' '+(task.name or '')+'\n'+ \
656 (task.description or '')+'\n\n'
658 # Compute: effective_hours, total_hours, progress
659 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
661 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
662 hours = dict(cr.fetchall())
663 for task in self.browse(cr, uid, ids, context=context):
664 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)}
665 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
666 res[task.id]['progress'] = 0.0
667 if (task.remaining_hours + hours.get(task.id, 0.0)):
668 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
669 if task.state in ('done','cancelled'):
670 res[task.id]['progress'] = 100.0
673 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
674 if remaining and not planned:
675 return {'value':{'planned_hours': remaining}}
678 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
679 return {'value':{'remaining_hours': planned - effective}}
681 def onchange_project(self, cr, uid, id, project_id):
684 data = self.pool.get('project.project').browse(cr, uid, [project_id])
685 partner_id=data and data[0].partner_id
687 return {'value':{'partner_id':partner_id.id}}
690 def duplicate_task(self, cr, uid, map_ids, context=None):
691 for new in map_ids.values():
692 task = self.browse(cr, uid, new, context)
693 child_ids = [ ch.id for ch in task.child_ids]
695 for child in task.child_ids:
696 if child.id in map_ids.keys():
697 child_ids.remove(child.id)
698 child_ids.append(map_ids[child.id])
700 parent_ids = [ ch.id for ch in task.parent_ids]
702 for parent in task.parent_ids:
703 if parent.id in map_ids.keys():
704 parent_ids.remove(parent.id)
705 parent_ids.append(map_ids[parent.id])
706 #FIXME why there is already the copy and the old one
707 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
709 def copy_data(self, cr, uid, id, default={}, context=None):
710 default = default or {}
711 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
712 if not default.get('remaining_hours', False):
713 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
714 default['active'] = True
715 default['stage_id'] = False
716 if not default.get('name', False):
717 default['name'] = self.browse(cr, uid, id, context=context).name or ''
718 if not context.get('copy',False):
719 new_name = _("%s (copy)")%default.get('name','')
720 default.update({'name':new_name})
721 return super(task, self).copy_data(cr, uid, id, default, context)
724 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
726 for task in self.browse(cr, uid, ids, context=context):
729 if task.project_id.active == False or task.project_id.state == 'template':
733 def _get_task(self, cr, uid, ids, context=None):
735 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
736 if work.task_id: result[work.task_id.id] = True
740 '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."),
741 'name': fields.char('Task Summary', size=128, required=True, select=True),
742 'description': fields.text('Description'),
743 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
744 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
745 'stage_id': fields.many2one('project.task.type', 'Stage',
746 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
747 'state': fields.related('stage_id', 'state', type="selection", store=True,
748 selection=_TASK_STATE, string="State", readonly=True,
749 help='The state is set to \'Draft\', when a case is created.\
750 If the case is in progress the state is set to \'Open\'.\
751 When the case is over, the state is set to \'Done\'.\
752 If the case needs to be reviewed then the state is \
753 set to \'Pending\'.'),
754 'categ_ids': fields.many2many('project.category', string='Categories'),
755 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
756 help="A task's kanban state indicates special situations affecting it:\n"
757 " * Normal is the default situation\n"
758 " * Blocked indicates something is preventing the progress of this task\n"
759 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
760 readonly=True, required=False),
761 'create_date': fields.datetime('Create Date', readonly=True,select=True),
762 'date_start': fields.datetime('Starting Date',select=True),
763 'date_end': fields.datetime('Ending Date',select=True),
764 'date_deadline': fields.date('Deadline',select=True),
765 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
766 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
767 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
768 'notes': fields.text('Notes'),
769 '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.'),
770 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
772 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
773 'project.task.work': (_get_task, ['hours'], 10),
775 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
776 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
778 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
779 'project.task.work': (_get_task, ['hours'], 10),
781 '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",
783 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
784 'project.task.work': (_get_task, ['hours'], 10),
786 '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.",
788 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
789 'project.task.work': (_get_task, ['hours'], 10),
791 'user_id': fields.many2one('res.users', 'Assigned to'),
792 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
793 'partner_id': fields.many2one('res.partner', 'Contact'),
794 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
795 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
796 'company_id': fields.many2one('res.company', 'Company'),
797 'id': fields.integer('ID', readonly=True),
798 'color': fields.integer('Color Index'),
799 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
803 'stage_id': _get_default_stage_id,
804 'project_id': _get_default_project_id,
806 'kanban_state': 'normal',
811 'user_id': lambda obj, cr, uid, context: uid,
812 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
815 _order = "priority, sequence, date_start, name, id"
817 def set_priority(self, cr, uid, ids, priority, *args):
820 return self.write(cr, uid, ids, {'priority' : priority})
822 def set_high_priority(self, cr, uid, ids, *args):
823 """Set task priority to high
825 return self.set_priority(cr, uid, ids, '1')
827 def set_normal_priority(self, cr, uid, ids, *args):
828 """Set task priority to normal
830 return self.set_priority(cr, uid, ids, '2')
832 def _check_recursion(self, cr, uid, ids, context=None):
834 visited_branch = set()
836 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
842 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
843 if id in visited_branch: #Cycle
846 if id in visited_node: #Already tested don't work one more time for nothing
849 visited_branch.add(id)
852 #visit child using DFS
853 task = self.browse(cr, uid, id, context=context)
854 for child in task.child_ids:
855 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
859 visited_branch.remove(id)
862 def _check_dates(self, cr, uid, ids, context=None):
865 obj_task = self.browse(cr, uid, ids[0], context=context)
866 start = obj_task.date_start or False
867 end = obj_task.date_end or False
874 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
875 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
878 # Override view according to the company definition
880 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
881 users_obj = self.pool.get('res.users')
882 if context is None: context = {}
883 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
884 # this should be safe (no context passed to avoid side-effects)
885 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
886 tm = obj_tm and obj_tm.name or 'Hours'
888 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
890 if tm in ['Hours','Hour']:
893 eview = etree.fromstring(res['arch'])
895 def _check_rec(eview):
896 if eview.attrib.get('widget','') == 'float_time':
897 eview.set('widget','float')
904 res['arch'] = etree.tostring(eview)
906 for f in res['fields']:
907 if 'Hours' in res['fields'][f]['string']:
908 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
911 # ****************************************
913 # ****************************************
915 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
916 """ Override of the base.stage method
917 Parameter of the stage search taken from the lead:
918 - section_id: if set, stages must belong to this section or
919 be a default stage; if not set, stages must be default
922 if isinstance(cases, (int, long)):
923 cases = self.browse(cr, uid, cases, context=context)
924 # collect all section_ids
927 section_ids.append(section_id)
930 section_ids.append(task.project_id.id)
931 # OR all section_ids and OR with case_default
934 search_domain += [('|')] * len(section_ids)
935 for section_id in section_ids:
936 search_domain.append(('project_ids', '=', section_id))
937 search_domain.append(('case_default', '=', True))
938 # AND with the domain in parameter
939 search_domain += list(domain)
940 # perform search, return the first found
941 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
946 def _check_child_task(self, cr, uid, ids, context=None):
949 tasks = self.browse(cr, uid, ids, context=context)
952 for child in task.child_ids:
953 if child.state in ['draft', 'open', 'pending']:
954 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
957 def action_close(self, cr, uid, ids, context=None):
958 """ This action closes the task
960 task_id = len(ids) and ids[0] or False
961 self._check_child_task(cr, uid, ids, context=context)
962 if not task_id: return False
963 return self.do_close(cr, uid, [task_id], context=context)
965 def do_close(self, cr, uid, ids, context=None):
966 """ Compatibility when changing to case_close. """
967 return self.case_close(cr, uid, ids, context=context)
969 def case_close(self, cr, uid, ids, context=None):
971 if not isinstance(ids, list): ids = [ids]
972 for task in self.browse(cr, uid, ids, context=context):
974 project = task.project_id
975 for parent_id in task.parent_ids:
976 if parent_id.state in ('pending','draft'):
978 for child in parent_id.child_ids:
979 if child.id != task.id and child.state not in ('done','cancelled'):
982 self.do_reopen(cr, uid, [parent_id.id], context=context)
984 vals['remaining_hours'] = 0.0
985 if not task.date_end:
986 vals['date_end'] = fields.datetime.now()
987 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
988 self.case_close_send_note(cr, uid, [task.id], context=context)
991 def do_reopen(self, cr, uid, ids, context=None):
992 for task in self.browse(cr, uid, ids, context=context):
993 project = task.project_id
994 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
995 self.case_open_send_note(cr, uid, [task.id], context)
998 def do_cancel(self, cr, uid, ids, context=None):
999 """ Compatibility when changing to case_cancel. """
1000 return self.case_cancel(cr, uid, ids, context=context)
1002 def case_cancel(self, cr, uid, ids, context=None):
1003 tasks = self.browse(cr, uid, ids, context=context)
1004 self._check_child_task(cr, uid, ids, context=context)
1006 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1007 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1010 def do_open(self, cr, uid, ids, context=None):
1011 """ Compatibility when changing to case_open. """
1012 return self.case_open(cr, uid, ids, context=context)
1014 def case_open(self, cr, uid, ids, context=None):
1015 if not isinstance(ids,list): ids = [ids]
1016 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1017 self.case_open_send_note(cr, uid, ids, context)
1020 def do_draft(self, cr, uid, ids, context=None):
1021 """ Compatibility when changing to case_draft. """
1022 return self.case_draft(cr, uid, ids, context=context)
1024 def case_draft(self, cr, uid, ids, context=None):
1025 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1026 self.case_draft_send_note(cr, uid, ids, context=context)
1029 def do_pending(self, cr, uid, ids, context=None):
1030 """ Compatibility when changing to case_pending. """
1031 return self.case_pending(cr, uid, ids, context=context)
1033 def case_pending(self, cr, uid, ids, context=None):
1034 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1035 return self.case_pending_send_note(cr, uid, ids, context=context)
1037 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1038 attachment = self.pool.get('ir.attachment')
1039 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1040 new_attachment_ids = []
1041 for attachment_id in attachment_ids:
1042 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1043 return new_attachment_ids
1045 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1047 Delegate Task to another users.
1049 assert delegate_data['user_id'], _("Delegated User should be specified")
1050 delegated_tasks = {}
1051 for task in self.browse(cr, uid, ids, context=context):
1052 delegated_task_id = self.copy(cr, uid, task.id, {
1053 'name': delegate_data['name'],
1054 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1055 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1056 'planned_hours': delegate_data['planned_hours'] or 0.0,
1057 'parent_ids': [(6, 0, [task.id])],
1059 'description': delegate_data['new_task_description'] or '',
1063 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1064 newname = delegate_data['prefix'] or ''
1066 'remaining_hours': delegate_data['planned_hours_me'],
1067 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1070 if delegate_data['state'] == 'pending':
1071 self.do_pending(cr, uid, [task.id], context=context)
1072 elif delegate_data['state'] == 'done':
1073 self.do_close(cr, uid, [task.id], context=context)
1074 self.do_delegation_send_note(cr, uid, [task.id], context)
1075 delegated_tasks[task.id] = delegated_task_id
1076 return delegated_tasks
1078 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1079 for task in self.browse(cr, uid, ids, context=context):
1080 if (task.state=='draft') or (task.planned_hours==0.0):
1081 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1082 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1085 def set_remaining_time_1(self, cr, uid, ids, context=None):
1086 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1088 def set_remaining_time_2(self, cr, uid, ids, context=None):
1089 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1091 def set_remaining_time_5(self, cr, uid, ids, context=None):
1092 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1094 def set_remaining_time_10(self, cr, uid, ids, context=None):
1095 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1097 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1098 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1101 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1102 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1105 def set_kanban_state_done(self, cr, uid, ids, context=None):
1106 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1109 def _store_history(self, cr, uid, ids, context=None):
1110 for task in self.browse(cr, uid, ids, context=context):
1111 self.pool.get('project.task.history').create(cr, uid, {
1113 'remaining_hours': task.remaining_hours,
1114 'planned_hours': task.planned_hours,
1115 'kanban_state': task.kanban_state,
1116 'type_id': task.stage_id.id,
1117 'state': task.state,
1118 'user_id': task.user_id.id
1123 def create(self, cr, uid, vals, context=None):
1124 task_id = super(task, self).create(cr, uid, vals, context=context)
1125 self._store_history(cr, uid, [task_id], context=context)
1126 self.create_send_note(cr, uid, [task_id], context=context)
1129 # Overridden to reset the kanban_state to normal whenever
1130 # the stage (stage_id) of the task changes.
1131 def write(self, cr, uid, ids, vals, context=None):
1132 if isinstance(ids, (int, long)):
1134 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1135 new_stage = vals.get('stage_id')
1136 vals_reset_kstate = dict(vals, kanban_state='normal')
1137 for t in self.browse(cr, uid, ids, context=context):
1138 #TO FIX:Kanban view doesn't raise warning
1139 #stages = [stage.id for stage in t.project_id.type_ids]
1140 #if new_stage not in stages:
1141 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1142 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1143 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1144 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1147 result = super(task,self).write(cr, uid, ids, vals, context=context)
1148 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1149 self._store_history(cr, uid, ids, context=context)
1152 def unlink(self, cr, uid, ids, context=None):
1155 self._check_child_task(cr, uid, ids, context=context)
1156 res = super(task, self).unlink(cr, uid, ids, context)
1159 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1160 context = context or {}
1164 if task.state in ('done','cancelled'):
1169 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1171 for t2 in task.parent_ids:
1172 start.append("up.Task_%s.end" % (t2.id,))
1176 ''' % (ident,','.join(start))
1181 ''' % (ident, 'User_'+str(task.user_id.id))
1186 # ---------------------------------------------------
1187 # OpenChatter methods and notifications
1188 # ---------------------------------------------------
1190 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1191 """ Override of default prefix for notifications. """
1194 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1195 """ Returns the user_ids that have to perform an action.
1196 Add to the previous results given by super the document responsible
1198 :return: dict { record_id: [user_ids], }
1200 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1201 for obj in self.browse(cr, uid, ids, context=context):
1202 if obj.state == 'draft' and obj.user_id:
1203 result[obj.id].append(obj.user_id.id)
1206 def message_get_subscribers(self, cr, uid, ids, context=None):
1207 """ Override to add responsible user and project manager. """
1208 user_ids = super(task, self).message_get_subscribers(cr, uid, ids, context=context)
1209 for obj in self.browse(cr, uid, ids, context=context):
1210 if obj.user_id and not obj.user_id.id in user_ids:
1211 user_ids.append(obj.user_id.id)
1212 if obj.manager_id and not obj.manager_id.id in user_ids:
1213 user_ids.append(obj.manager_id.id)
1216 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1217 """ Override of the (void) default notification method. """
1218 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1219 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1221 def create_send_note(self, cr, uid, ids, context=None):
1222 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1224 def case_draft_send_note(self, cr, uid, ids, context=None):
1225 msg = _('Task has been set as <b>draft</b>.')
1226 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1228 def do_delegation_send_note(self, cr, uid, ids, context=None):
1229 for task in self.browse(cr, uid, ids, context=context):
1230 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1231 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1235 class project_work(osv.osv):
1236 _name = "project.task.work"
1237 _description = "Project Task Work"
1239 'name': fields.char('Work summary', size=128),
1240 'date': fields.datetime('Date', select="1"),
1241 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1242 'hours': fields.float('Time Spent'),
1243 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1244 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1248 'user_id': lambda obj, cr, uid, context: uid,
1249 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1252 _order = "date desc"
1253 def create(self, cr, uid, vals, *args, **kwargs):
1254 if 'hours' in vals and (not vals['hours']):
1255 vals['hours'] = 0.00
1256 if 'task_id' in vals:
1257 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1258 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1260 def write(self, cr, uid, ids, vals, context=None):
1261 if 'hours' in vals and (not vals['hours']):
1262 vals['hours'] = 0.00
1264 for work in self.browse(cr, uid, ids, context=context):
1265 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))
1266 return super(project_work,self).write(cr, uid, ids, vals, context)
1268 def unlink(self, cr, uid, ids, *args, **kwargs):
1269 for work in self.browse(cr, uid, ids):
1270 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1271 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1274 class account_analytic_account(osv.osv):
1275 _inherit = 'account.analytic.account'
1276 _description = 'Analytic Account'
1278 '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"),
1279 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1282 def on_change_template(self, cr, uid, ids, template_id, context=None):
1283 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1284 if template_id and 'value' in res:
1285 template = self.browse(cr, uid, template_id, context=context)
1286 res['value']['use_tasks'] = template.use_tasks
1289 def _trigger_project_creation(self, cr, uid, vals, context=None):
1291 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.
1293 if context is None: context = {}
1294 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1296 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1298 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.
1300 project_pool = self.pool.get('project.project')
1301 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1302 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1304 'name': vals.get('name'),
1305 'analytic_account_id': analytic_account_id,
1307 return project_pool.create(cr, uid, project_values, context=context)
1310 def create(self, cr, uid, vals, context=None):
1313 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1314 vals['child_ids'] = []
1315 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1316 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1317 return analytic_account_id
1319 def write(self, cr, uid, ids, vals, context=None):
1320 name = vals.get('name')
1321 for account in self.browse(cr, uid, ids, context=context):
1323 vals['name'] = account.name
1324 self.project_create(cr, uid, account.id, vals, context=context)
1325 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1327 def unlink(self, cr, uid, ids, *args, **kwargs):
1328 project_obj = self.pool.get('project.project')
1329 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1331 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1332 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1334 class project_project(osv.osv):
1335 _inherit = 'project.project'
1342 # Tasks History, used for cumulative flow charts (Lean/Agile)
1345 class project_task_history(osv.osv):
1346 _name = 'project.task.history'
1347 _description = 'History of Tasks'
1348 _rec_name = 'task_id'
1350 def _get_date(self, cr, uid, ids, name, arg, context=None):
1352 for history in self.browse(cr, uid, ids, context=context):
1353 if history.state in ('done','cancelled'):
1354 result[history.id] = history.date
1356 cr.execute('''select
1359 project_task_history
1363 order by id limit 1''', (history.task_id.id, history.id))
1365 result[history.id] = res and res[0] or False
1368 def _get_related_date(self, cr, uid, ids, context=None):
1370 for history in self.browse(cr, uid, ids, context=context):
1371 cr.execute('''select
1374 project_task_history
1378 order by id desc limit 1''', (history.task_id.id, history.id))
1381 result.append(res[0])
1385 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1386 'type_id': fields.many2one('project.task.type', 'Stage'),
1387 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1388 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1389 'date': fields.date('Date', select=True),
1390 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1391 'project.task.history': (_get_related_date, None, 20)
1393 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1394 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1395 'user_id': fields.many2one('res.users', 'Responsible'),
1398 'date': fields.date.context_today,
1402 class project_task_history_cumulative(osv.osv):
1403 _name = 'project.task.history.cumulative'
1404 _table = 'project_task_history_cumulative'
1405 _inherit = 'project.task.history'
1408 'end_date': fields.date('End Date'),
1409 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1412 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1414 history.date::varchar||'-'||history.history_id::varchar as id,
1415 history.date as end_date,
1420 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1421 task_id, type_id, user_id, kanban_state, state,
1422 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1424 project_task_history
1430 class project_category(osv.osv):
1431 """ Category of project's task (or issue) """
1432 _name = "project.category"
1433 _description = "Category of project's task, issue, ..."
1435 'name': fields.char('Name', size=64, required=True, translate=True),