1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
22 from base_status.base_stage import base_stage
23 from datetime import datetime, date
24 from lxml import etree
25 from osv import fields, osv
26 from openerp.addons.resource.faces import task as Task
28 from tools.translate import _
30 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
32 class project_task_type(osv.osv):
33 _name = 'project.task.type'
34 _description = 'Task Stage'
37 'name': fields.char('Stage Name', required=True, size=64, translate=True),
38 'description': fields.text('Description'),
39 'sequence': fields.integer('Sequence'),
40 'case_default': fields.boolean('Common to All Projects',
41 help="If you check this field, this stage will be proposed by default on each new project. It will not assign this stage to existing projects."),
42 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
43 'state': fields.selection(_TASK_STATE, 'State', required=True,
44 help="The related state for the stage. The state of your document will automatically change regarding the selected stage. Example, a stage is related to the state 'Close', when your document reach this stage, it will be automatically closed."),
45 'fold': fields.boolean('Hide in views if empty',
46 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
57 """Keep first word(s) of name to make it small enough
59 if not name: return name
60 # keep 7 chars + end of the last word
61 keep_words = name[:7].strip().split()
62 return ' '.join(name.split()[:len(keep_words)])
64 class project(osv.osv):
65 _name = "project.project"
66 _description = "Project"
67 _inherits = {'account.analytic.account': "analytic_account_id",
68 "mail.alias": "alias_id"}
69 _inherit = ['ir.needaction_mixin', 'mail.thread']
71 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
73 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
74 if context and context.get('user_preference'):
75 cr.execute("""SELECT project.id FROM project_project project
76 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
77 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
78 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
79 return [(r[0]) for r in cr.fetchall()]
80 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
81 context=context, count=count)
83 def _complete_name(self, cr, uid, ids, name, args, context=None):
85 for m in self.browse(cr, uid, ids, context=context):
86 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
89 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
90 partner_obj = self.pool.get('res.partner')
94 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
95 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
96 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
97 val['pricelist_id'] = pricelist_id
100 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
101 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
102 project_ids = [task.project_id.id for task in tasks if task.project_id]
103 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
105 def _get_project_and_parents(self, cr, uid, ids, context=None):
106 """ return the project ids and all their parent projects """
110 SELECT DISTINCT parent.id
111 FROM project_project project, project_project parent, account_analytic_account account
112 WHERE project.analytic_account_id = account.id
113 AND parent.analytic_account_id = account.parent_id
116 ids = [t[0] for t in cr.fetchall()]
120 def _get_project_and_children(self, cr, uid, ids, context=None):
121 """ retrieve all children projects of project ids;
122 return a dictionary mapping each project to its parent project (or None)
124 res = dict.fromkeys(ids, None)
127 SELECT project.id, parent.id
128 FROM project_project project, project_project parent, account_analytic_account account
129 WHERE project.analytic_account_id = account.id
130 AND parent.analytic_account_id = account.parent_id
133 dic = dict(cr.fetchall())
138 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
139 child_parent = self._get_project_and_children(cr, uid, ids, context)
140 # compute planned_hours, total_hours, effective_hours specific to each project
142 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
143 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
144 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
146 """, (tuple(child_parent.keys()),))
147 # aggregate results into res
148 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
149 for id, planned, total, effective in cr.fetchall():
150 # add the values specific to id to all parent projects of id in the result
153 res[id]['planned_hours'] += planned
154 res[id]['total_hours'] += total
155 res[id]['effective_hours'] += effective
156 id = child_parent[id]
157 # compute progress rates
159 if res[id]['total_hours']:
160 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
162 res[id]['progress_rate'] = 0.0
165 def unlink(self, cr, uid, ids, *args, **kwargs):
167 mail_alias = self.pool.get('mail.alias')
168 for proj in self.browse(cr, uid, ids):
170 raise osv.except_osv(_('Invalid Action!'),
171 _('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.'))
173 alias_ids.append(proj.alias_id.id)
174 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
175 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
178 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
179 res = dict.fromkeys(ids, 0)
180 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
181 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
182 res[task.project_id.id] += 1
185 def _get_alias_models(self, cr, uid, context=None):
186 """Overriden in project_issue to offer more options"""
187 return [('project.task', "Tasks")]
189 def _get_followers(self, cr, uid, ids, name, arg, context=None):
191 Functional field that computes the users that are 'following' a thread.
194 for project in self.browse(cr, uid, ids, context=context):
196 for message in project.message_ids:
197 l.add(message.user_id and message.user_id.id or False)
198 res[project.id] = list(filter(None, l))
201 def _search_followers(self, cr, uid, obj, name, args, context=None):
202 project_obj = self.pool.get('project.project')
203 project_ids = project_obj.search(cr, uid, [('message_ids.user_id.id', 'in', args[0][2])], context=context)
204 return [('id', 'in', project_ids)]
206 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
207 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
210 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
211 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
212 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
213 'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account', help="Link this project to an analytic account if you need financial management on projects. It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True),
214 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
215 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
216 help="Project's members are users who can have an access to the tasks related to this project.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
217 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
218 'planned_hours': fields.function(_progress_rate, multi="progress", string='Planned Time', help="Sum of planned hours of all tasks related to this project and its child projects.",
220 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
221 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
223 'effective_hours': fields.function(_progress_rate, multi="progress", string='Time Spent', help="Sum of spent hours of all tasks related to this project and its child projects.",
225 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
226 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
228 'total_hours': fields.function(_progress_rate, multi="progress", string='Total Time', help="Sum of total hours of all tasks related to this project and its child projects.",
230 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
231 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
233 'progress_rate': fields.function(_progress_rate, multi="progress", string='Progress', type='float', group_operator="avg", help="Percent of tasks closed according to the total of tasks todo.",
235 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
236 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
238 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
239 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
240 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
241 'color': fields.integer('Color Index'),
242 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
243 help="Internal email associated with this project. Incoming emails are automatically synchronized"
244 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
245 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
246 help="The kind of document created when an email is received on this project's email alias"),
247 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
248 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
249 'followers': fields.function(_get_followers, method=True, fnct_search=_search_followers,
250 type='many2many', relation='res.users', string='Followers'),
253 def dummy(self, cr, uid, ids, context):
256 def _get_type_common(self, cr, uid, context):
257 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
267 'type_ids': _get_type_common,
268 'alias_model': 'project.task',
269 'privacy_visibility': 'public',
270 'alias_domain': False, # always hide alias during creation
273 # TODO: Why not using a SQL contraints ?
274 def _check_dates(self, cr, uid, ids, context=None):
275 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
276 if leave['date_start'] and leave['date']:
277 if leave['date_start'] > leave['date']:
282 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
285 def set_template(self, cr, uid, ids, context=None):
286 res = self.setActive(cr, uid, ids, value=False, context=context)
289 def set_done(self, cr, uid, ids, context=None):
290 task_obj = self.pool.get('project.task')
291 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
292 task_obj.case_close(cr, uid, task_ids, context=context)
293 self.write(cr, uid, ids, {'state':'close'}, context=context)
294 self.set_close_send_note(cr, uid, ids, context=context)
297 def set_cancel(self, cr, uid, ids, context=None):
298 task_obj = self.pool.get('project.task')
299 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
300 task_obj.case_cancel(cr, uid, task_ids, context=context)
301 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
302 self.set_cancel_send_note(cr, uid, ids, context=context)
305 def set_pending(self, cr, uid, ids, context=None):
306 self.write(cr, uid, ids, {'state':'pending'}, context=context)
307 self.set_pending_send_note(cr, uid, ids, context=context)
310 def set_open(self, cr, uid, ids, context=None):
311 self.write(cr, uid, ids, {'state':'open'}, context=context)
312 self.set_open_send_note(cr, uid, ids, context=context)
315 def reset_project(self, cr, uid, ids, context=None):
316 res = self.setActive(cr, uid, ids, value=True, context=context)
317 self.set_open_send_note(cr, uid, ids, context=context)
320 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
321 """ copy and map tasks from old to new project """
325 task_obj = self.pool.get('project.task')
326 proj = self.browse(cr, uid, old_project_id, context=context)
327 for task in proj.tasks:
328 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
329 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
330 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
333 def copy(self, cr, uid, id, default={}, context=None):
337 default = default or {}
338 context['active_test'] = False
339 default['state'] = 'open'
340 default['tasks'] = []
341 default.pop('alias_name', None)
342 default.pop('alias_id', None)
343 proj = self.browse(cr, uid, id, context=context)
344 if not default.get('name', False):
345 default['name'] = proj.name + _(' (copy)')
346 res = super(project, self).copy(cr, uid, id, default, context)
347 self.map_tasks(cr,uid,id,res,context)
350 def duplicate_template(self, cr, uid, ids, context=None):
353 data_obj = self.pool.get('ir.model.data')
355 for proj in self.browse(cr, uid, ids, context=context):
356 parent_id = context.get('parent_id', False)
357 context.update({'analytic_project_copy': True})
358 new_date_start = time.strftime('%Y-%m-%d')
360 if proj.date_start and proj.date:
361 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
362 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
363 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
364 context.update({'copy':True})
365 new_id = self.copy(cr, uid, proj.id, default = {
366 'name': proj.name +_(' (copy)'),
368 'date_start':new_date_start,
370 'parent_id':parent_id}, context=context)
371 result.append(new_id)
373 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
374 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
376 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
378 if result and len(result):
380 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
381 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
382 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
383 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
384 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
385 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
387 'name': _('Projects'),
389 'view_mode': 'form,tree',
390 'res_model': 'project.project',
393 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
394 'type': 'ir.actions.act_window',
395 'search_view_id': search_view['res_id'],
399 # set active value for a project, its sub projects and its tasks
400 def setActive(self, cr, uid, ids, value=True, context=None):
401 task_obj = self.pool.get('project.task')
402 for proj in self.browse(cr, uid, ids, context=None):
403 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
404 cr.execute('select id from project_task where project_id=%s', (proj.id,))
405 tasks_id = [x[0] for x in cr.fetchall()]
407 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
408 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
410 self.setActive(cr, uid, child_ids, value, context=None)
413 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
414 context = context or {}
415 if type(ids) in (long, int,):
417 projects = self.browse(cr, uid, ids, context=context)
419 for project in projects:
420 if (not project.members) and force_members:
421 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
423 resource_pool = self.pool.get('resource.resource')
425 result = "from openerp.addons.resource.faces import *\n"
426 result += "import datetime\n"
427 for project in self.browse(cr, uid, ids, context=context):
428 u_ids = [i.id for i in project.members]
429 if project.user_id and (project.user_id.id not in u_ids):
430 u_ids.append(project.user_id.id)
431 for task in project.tasks:
432 if task.state in ('done','cancelled'):
434 if task.user_id and (task.user_id.id not in u_ids):
435 u_ids.append(task.user_id.id)
436 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
437 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
438 for key, vals in resource_objs.items():
440 class User_%s(Resource):
442 ''' % (key, vals.get('efficiency', False))
449 def _schedule_project(self, cr, uid, project, context=None):
450 resource_pool = self.pool.get('resource.resource')
451 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
452 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
453 # TODO: check if we need working_..., default values are ok.
454 puids = [x.id for x in project.members]
456 puids.append(project.user_id.id)
464 project.date_start, working_days,
465 '|'.join(['User_'+str(x) for x in puids])
467 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
474 #TODO: DO Resource allocation and compute availability
475 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
481 def schedule_tasks(self, cr, uid, ids, context=None):
482 context = context or {}
483 if type(ids) in (long, int,):
485 projects = self.browse(cr, uid, ids, context=context)
486 result = self._schedule_header(cr, uid, ids, False, context=context)
487 for project in projects:
488 result += self._schedule_project(cr, uid, project, context=context)
489 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
492 exec result in local_dict
493 projects_gantt = Task.BalancedProject(local_dict['Project'])
495 for project in projects:
496 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
497 for task in project.tasks:
498 if task.state in ('done','cancelled'):
501 p = getattr(project_gantt, 'Task_%d' % (task.id,))
503 self.pool.get('project.task').write(cr, uid, [task.id], {
504 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
505 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
507 if (not task.user_id) and (p.booked_resource):
508 self.pool.get('project.task').write(cr, uid, [task.id], {
509 'user_id': int(p.booked_resource[0].name[5:]),
513 # ------------------------------------------------
514 # OpenChatter methods and notifications
515 # ------------------------------------------------
517 def message_get_subscribers(self, cr, uid, ids, context=None):
518 """ Override to add responsible user. """
519 user_ids = super(project, self).message_get_subscribers(cr, uid, ids, context=context)
520 for obj in self.browse(cr, uid, ids, context=context):
521 if obj.user_id and not obj.user_id.id in user_ids:
522 user_ids.append(obj.user_id.id)
525 def create(self, cr, uid, vals, context=None):
526 if context is None: context = {}
527 # Prevent double project creation when 'use_tasks' is checked!
528 context = dict(context, project_creation_in_progress=True)
529 mail_alias = self.pool.get('mail.alias')
530 if not vals.get('alias_id'):
531 vals.pop('alias_name', None) # prevent errors during copy()
532 alias_id = mail_alias.create_unique_alias(cr, uid,
533 # Using '+' allows using subaddressing for those who don't
534 # have a catchall domain setup.
535 {'alias_name': "project+"+short_name(vals['name'])},
536 model_name=vals.get('alias_model', 'project.task'),
538 vals['alias_id'] = alias_id
539 project_id = super(project, self).create(cr, uid, vals, context)
540 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
541 self.create_send_note(cr, uid, [project_id], context=context)
544 def create_send_note(self, cr, uid, ids, context=None):
545 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
547 def set_open_send_note(self, cr, uid, ids, context=None):
548 message = _("Project has been <b>opened</b>.")
549 return self.message_append_note(cr, uid, ids, body=message, context=context)
551 def set_pending_send_note(self, cr, uid, ids, context=None):
552 message = _("Project is now <b>pending</b>.")
553 return self.message_append_note(cr, uid, ids, body=message, context=context)
555 def set_cancel_send_note(self, cr, uid, ids, context=None):
556 message = _("Project has been <b>cancelled</b>.")
557 return self.message_append_note(cr, uid, ids, body=message, context=context)
559 def set_close_send_note(self, cr, uid, ids, context=None):
560 message = _("Project has been <b>closed</b>.")
561 return self.message_append_note(cr, uid, ids, body=message, context=context)
563 def write(self, cr, uid, ids, vals, context=None):
564 # if alias_model has been changed, update alias_model_id accordingly
565 if vals.get('alias_model'):
566 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
567 vals.update(alias_model_id=model_ids[0])
568 return super(project, self).write(cr, uid, ids, vals, context=context)
570 class task(base_stage, osv.osv):
571 _name = "project.task"
572 _description = "Task"
573 _date_name = "date_start"
574 _inherit = ['ir.needaction_mixin', 'mail.thread']
576 def _get_default_project_id(self, cr, uid, context=None):
577 """ Gives default section by checking if present in the context """
578 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
580 def _get_default_stage_id(self, cr, uid, context=None):
581 """ Gives default stage_id """
582 project_id = self._get_default_project_id(cr, uid, context=context)
583 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
585 def _resolve_project_id_from_context(self, cr, uid, context=None):
586 """ Returns ID of project based on the value of 'default_project_id'
587 context key, or None if it cannot be resolved to a single
590 if context is None: context = {}
591 if type(context.get('default_project_id')) in (int, long):
592 return context['default_project_id']
593 if isinstance(context.get('default_project_id'), basestring):
594 project_name = context['default_project_id']
595 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
596 if len(project_ids) == 1:
597 return project_ids[0][0]
600 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
601 stage_obj = self.pool.get('project.task.type')
602 order = stage_obj._order
603 access_rights_uid = access_rights_uid or uid
604 # lame way to allow reverting search, should just work in the trivial case
605 if read_group_order == 'stage_id desc':
606 order = '%s desc' % order
607 # retrieve section_id from the context and write the domain
608 # - ('id', 'in', 'ids'): add columns that should be present
609 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
610 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
612 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
614 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
615 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
616 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
617 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
618 # restore order of the search
619 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
622 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
623 res_users = self.pool.get('res.users')
624 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
625 access_rights_uid = access_rights_uid or uid
627 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
628 order = res_users._order
629 # lame way to allow reverting search, should just work in the trivial case
630 if read_group_order == 'user_id desc':
631 order = '%s desc' % order
632 # de-duplicate and apply search order
633 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
634 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
635 # restore order of the search
636 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
640 'stage_id': _read_group_stage_ids,
641 'user_id': _read_group_user_id,
644 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
645 obj_project = self.pool.get('project.project')
647 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
648 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
649 if id and isinstance(id, (long, int)):
650 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
651 args.append(('active', '=', False))
652 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
654 def _str_get(self, task, level=0, border='***', context=None):
655 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'+ \
656 border[0]+' '+(task.name or '')+'\n'+ \
657 (task.description or '')+'\n\n'
659 # Compute: effective_hours, total_hours, progress
660 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
662 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
663 hours = dict(cr.fetchall())
664 for task in self.browse(cr, uid, ids, context=context):
665 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)}
666 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
667 res[task.id]['progress'] = 0.0
668 if (task.remaining_hours + hours.get(task.id, 0.0)):
669 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
670 if task.state in ('done','cancelled'):
671 res[task.id]['progress'] = 100.0
674 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
675 if remaining and not planned:
676 return {'value':{'planned_hours': remaining}}
679 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
680 return {'value':{'remaining_hours': planned - effective}}
682 def onchange_project(self, cr, uid, id, project_id):
685 data = self.pool.get('project.project').browse(cr, uid, [project_id])
686 partner_id=data and data[0].partner_id
688 return {'value':{'partner_id':partner_id.id}}
691 def duplicate_task(self, cr, uid, map_ids, context=None):
692 for new in map_ids.values():
693 task = self.browse(cr, uid, new, context)
694 child_ids = [ ch.id for ch in task.child_ids]
696 for child in task.child_ids:
697 if child.id in map_ids.keys():
698 child_ids.remove(child.id)
699 child_ids.append(map_ids[child.id])
701 parent_ids = [ ch.id for ch in task.parent_ids]
703 for parent in task.parent_ids:
704 if parent.id in map_ids.keys():
705 parent_ids.remove(parent.id)
706 parent_ids.append(map_ids[parent.id])
707 #FIXME why there is already the copy and the old one
708 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
710 def copy_data(self, cr, uid, id, default={}, context=None):
711 default = default or {}
712 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
713 if not default.get('remaining_hours', False):
714 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
715 default['active'] = True
716 default['stage_id'] = False
717 if not default.get('name', False):
718 default['name'] = self.browse(cr, uid, id, context=context).name or ''
719 if not context.get('copy',False):
720 new_name = _("%s (copy)")%default.get('name','')
721 default.update({'name':new_name})
722 return super(task, self).copy_data(cr, uid, id, default, context)
725 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
727 for task in self.browse(cr, uid, ids, context=context):
730 if task.project_id.active == False or task.project_id.state == 'template':
734 def _get_task(self, cr, uid, ids, context=None):
736 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
737 if work.task_id: result[work.task_id.id] = True
741 '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."),
742 'name': fields.char('Task Summary', size=128, required=True, select=True),
743 'description': fields.text('Description'),
744 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
745 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
746 'stage_id': fields.many2one('project.task.type', 'Stage',
747 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
748 'state': fields.related('stage_id', 'state', type="selection", store=True,
749 selection=_TASK_STATE, string="State", readonly=True,
750 help='The state is set to \'Draft\', when a case is created.\
751 If the case is in progress the state is set to \'Open\'.\
752 When the case is over, the state is set to \'Done\'.\
753 If the case needs to be reviewed then the state is \
754 set to \'Pending\'.'),
755 'categ_ids': fields.many2many('project.category', string='Categories'),
756 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
757 help="A task's kanban state indicates special situations affecting it:\n"
758 " * Normal is the default situation\n"
759 " * Blocked indicates something is preventing the progress of this task\n"
760 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
761 readonly=True, required=False),
762 'create_date': fields.datetime('Create Date', readonly=True,select=True),
763 'date_start': fields.datetime('Starting Date',select=True),
764 'date_end': fields.datetime('Ending Date',select=True),
765 'date_deadline': fields.date('Deadline',select=True),
766 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
767 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
768 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
769 'notes': fields.text('Notes'),
770 '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.'),
771 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
773 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
774 'project.task.work': (_get_task, ['hours'], 10),
776 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
777 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
779 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
780 'project.task.work': (_get_task, ['hours'], 10),
782 '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",
784 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
785 'project.task.work': (_get_task, ['hours'], 10),
787 '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.",
789 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
790 'project.task.work': (_get_task, ['hours'], 10),
792 'user_id': fields.many2one('res.users', 'Assigned to'),
793 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
794 'partner_id': fields.many2one('res.partner', 'Contact'),
795 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
796 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
797 'company_id': fields.many2one('res.company', 'Company'),
798 'id': fields.integer('ID', readonly=True),
799 'color': fields.integer('Color Index'),
800 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
804 'stage_id': _get_default_stage_id,
805 'project_id': _get_default_project_id,
807 'kanban_state': 'normal',
812 'user_id': lambda obj, cr, uid, context: uid,
813 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
816 _order = "priority, sequence, date_start, name, id"
818 def set_priority(self, cr, uid, ids, priority, *args):
821 return self.write(cr, uid, ids, {'priority' : priority})
823 def set_high_priority(self, cr, uid, ids, *args):
824 """Set task priority to high
826 return self.set_priority(cr, uid, ids, '1')
828 def set_normal_priority(self, cr, uid, ids, *args):
829 """Set task priority to normal
831 return self.set_priority(cr, uid, ids, '2')
833 def _check_recursion(self, cr, uid, ids, context=None):
835 visited_branch = set()
837 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
843 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
844 if id in visited_branch: #Cycle
847 if id in visited_node: #Already tested don't work one more time for nothing
850 visited_branch.add(id)
853 #visit child using DFS
854 task = self.browse(cr, uid, id, context=context)
855 for child in task.child_ids:
856 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
860 visited_branch.remove(id)
863 def _check_dates(self, cr, uid, ids, context=None):
866 obj_task = self.browse(cr, uid, ids[0], context=context)
867 start = obj_task.date_start or False
868 end = obj_task.date_end or False
875 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
876 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
879 # Override view according to the company definition
881 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
882 users_obj = self.pool.get('res.users')
883 if context is None: context = {}
884 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
885 # this should be safe (no context passed to avoid side-effects)
886 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
887 tm = obj_tm and obj_tm.name or 'Hours'
889 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
891 if tm in ['Hours','Hour']:
894 eview = etree.fromstring(res['arch'])
896 def _check_rec(eview):
897 if eview.attrib.get('widget','') == 'float_time':
898 eview.set('widget','float')
905 res['arch'] = etree.tostring(eview)
907 for f in res['fields']:
908 if 'Hours' in res['fields'][f]['string']:
909 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
912 # ****************************************
914 # ****************************************
916 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
917 """ Override of the base.stage method
918 Parameter of the stage search taken from the lead:
919 - section_id: if set, stages must belong to this section or
920 be a default stage; if not set, stages must be default
923 if isinstance(cases, (int, long)):
924 cases = self.browse(cr, uid, cases, context=context)
925 # collect all section_ids
928 section_ids.append(section_id)
931 section_ids.append(task.project_id.id)
932 # OR all section_ids and OR with case_default
935 search_domain += [('|')] * len(section_ids)
936 for section_id in section_ids:
937 search_domain.append(('project_ids', '=', section_id))
938 search_domain.append(('case_default', '=', True))
939 # AND with the domain in parameter
940 search_domain += list(domain)
941 # perform search, return the first found
942 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
947 def _check_child_task(self, cr, uid, ids, context=None):
950 tasks = self.browse(cr, uid, ids, context=context)
953 for child in task.child_ids:
954 if child.state in ['draft', 'open', 'pending']:
955 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
958 def action_close(self, cr, uid, ids, context=None):
959 """ This action closes the task
961 task_id = len(ids) and ids[0] or False
962 self._check_child_task(cr, uid, ids, context=context)
963 if not task_id: return False
964 return self.do_close(cr, uid, [task_id], context=context)
966 def do_close(self, cr, uid, ids, context=None):
967 """ Compatibility when changing to case_close. """
968 return self.case_close(cr, uid, ids, context=context)
970 def case_close(self, cr, uid, ids, context=None):
972 if not isinstance(ids, list): ids = [ids]
973 for task in self.browse(cr, uid, ids, context=context):
975 project = task.project_id
976 for parent_id in task.parent_ids:
977 if parent_id.state in ('pending','draft'):
979 for child in parent_id.child_ids:
980 if child.id != task.id and child.state not in ('done','cancelled'):
983 self.do_reopen(cr, uid, [parent_id.id], context=context)
985 vals['remaining_hours'] = 0.0
986 if not task.date_end:
987 vals['date_end'] = fields.datetime.now()
988 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
989 self.case_close_send_note(cr, uid, [task.id], context=context)
992 def do_reopen(self, cr, uid, ids, context=None):
993 for task in self.browse(cr, uid, ids, context=context):
994 project = task.project_id
995 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
996 self.case_open_send_note(cr, uid, [task.id], context)
999 def do_cancel(self, cr, uid, ids, context=None):
1000 """ Compatibility when changing to case_cancel. """
1001 return self.case_cancel(cr, uid, ids, context=context)
1003 def case_cancel(self, cr, uid, ids, context=None):
1004 tasks = self.browse(cr, uid, ids, context=context)
1005 self._check_child_task(cr, uid, ids, context=context)
1007 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1008 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1011 def do_open(self, cr, uid, ids, context=None):
1012 """ Compatibility when changing to case_open. """
1013 return self.case_open(cr, uid, ids, context=context)
1015 def case_open(self, cr, uid, ids, context=None):
1016 if not isinstance(ids,list): ids = [ids]
1017 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1018 self.case_open_send_note(cr, uid, ids, context)
1021 def do_draft(self, cr, uid, ids, context=None):
1022 """ Compatibility when changing to case_draft. """
1023 return self.case_draft(cr, uid, ids, context=context)
1025 def case_draft(self, cr, uid, ids, context=None):
1026 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1027 self.case_draft_send_note(cr, uid, ids, context=context)
1030 def do_pending(self, cr, uid, ids, context=None):
1031 """ Compatibility when changing to case_pending. """
1032 return self.case_pending(cr, uid, ids, context=context)
1034 def case_pending(self, cr, uid, ids, context=None):
1035 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1036 return self.case_pending_send_note(cr, uid, ids, context=context)
1038 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1039 attachment = self.pool.get('ir.attachment')
1040 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1041 new_attachment_ids = []
1042 for attachment_id in attachment_ids:
1043 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1044 return new_attachment_ids
1046 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1048 Delegate Task to another users.
1050 assert delegate_data['user_id'], _("Delegated User should be specified")
1051 delegated_tasks = {}
1052 for task in self.browse(cr, uid, ids, context=context):
1053 delegated_task_id = self.copy(cr, uid, task.id, {
1054 'name': delegate_data['name'],
1055 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1056 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1057 'planned_hours': delegate_data['planned_hours'] or 0.0,
1058 'parent_ids': [(6, 0, [task.id])],
1060 'description': delegate_data['new_task_description'] or '',
1064 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1065 newname = delegate_data['prefix'] or ''
1067 'remaining_hours': delegate_data['planned_hours_me'],
1068 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1071 if delegate_data['state'] == 'pending':
1072 self.do_pending(cr, uid, [task.id], context=context)
1073 elif delegate_data['state'] == 'done':
1074 self.do_close(cr, uid, [task.id], context=context)
1075 self.do_delegation_send_note(cr, uid, [task.id], context)
1076 delegated_tasks[task.id] = delegated_task_id
1077 return delegated_tasks
1079 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1080 for task in self.browse(cr, uid, ids, context=context):
1081 if (task.state=='draft') or (task.planned_hours==0.0):
1082 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1083 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1086 def set_remaining_time_1(self, cr, uid, ids, context=None):
1087 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1089 def set_remaining_time_2(self, cr, uid, ids, context=None):
1090 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1092 def set_remaining_time_5(self, cr, uid, ids, context=None):
1093 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1095 def set_remaining_time_10(self, cr, uid, ids, context=None):
1096 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1098 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1099 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1102 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1103 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1106 def set_kanban_state_done(self, cr, uid, ids, context=None):
1107 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1110 def _store_history(self, cr, uid, ids, context=None):
1111 for task in self.browse(cr, uid, ids, context=context):
1112 self.pool.get('project.task.history').create(cr, uid, {
1114 'remaining_hours': task.remaining_hours,
1115 'planned_hours': task.planned_hours,
1116 'kanban_state': task.kanban_state,
1117 'type_id': task.stage_id.id,
1118 'state': task.state,
1119 'user_id': task.user_id.id
1124 def create(self, cr, uid, vals, context=None):
1125 task_id = super(task, self).create(cr, uid, vals, context=context)
1126 self._store_history(cr, uid, [task_id], context=context)
1127 self.create_send_note(cr, uid, [task_id], context=context)
1130 # Overridden to reset the kanban_state to normal whenever
1131 # the stage (stage_id) of the task changes.
1132 def write(self, cr, uid, ids, vals, context=None):
1133 if isinstance(ids, (int, long)):
1135 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1136 new_stage = vals.get('stage_id')
1137 vals_reset_kstate = dict(vals, kanban_state='normal')
1138 for t in self.browse(cr, uid, ids, context=context):
1139 #TO FIX:Kanban view doesn't raise warning
1140 #stages = [stage.id for stage in t.project_id.type_ids]
1141 #if new_stage not in stages:
1142 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1143 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1144 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1145 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1148 result = super(task,self).write(cr, uid, ids, vals, context=context)
1149 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1150 self._store_history(cr, uid, ids, context=context)
1153 def unlink(self, cr, uid, ids, context=None):
1156 self._check_child_task(cr, uid, ids, context=context)
1157 res = super(task, self).unlink(cr, uid, ids, context)
1160 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1161 context = context or {}
1165 if task.state in ('done','cancelled'):
1170 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1172 for t2 in task.parent_ids:
1173 start.append("up.Task_%s.end" % (t2.id,))
1177 ''' % (ident,','.join(start))
1182 ''' % (ident, 'User_'+str(task.user_id.id))
1187 # ---------------------------------------------------
1188 # OpenChatter methods and notifications
1189 # ---------------------------------------------------
1191 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1192 """ Override of default prefix for notifications. """
1195 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1196 """ Returns the user_ids that have to perform an action.
1197 Add to the previous results given by super the document responsible
1199 :return: dict { record_id: [user_ids], }
1201 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1202 for obj in self.browse(cr, uid, ids, context=context):
1203 if obj.state == 'draft' and obj.user_id:
1204 result[obj.id].append(obj.user_id.id)
1207 def message_get_subscribers(self, cr, uid, ids, context=None):
1208 """ Override to add responsible user and project manager. """
1209 user_ids = super(task, self).message_get_subscribers(cr, uid, ids, context=context)
1210 for obj in self.browse(cr, uid, ids, context=context):
1211 if obj.user_id and not obj.user_id.id in user_ids:
1212 user_ids.append(obj.user_id.id)
1213 if obj.manager_id and not obj.manager_id.id in user_ids:
1214 user_ids.append(obj.manager_id.id)
1217 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1218 """ Override of the (void) default notification method. """
1219 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1220 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1222 def create_send_note(self, cr, uid, ids, context=None):
1223 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1225 def case_draft_send_note(self, cr, uid, ids, context=None):
1226 msg = _('Task has been set as <b>draft</b>.')
1227 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1229 def do_delegation_send_note(self, cr, uid, ids, context=None):
1230 for task in self.browse(cr, uid, ids, context=context):
1231 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1232 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1236 class project_work(osv.osv):
1237 _name = "project.task.work"
1238 _description = "Project Task Work"
1240 'name': fields.char('Work summary', size=128),
1241 'date': fields.datetime('Date', select="1"),
1242 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1243 'hours': fields.float('Time Spent'),
1244 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1245 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1249 'user_id': lambda obj, cr, uid, context: uid,
1250 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1253 _order = "date desc"
1254 def create(self, cr, uid, vals, *args, **kwargs):
1255 if 'hours' in vals and (not vals['hours']):
1256 vals['hours'] = 0.00
1257 if 'task_id' in vals:
1258 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1259 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1261 def write(self, cr, uid, ids, vals, context=None):
1262 if 'hours' in vals and (not vals['hours']):
1263 vals['hours'] = 0.00
1265 for work in self.browse(cr, uid, ids, context=context):
1266 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))
1267 return super(project_work,self).write(cr, uid, ids, vals, context)
1269 def unlink(self, cr, uid, ids, *args, **kwargs):
1270 for work in self.browse(cr, uid, ids):
1271 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1272 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1275 class account_analytic_account(osv.osv):
1276 _inherit = 'account.analytic.account'
1277 _description = 'Analytic Account'
1279 '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"),
1280 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1283 def on_change_template(self, cr, uid, ids, template_id, context=None):
1284 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1285 if template_id and 'value' in res:
1286 template = self.browse(cr, uid, template_id, context=context)
1287 res['value']['use_tasks'] = template.use_tasks
1290 def _trigger_project_creation(self, cr, uid, vals, context=None):
1292 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.
1294 if context is None: context = {}
1295 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1297 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1299 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.
1301 project_pool = self.pool.get('project.project')
1302 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1303 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1305 'name': vals.get('name'),
1306 'analytic_account_id': analytic_account_id,
1308 return project_pool.create(cr, uid, project_values, context=context)
1311 def create(self, cr, uid, vals, context=None):
1314 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1315 vals['child_ids'] = []
1316 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1317 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1318 return analytic_account_id
1320 def write(self, cr, uid, ids, vals, context=None):
1321 name = vals.get('name')
1322 for account in self.browse(cr, uid, ids, context=context):
1324 vals['name'] = account.name
1325 self.project_create(cr, uid, account.id, vals, context=context)
1326 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1328 def unlink(self, cr, uid, ids, *args, **kwargs):
1329 project_obj = self.pool.get('project.project')
1330 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1332 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1333 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1335 class project_project(osv.osv):
1336 _inherit = 'project.project'
1343 # Tasks History, used for cumulative flow charts (Lean/Agile)
1346 class project_task_history(osv.osv):
1347 _name = 'project.task.history'
1348 _description = 'History of Tasks'
1349 _rec_name = 'task_id'
1351 def _get_date(self, cr, uid, ids, name, arg, context=None):
1353 for history in self.browse(cr, uid, ids, context=context):
1354 if history.state in ('done','cancelled'):
1355 result[history.id] = history.date
1357 cr.execute('''select
1360 project_task_history
1364 order by id limit 1''', (history.task_id.id, history.id))
1366 result[history.id] = res and res[0] or False
1369 def _get_related_date(self, cr, uid, ids, context=None):
1371 for history in self.browse(cr, uid, ids, context=context):
1372 cr.execute('''select
1375 project_task_history
1379 order by id desc limit 1''', (history.task_id.id, history.id))
1382 result.append(res[0])
1386 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1387 'type_id': fields.many2one('project.task.type', 'Stage'),
1388 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1389 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1390 'date': fields.date('Date', select=True),
1391 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1392 'project.task.history': (_get_related_date, None, 20)
1394 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1395 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1396 'user_id': fields.many2one('res.users', 'Responsible'),
1399 'date': fields.date.context_today,
1403 class project_task_history_cumulative(osv.osv):
1404 _name = 'project.task.history.cumulative'
1405 _table = 'project_task_history_cumulative'
1406 _inherit = 'project.task.history'
1409 'end_date': fields.date('End Date'),
1410 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1413 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1415 history.date::varchar||'-'||history.history_id::varchar as id,
1416 history.date as end_date,
1421 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1422 task_id, type_id, user_id, kanban_state, state,
1423 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1425 project_task_history
1431 class project_category(osv.osv):
1432 """ Category of project's task (or issue) """
1433 _name = "project.category"
1434 _description = "Category of project's task, issue, ..."
1436 'name': fields.char('Name', size=64, required=True, translate=True),