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 _
29 from openerp import SUPERUSER_ID
31 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
33 class project_task_type(osv.osv):
34 _name = 'project.task.type'
35 _description = 'Task Stage'
38 'name': fields.char('Stage Name', required=True, size=64, translate=True),
39 'description': fields.text('Description'),
40 'sequence': fields.integer('Sequence'),
41 'case_default': fields.boolean('Common to All Projects',
42 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."),
43 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
44 'state': fields.selection(_TASK_STATE, 'State', required=True,
45 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."),
46 'fold': fields.boolean('Hide in views if empty',
47 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
58 """Keep first word(s) of name to make it small enough
60 if not name: return name
61 # keep 7 chars + end of the last word
62 keep_words = name[:7].strip().split()
63 return ' '.join(name.split()[:len(keep_words)])
65 class project(osv.osv):
66 _name = "project.project"
67 _description = "Project"
68 _inherits = {'account.analytic.account': "analytic_account_id",
69 "mail.alias": "alias_id"}
70 _inherit = ['ir.needaction_mixin', 'mail.thread']
72 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
74 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
75 if context and context.get('user_preference'):
76 cr.execute("""SELECT project.id FROM project_project project
77 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
78 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
79 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
80 return [(r[0]) for r in cr.fetchall()]
81 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
82 context=context, count=count)
84 def _complete_name(self, cr, uid, ids, name, args, context=None):
86 for m in self.browse(cr, uid, ids, context=context):
87 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
90 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
91 partner_obj = self.pool.get('res.partner')
95 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
96 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
97 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
98 val['pricelist_id'] = pricelist_id
101 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
102 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
103 project_ids = [task.project_id.id for task in tasks if task.project_id]
104 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
106 def _get_project_and_parents(self, cr, uid, ids, context=None):
107 """ return the project ids and all their parent projects """
111 SELECT DISTINCT parent.id
112 FROM project_project project, project_project parent, account_analytic_account account
113 WHERE project.analytic_account_id = account.id
114 AND parent.analytic_account_id = account.parent_id
117 ids = [t[0] for t in cr.fetchall()]
121 def _get_project_and_children(self, cr, uid, ids, context=None):
122 """ retrieve all children projects of project ids;
123 return a dictionary mapping each project to its parent project (or None)
125 res = dict.fromkeys(ids, None)
128 SELECT project.id, parent.id
129 FROM project_project project, project_project parent, account_analytic_account account
130 WHERE project.analytic_account_id = account.id
131 AND parent.analytic_account_id = account.parent_id
134 dic = dict(cr.fetchall())
139 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
140 child_parent = self._get_project_and_children(cr, uid, ids, context)
141 # compute planned_hours, total_hours, effective_hours specific to each project
143 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
144 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
145 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
147 """, (tuple(child_parent.keys()),))
148 # aggregate results into res
149 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
150 for id, planned, total, effective in cr.fetchall():
151 # add the values specific to id to all parent projects of id in the result
154 res[id]['planned_hours'] += planned
155 res[id]['total_hours'] += total
156 res[id]['effective_hours'] += effective
157 id = child_parent[id]
158 # compute progress rates
160 if res[id]['total_hours']:
161 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
163 res[id]['progress_rate'] = 0.0
166 def unlink(self, cr, uid, ids, *args, **kwargs):
168 mail_alias = self.pool.get('mail.alias')
169 for proj in self.browse(cr, uid, ids):
171 raise osv.except_osv(_('Invalid Action!'),
172 _('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.'))
174 alias_ids.append(proj.alias_id.id)
175 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
176 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
179 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
180 res = dict.fromkeys(ids, 0)
181 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
182 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
183 res[task.project_id.id] += 1
186 def _get_alias_models(self, cr, uid, context=None):
187 """Overriden in project_issue to offer more options"""
188 return [('project.task', "Tasks")]
190 def _get_followers(self, cr, uid, ids, name, arg, context=None):
192 Functional field that computes the users that are 'following' a thread.
195 for project in self.browse(cr, uid, ids, context=context):
197 for message in project.message_ids:
198 l.add(message.user_id and message.user_id.id or False)
199 res[project.id] = list(filter(None, l))
202 def _search_followers(self, cr, uid, obj, name, args, context=None):
203 project_obj = self.pool.get('project.project')
204 project_ids = project_obj.search(cr, uid, [('message_ids.user_id.id', 'in', args[0][2])], context=context)
205 return [('id', 'in', project_ids)]
207 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
208 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
211 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
212 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
213 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
214 'analytic_account_id': fields.many2one('account.analytic.account', 'Contract/Analytic', help="Link this project to an analytic account if you need financial management on projects. It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True),
215 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
216 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
217 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)]}),
218 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
219 '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.",
221 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
222 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
224 '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.",
226 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
227 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
229 '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.",
231 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
232 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
234 '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.",
236 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
237 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
239 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
240 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
241 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
242 'color': fields.integer('Color Index'),
243 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
244 help="Internal email associated with this project. Incoming emails are automatically synchronized"
245 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
246 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
247 help="The kind of document created when an email is received on this project's email alias"),
248 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
249 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
250 'followers': fields.function(_get_followers, method=True, fnct_search=_search_followers,
251 type='many2many', relation='res.users', string='Followers'),
254 def _get_type_common(self, cr, uid, context):
255 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
265 'type_ids': _get_type_common,
266 'alias_model': 'project.task',
267 'privacy_visibility': 'public',
268 'alias_domain': False, # always hide alias during creation
271 # TODO: Why not using a SQL contraints ?
272 def _check_dates(self, cr, uid, ids, context=None):
273 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
274 if leave['date_start'] and leave['date']:
275 if leave['date_start'] > leave['date']:
280 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
283 def set_template(self, cr, uid, ids, context=None):
284 res = self.setActive(cr, uid, ids, value=False, context=context)
287 def set_done(self, cr, uid, ids, context=None):
288 task_obj = self.pool.get('project.task')
289 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
290 task_obj.case_close(cr, uid, task_ids, context=context)
291 self.write(cr, uid, ids, {'state':'close'}, context=context)
292 self.set_close_send_note(cr, uid, ids, context=context)
295 def set_cancel(self, cr, uid, ids, context=None):
296 task_obj = self.pool.get('project.task')
297 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
298 task_obj.case_cancel(cr, uid, task_ids, context=context)
299 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
300 self.set_cancel_send_note(cr, uid, ids, context=context)
303 def set_pending(self, cr, uid, ids, context=None):
304 self.write(cr, uid, ids, {'state':'pending'}, context=context)
305 self.set_pending_send_note(cr, uid, ids, context=context)
308 def set_open(self, cr, uid, ids, context=None):
309 self.write(cr, uid, ids, {'state':'open'}, context=context)
310 self.set_open_send_note(cr, uid, ids, context=context)
313 def reset_project(self, cr, uid, ids, context=None):
314 res = self.setActive(cr, uid, ids, value=True, context=context)
315 self.set_open_send_note(cr, uid, ids, context=context)
318 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
319 """ copy and map tasks from old to new project """
323 task_obj = self.pool.get('project.task')
324 proj = self.browse(cr, uid, old_project_id, context=context)
325 for task in proj.tasks:
326 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
327 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
328 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
331 def copy(self, cr, uid, id, default={}, context=None):
335 default = default or {}
336 context['active_test'] = False
337 default['state'] = 'open'
338 default['tasks'] = []
339 default.pop('alias_name', None)
340 default.pop('alias_id', None)
341 proj = self.browse(cr, uid, id, context=context)
342 if not default.get('name', False):
343 default['name'] = proj.name + _(' (copy)')
344 res = super(project, self).copy(cr, uid, id, default, context)
345 self.map_tasks(cr,uid,id,res,context)
348 def duplicate_template(self, cr, uid, ids, context=None):
351 data_obj = self.pool.get('ir.model.data')
353 for proj in self.browse(cr, uid, ids, context=context):
354 parent_id = context.get('parent_id', False)
355 context.update({'analytic_project_copy': True})
356 new_date_start = time.strftime('%Y-%m-%d')
358 if proj.date_start and proj.date:
359 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
360 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
361 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
362 context.update({'copy':True})
363 new_id = self.copy(cr, uid, proj.id, default = {
364 'name': proj.name +_(' (copy)'),
366 'date_start':new_date_start,
368 'parent_id':parent_id}, context=context)
369 result.append(new_id)
371 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
372 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
374 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
376 if result and len(result):
378 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
379 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
380 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
381 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
382 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
383 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
385 'name': _('Projects'),
387 'view_mode': 'form,tree',
388 'res_model': 'project.project',
391 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
392 'type': 'ir.actions.act_window',
393 'search_view_id': search_view['res_id'],
397 # set active value for a project, its sub projects and its tasks
398 def setActive(self, cr, uid, ids, value=True, context=None):
399 task_obj = self.pool.get('project.task')
400 for proj in self.browse(cr, uid, ids, context=None):
401 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
402 cr.execute('select id from project_task where project_id=%s', (proj.id,))
403 tasks_id = [x[0] for x in cr.fetchall()]
405 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
406 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
408 self.setActive(cr, uid, child_ids, value, context=None)
411 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
412 context = context or {}
413 if type(ids) in (long, int,):
415 projects = self.browse(cr, uid, ids, context=context)
417 for project in projects:
418 if (not project.members) and force_members:
419 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
421 resource_pool = self.pool.get('resource.resource')
423 result = "from openerp.addons.resource.faces import *\n"
424 result += "import datetime\n"
425 for project in self.browse(cr, uid, ids, context=context):
426 u_ids = [i.id for i in project.members]
427 if project.user_id and (project.user_id.id not in u_ids):
428 u_ids.append(project.user_id.id)
429 for task in project.tasks:
430 if task.state in ('done','cancelled'):
432 if task.user_id and (task.user_id.id not in u_ids):
433 u_ids.append(task.user_id.id)
434 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
435 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
436 for key, vals in resource_objs.items():
438 class User_%s(Resource):
440 ''' % (key, vals.get('efficiency', False))
447 def _schedule_project(self, cr, uid, project, context=None):
448 resource_pool = self.pool.get('resource.resource')
449 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
450 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
451 # TODO: check if we need working_..., default values are ok.
452 puids = [x.id for x in project.members]
454 puids.append(project.user_id.id)
462 project.date_start, working_days,
463 '|'.join(['User_'+str(x) for x in puids])
465 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
472 #TODO: DO Resource allocation and compute availability
473 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
479 def schedule_tasks(self, cr, uid, ids, context=None):
480 context = context or {}
481 if type(ids) in (long, int,):
483 projects = self.browse(cr, uid, ids, context=context)
484 result = self._schedule_header(cr, uid, ids, False, context=context)
485 for project in projects:
486 result += self._schedule_project(cr, uid, project, context=context)
487 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
490 exec result in local_dict
491 projects_gantt = Task.BalancedProject(local_dict['Project'])
493 for project in projects:
494 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
495 for task in project.tasks:
496 if task.state in ('done','cancelled'):
499 p = getattr(project_gantt, 'Task_%d' % (task.id,))
501 self.pool.get('project.task').write(cr, uid, [task.id], {
502 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
503 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
505 if (not task.user_id) and (p.booked_resource):
506 self.pool.get('project.task').write(cr, uid, [task.id], {
507 'user_id': int(p.booked_resource[0].name[5:]),
511 # ------------------------------------------------
512 # OpenChatter methods and notifications
513 # ------------------------------------------------
515 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
516 """ Add 'user_id' to the monitored fields """
517 res = super(project, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
518 return res + ['user_id']
520 def create(self, cr, uid, vals, context=None):
521 if context is None: context = {}
522 # Prevent double project creation when 'use_tasks' is checked!
523 context = dict(context, project_creation_in_progress=True)
524 mail_alias = self.pool.get('mail.alias')
525 if not vals.get('alias_id'):
526 vals.pop('alias_name', None) # prevent errors during copy()
527 alias_id = mail_alias.create_unique_alias(cr, uid,
528 # Using '+' allows using subaddressing for those who don't
529 # have a catchall domain setup.
530 {'alias_name': "project+"+short_name(vals['name'])},
531 model_name=vals.get('alias_model', 'project.task'),
533 vals['alias_id'] = alias_id
534 project_id = super(project, self).create(cr, uid, vals, context)
535 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
536 self.create_send_note(cr, uid, [project_id], context=context)
539 def create_send_note(self, cr, uid, ids, context=None):
540 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
542 def set_open_send_note(self, cr, uid, ids, context=None):
543 message = _("Project has been <b>opened</b>.")
544 return self.message_append_note(cr, uid, ids, body=message, context=context)
546 def set_pending_send_note(self, cr, uid, ids, context=None):
547 message = _("Project is now <b>pending</b>.")
548 return self.message_append_note(cr, uid, ids, body=message, context=context)
550 def set_cancel_send_note(self, cr, uid, ids, context=None):
551 message = _("Project has been <b>cancelled</b>.")
552 return self.message_append_note(cr, uid, ids, body=message, context=context)
554 def set_close_send_note(self, cr, uid, ids, context=None):
555 message = _("Project has been <b>closed</b>.")
556 return self.message_append_note(cr, uid, ids, body=message, context=context)
558 def write(self, cr, uid, ids, vals, context=None):
559 # if alias_model has been changed, update alias_model_id accordingly
560 if vals.get('alias_model'):
561 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
562 vals.update(alias_model_id=model_ids[0])
563 return super(project, self).write(cr, uid, ids, vals, context=context)
565 class task(base_stage, osv.osv):
566 _name = "project.task"
567 _description = "Task"
568 _date_name = "date_start"
569 _inherit = ['ir.needaction_mixin', 'mail.thread']
571 def _get_default_project_id(self, cr, uid, context=None):
572 """ Gives default section by checking if present in the context """
573 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
575 def _get_default_stage_id(self, cr, uid, context=None):
576 """ Gives default stage_id """
577 project_id = self._get_default_project_id(cr, uid, context=context)
578 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
580 def _resolve_project_id_from_context(self, cr, uid, context=None):
581 """ Returns ID of project based on the value of 'default_project_id'
582 context key, or None if it cannot be resolved to a single
585 if context is None: context = {}
586 if type(context.get('default_project_id')) in (int, long):
587 return context['default_project_id']
588 if isinstance(context.get('default_project_id'), basestring):
589 project_name = context['default_project_id']
590 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
591 if len(project_ids) == 1:
592 return project_ids[0][0]
595 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
596 stage_obj = self.pool.get('project.task.type')
597 order = stage_obj._order
598 access_rights_uid = access_rights_uid or uid
599 # lame way to allow reverting search, should just work in the trivial case
600 if read_group_order == 'stage_id desc':
601 order = '%s desc' % order
602 # retrieve section_id from the context and write the domain
603 # - ('id', 'in', 'ids'): add columns that should be present
604 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
605 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
607 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
609 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
610 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
611 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
612 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
613 # restore order of the search
614 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
617 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
618 res_users = self.pool.get('res.users')
619 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
620 access_rights_uid = access_rights_uid or uid
622 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
623 order = res_users._order
624 # lame way to allow reverting search, should just work in the trivial case
625 if read_group_order == 'user_id desc':
626 order = '%s desc' % order
627 # de-duplicate and apply search order
628 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
629 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
630 # restore order of the search
631 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
635 'stage_id': _read_group_stage_ids,
636 'user_id': _read_group_user_id,
639 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
640 obj_project = self.pool.get('project.project')
642 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
643 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
644 if id and isinstance(id, (long, int)):
645 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
646 args.append(('active', '=', False))
647 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
649 def _str_get(self, task, level=0, border='***', context=None):
650 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'+ \
651 border[0]+' '+(task.name or '')+'\n'+ \
652 (task.description or '')+'\n\n'
654 # Compute: effective_hours, total_hours, progress
655 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
657 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
658 hours = dict(cr.fetchall())
659 for task in self.browse(cr, uid, ids, context=context):
660 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)}
661 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
662 res[task.id]['progress'] = 0.0
663 if (task.remaining_hours + hours.get(task.id, 0.0)):
664 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
665 if task.state in ('done','cancelled'):
666 res[task.id]['progress'] = 100.0
669 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
670 if remaining and not planned:
671 return {'value':{'planned_hours': remaining}}
674 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
675 return {'value':{'remaining_hours': planned - effective}}
677 def onchange_project(self, cr, uid, id, project_id):
680 data = self.pool.get('project.project').browse(cr, uid, [project_id])
681 partner_id=data and data[0].partner_id
683 return {'value':{'partner_id':partner_id.id}}
686 def duplicate_task(self, cr, uid, map_ids, context=None):
687 for new in map_ids.values():
688 task = self.browse(cr, uid, new, context)
689 child_ids = [ ch.id for ch in task.child_ids]
691 for child in task.child_ids:
692 if child.id in map_ids.keys():
693 child_ids.remove(child.id)
694 child_ids.append(map_ids[child.id])
696 parent_ids = [ ch.id for ch in task.parent_ids]
698 for parent in task.parent_ids:
699 if parent.id in map_ids.keys():
700 parent_ids.remove(parent.id)
701 parent_ids.append(map_ids[parent.id])
702 #FIXME why there is already the copy and the old one
703 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
705 def copy_data(self, cr, uid, id, default={}, context=None):
706 default = default or {}
707 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
708 if not default.get('remaining_hours', False):
709 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
710 default['active'] = True
711 default['stage_id'] = False
712 if not default.get('name', False):
713 default['name'] = self.browse(cr, uid, id, context=context).name or ''
714 if not context.get('copy',False):
715 new_name = _("%s (copy)")%default.get('name','')
716 default.update({'name':new_name})
717 return super(task, self).copy_data(cr, uid, id, default, context)
720 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
722 for task in self.browse(cr, uid, ids, context=context):
725 if task.project_id.active == False or task.project_id.state == 'template':
729 def _get_task(self, cr, uid, ids, context=None):
731 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
732 if work.task_id: result[work.task_id.id] = True
736 '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."),
737 'name': fields.char('Task Summary', size=128, required=True, select=True),
738 'description': fields.text('Description'),
739 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
740 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
741 'stage_id': fields.many2one('project.task.type', 'Stage',
742 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
743 'state': fields.related('stage_id', 'state', type="selection", store=True,
744 selection=_TASK_STATE, string="State", readonly=True,
745 help='The state is set to \'Draft\', when a case is created.\
746 If the case is in progress the state is set to \'Open\'.\
747 When the case is over, the state is set to \'Done\'.\
748 If the case needs to be reviewed then the state is \
749 set to \'Pending\'.'),
750 'categ_ids': fields.many2many('project.category', string='Categories'),
751 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
752 help="A task's kanban state indicates special situations affecting it:\n"
753 " * Normal is the default situation\n"
754 " * Blocked indicates something is preventing the progress of this task\n"
755 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
756 readonly=True, required=False),
757 'create_date': fields.datetime('Create Date', readonly=True,select=True),
758 'date_start': fields.datetime('Starting Date',select=True),
759 'date_end': fields.datetime('Ending Date',select=True),
760 'date_deadline': fields.date('Deadline',select=True),
761 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
762 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
763 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
764 'notes': fields.text('Notes'),
765 '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.'),
766 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
768 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
769 'project.task.work': (_get_task, ['hours'], 10),
771 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
772 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
774 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
775 'project.task.work': (_get_task, ['hours'], 10),
777 '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",
779 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
780 'project.task.work': (_get_task, ['hours'], 10),
782 '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.",
784 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
785 'project.task.work': (_get_task, ['hours'], 10),
787 'user_id': fields.many2one('res.users', 'Assigned to'),
788 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
789 'partner_id': fields.many2one('res.partner', 'Contact'),
790 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
791 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
792 'company_id': fields.many2one('res.company', 'Company'),
793 'id': fields.integer('ID', readonly=True),
794 'color': fields.integer('Color Index'),
795 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
799 'stage_id': _get_default_stage_id,
800 'project_id': _get_default_project_id,
802 'kanban_state': 'normal',
807 'user_id': lambda obj, cr, uid, context: uid,
808 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
811 _order = "priority, sequence, date_start, name, id"
813 def set_priority(self, cr, uid, ids, priority, *args):
816 return self.write(cr, uid, ids, {'priority' : priority})
818 def set_high_priority(self, cr, uid, ids, *args):
819 """Set task priority to high
821 return self.set_priority(cr, uid, ids, '1')
823 def set_normal_priority(self, cr, uid, ids, *args):
824 """Set task priority to normal
826 return self.set_priority(cr, uid, ids, '2')
828 def _check_recursion(self, cr, uid, ids, context=None):
830 visited_branch = set()
832 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
838 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
839 if id in visited_branch: #Cycle
842 if id in visited_node: #Already tested don't work one more time for nothing
845 visited_branch.add(id)
848 #visit child using DFS
849 task = self.browse(cr, uid, id, context=context)
850 for child in task.child_ids:
851 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
855 visited_branch.remove(id)
858 def _check_dates(self, cr, uid, ids, context=None):
861 obj_task = self.browse(cr, uid, ids[0], context=context)
862 start = obj_task.date_start or False
863 end = obj_task.date_end or False
870 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
871 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
874 # Override view according to the company definition
876 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
877 users_obj = self.pool.get('res.users')
878 if context is None: context = {}
879 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
880 # this should be safe (no context passed to avoid side-effects)
881 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
882 tm = obj_tm and obj_tm.name or 'Hours'
884 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
886 if tm in ['Hours','Hour']:
889 eview = etree.fromstring(res['arch'])
891 def _check_rec(eview):
892 if eview.attrib.get('widget','') == 'float_time':
893 eview.set('widget','float')
900 res['arch'] = etree.tostring(eview)
902 for f in res['fields']:
903 if 'Hours' in res['fields'][f]['string']:
904 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
907 # ****************************************
909 # ****************************************
911 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
912 """ Override of the base.stage method
913 Parameter of the stage search taken from the lead:
914 - section_id: if set, stages must belong to this section or
915 be a default stage; if not set, stages must be default
918 if isinstance(cases, (int, long)):
919 cases = self.browse(cr, uid, cases, context=context)
920 # collect all section_ids
923 section_ids.append(section_id)
926 section_ids.append(task.project_id.id)
927 # OR all section_ids and OR with case_default
930 search_domain += [('|')] * len(section_ids)
931 for section_id in section_ids:
932 search_domain.append(('project_ids', '=', section_id))
933 search_domain.append(('case_default', '=', True))
934 # AND with the domain in parameter
935 search_domain += list(domain)
936 # perform search, return the first found
937 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
942 def _check_child_task(self, cr, uid, ids, context=None):
945 tasks = self.browse(cr, uid, ids, context=context)
948 for child in task.child_ids:
949 if child.state in ['draft', 'open', 'pending']:
950 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
953 def action_close(self, cr, uid, ids, context=None):
954 """ This action closes the task
956 task_id = len(ids) and ids[0] or False
957 self._check_child_task(cr, uid, ids, context=context)
958 if not task_id: return False
959 return self.do_close(cr, uid, [task_id], context=context)
961 def do_close(self, cr, uid, ids, context=None):
962 """ Compatibility when changing to case_close. """
963 return self.case_close(cr, uid, ids, context=context)
965 def case_close(self, cr, uid, ids, context=None):
967 if not isinstance(ids, list): ids = [ids]
968 for task in self.browse(cr, uid, ids, context=context):
970 project = task.project_id
971 for parent_id in task.parent_ids:
972 if parent_id.state in ('pending','draft'):
974 for child in parent_id.child_ids:
975 if child.id != task.id and child.state not in ('done','cancelled'):
978 self.do_reopen(cr, uid, [parent_id.id], context=context)
980 vals['remaining_hours'] = 0.0
981 if not task.date_end:
982 vals['date_end'] = fields.datetime.now()
983 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
984 self.case_close_send_note(cr, uid, [task.id], context=context)
987 def do_reopen(self, cr, uid, ids, context=None):
988 for task in self.browse(cr, uid, ids, context=context):
989 project = task.project_id
990 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
991 self.case_open_send_note(cr, uid, [task.id], context)
994 def do_cancel(self, cr, uid, ids, context=None):
995 """ Compatibility when changing to case_cancel. """
996 return self.case_cancel(cr, uid, ids, context=context)
998 def case_cancel(self, cr, uid, ids, context=None):
999 tasks = self.browse(cr, uid, ids, context=context)
1000 self._check_child_task(cr, uid, ids, context=context)
1002 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1003 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1006 def do_open(self, cr, uid, ids, context=None):
1007 """ Compatibility when changing to case_open. """
1008 return self.case_open(cr, uid, ids, context=context)
1010 def case_open(self, cr, uid, ids, context=None):
1011 if not isinstance(ids,list): ids = [ids]
1012 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1013 self.case_open_send_note(cr, uid, ids, context)
1016 def do_draft(self, cr, uid, ids, context=None):
1017 """ Compatibility when changing to case_draft. """
1018 return self.case_draft(cr, uid, ids, context=context)
1020 def case_draft(self, cr, uid, ids, context=None):
1021 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1022 self.case_draft_send_note(cr, uid, ids, context=context)
1025 def do_pending(self, cr, uid, ids, context=None):
1026 """ Compatibility when changing to case_pending. """
1027 return self.case_pending(cr, uid, ids, context=context)
1029 def case_pending(self, cr, uid, ids, context=None):
1030 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1031 return self.case_pending_send_note(cr, uid, ids, context=context)
1033 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1034 attachment = self.pool.get('ir.attachment')
1035 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1036 new_attachment_ids = []
1037 for attachment_id in attachment_ids:
1038 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1039 return new_attachment_ids
1041 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1043 Delegate Task to another users.
1045 assert delegate_data['user_id'], _("Delegated User should be specified")
1046 delegated_tasks = {}
1047 for task in self.browse(cr, uid, ids, context=context):
1048 delegated_task_id = self.copy(cr, uid, task.id, {
1049 'name': delegate_data['name'],
1050 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1051 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1052 'planned_hours': delegate_data['planned_hours'] or 0.0,
1053 'parent_ids': [(6, 0, [task.id])],
1055 'description': delegate_data['new_task_description'] or '',
1059 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1060 newname = delegate_data['prefix'] or ''
1062 'remaining_hours': delegate_data['planned_hours_me'],
1063 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1066 if delegate_data['state'] == 'pending':
1067 self.do_pending(cr, uid, [task.id], context=context)
1068 elif delegate_data['state'] == 'done':
1069 self.do_close(cr, uid, [task.id], context=context)
1070 self.do_delegation_send_note(cr, uid, [task.id], context)
1071 delegated_tasks[task.id] = delegated_task_id
1072 return delegated_tasks
1074 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1075 for task in self.browse(cr, uid, ids, context=context):
1076 if (task.state=='draft') or (task.planned_hours==0.0):
1077 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1078 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1081 def set_remaining_time_1(self, cr, uid, ids, context=None):
1082 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1084 def set_remaining_time_2(self, cr, uid, ids, context=None):
1085 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1087 def set_remaining_time_5(self, cr, uid, ids, context=None):
1088 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1090 def set_remaining_time_10(self, cr, uid, ids, context=None):
1091 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1093 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1094 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1097 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1098 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1101 def set_kanban_state_done(self, cr, uid, ids, context=None):
1102 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1105 def _store_history(self, cr, uid, ids, context=None):
1106 for task in self.browse(cr, uid, ids, context=context):
1107 self.pool.get('project.task.history').create(cr, uid, {
1109 'remaining_hours': task.remaining_hours,
1110 'planned_hours': task.planned_hours,
1111 'kanban_state': task.kanban_state,
1112 'type_id': task.stage_id.id,
1113 'state': task.state,
1114 'user_id': task.user_id.id
1119 def create(self, cr, uid, vals, context=None):
1120 task_id = super(task, self).create(cr, uid, vals, context=context)
1121 self._store_history(cr, uid, [task_id], context=context)
1122 self.create_send_note(cr, uid, [task_id], context=context)
1125 # Overridden to reset the kanban_state to normal whenever
1126 # the stage (stage_id) of the task changes.
1127 def write(self, cr, uid, ids, vals, context=None):
1128 if isinstance(ids, (int, long)):
1130 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1131 new_stage = vals.get('stage_id')
1132 vals_reset_kstate = dict(vals, kanban_state='normal')
1133 for t in self.browse(cr, uid, ids, context=context):
1134 #TO FIX:Kanban view doesn't raise warning
1135 #stages = [stage.id for stage in t.project_id.type_ids]
1136 #if new_stage not in stages:
1137 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1138 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1139 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1140 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1143 result = super(task,self).write(cr, uid, ids, vals, context=context)
1144 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1145 self._store_history(cr, uid, ids, context=context)
1148 def unlink(self, cr, uid, ids, context=None):
1151 self._check_child_task(cr, uid, ids, context=context)
1152 res = super(task, self).unlink(cr, uid, ids, context)
1155 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1156 context = context or {}
1160 if task.state in ('done','cancelled'):
1165 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1167 for t2 in task.parent_ids:
1168 start.append("up.Task_%s.end" % (t2.id,))
1172 ''' % (ident,','.join(start))
1177 ''' % (ident, 'User_'+str(task.user_id.id))
1182 # ---------------------------------------------------
1183 # OpenChatter methods and notifications
1184 # ---------------------------------------------------
1186 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1187 """ Override of default prefix for notifications. """
1190 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1191 """ Returns the user_ids that have to perform an action.
1192 Add to the previous results given by super the document responsible
1194 :return: dict { record_id: [user_ids], }
1196 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1197 for obj in self.browse(cr, uid, ids, context=context):
1198 if obj.state == 'draft' and obj.user_id:
1199 result[obj.id].append(obj.user_id.id)
1202 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1203 """ Add 'user_id' and 'manager_id' to the monitored fields """
1204 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1205 return res + ['user_id', 'manager_id']
1207 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1208 """ Override of the (void) default notification method. """
1209 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1210 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1212 def create_send_note(self, cr, uid, ids, context=None):
1213 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1215 def case_draft_send_note(self, cr, uid, ids, context=None):
1216 msg = _('Task has been set as <b>draft</b>.')
1217 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1219 def do_delegation_send_note(self, cr, uid, ids, context=None):
1220 for task in self.browse(cr, uid, ids, context=context):
1221 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1222 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1226 class project_work(osv.osv):
1227 _name = "project.task.work"
1228 _description = "Project Task Work"
1230 'name': fields.char('Work summary', size=128),
1231 'date': fields.datetime('Date', select="1"),
1232 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1233 'hours': fields.float('Time Spent'),
1234 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1235 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1239 'user_id': lambda obj, cr, uid, context: uid,
1240 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1243 _order = "date desc"
1244 def create(self, cr, uid, vals, *args, **kwargs):
1245 if 'hours' in vals and (not vals['hours']):
1246 vals['hours'] = 0.00
1247 if 'task_id' in vals:
1248 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1249 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1251 def write(self, cr, uid, ids, vals, context=None):
1252 if 'hours' in vals and (not vals['hours']):
1253 vals['hours'] = 0.00
1255 for work in self.browse(cr, uid, ids, context=context):
1256 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))
1257 return super(project_work,self).write(cr, uid, ids, vals, context)
1259 def unlink(self, cr, uid, ids, *args, **kwargs):
1260 for work in self.browse(cr, uid, ids):
1261 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1262 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1265 class account_analytic_account(osv.osv):
1266 _inherit = 'account.analytic.account'
1267 _description = 'Analytic Account'
1269 'use_tasks': fields.boolean('Tasks',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1270 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1273 def on_change_template(self, cr, uid, ids, template_id, context=None):
1274 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1275 if template_id and 'value' in res:
1276 template = self.browse(cr, uid, template_id, context=context)
1277 res['value']['use_tasks'] = template.use_tasks
1280 def _trigger_project_creation(self, cr, uid, vals, context=None):
1282 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.
1284 if context is None: context = {}
1285 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1287 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1289 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.
1291 project_pool = self.pool.get('project.project')
1292 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1293 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1295 'name': vals.get('name'),
1296 'analytic_account_id': analytic_account_id,
1298 return project_pool.create(cr, uid, project_values, context=context)
1301 def create(self, cr, uid, vals, context=None):
1304 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1305 vals['child_ids'] = []
1306 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1307 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1308 return analytic_account_id
1310 def write(self, cr, uid, ids, vals, context=None):
1311 name = vals.get('name')
1312 for account in self.browse(cr, uid, ids, context=context):
1314 vals['name'] = account.name
1315 self.project_create(cr, uid, account.id, vals, context=context)
1316 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1318 def unlink(self, cr, uid, ids, *args, **kwargs):
1319 project_obj = self.pool.get('project.project')
1320 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1322 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1323 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1325 class project_project(osv.osv):
1326 _inherit = 'project.project'
1333 # Tasks History, used for cumulative flow charts (Lean/Agile)
1336 class project_task_history(osv.osv):
1337 _name = 'project.task.history'
1338 _description = 'History of Tasks'
1339 _rec_name = 'task_id'
1341 def _get_date(self, cr, uid, ids, name, arg, context=None):
1343 for history in self.browse(cr, uid, ids, context=context):
1344 if history.state in ('done','cancelled'):
1345 result[history.id] = history.date
1347 cr.execute('''select
1350 project_task_history
1354 order by id limit 1''', (history.task_id.id, history.id))
1356 result[history.id] = res and res[0] or False
1359 def _get_related_date(self, cr, uid, ids, context=None):
1361 for history in self.browse(cr, uid, ids, context=context):
1362 cr.execute('''select
1365 project_task_history
1369 order by id desc limit 1''', (history.task_id.id, history.id))
1372 result.append(res[0])
1376 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1377 'type_id': fields.many2one('project.task.type', 'Stage'),
1378 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1379 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1380 'date': fields.date('Date', select=True),
1381 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1382 'project.task.history': (_get_related_date, None, 20)
1384 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1385 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1386 'user_id': fields.many2one('res.users', 'Responsible'),
1389 'date': fields.date.context_today,
1393 class project_task_history_cumulative(osv.osv):
1394 _name = 'project.task.history.cumulative'
1395 _table = 'project_task_history_cumulative'
1396 _inherit = 'project.task.history'
1399 'end_date': fields.date('End Date'),
1400 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1403 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1405 history.date::varchar||'-'||history.history_id::varchar as id,
1406 history.date as end_date,
1411 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1412 task_id, type_id, user_id, kanban_state, state,
1413 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1415 project_task_history
1421 class project_category(osv.osv):
1422 """ Category of project's task (or issue) """
1423 _name = "project.category"
1424 _description = "Category of project's task, issue, ..."
1426 'name': fields.char('Name', size=64, required=True, translate=True),