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 = ['mail.thread', 'ir.needaction_mixin']
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 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
191 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
194 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
195 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
196 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
197 '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),
198 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
199 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
200 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)]}),
201 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
202 '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.",
204 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
205 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
207 '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.",
209 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
210 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
212 '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.",
214 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
215 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
217 '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.",
219 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
220 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
222 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
223 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
224 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
225 'color': fields.integer('Color Index'),
226 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
227 help="Internal email associated with this project. Incoming emails are automatically synchronized"
228 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
229 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
230 help="The kind of document created when an email is received on this project's email alias"),
231 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
232 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
235 def _get_type_common(self, cr, uid, context):
236 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
246 'type_ids': _get_type_common,
247 'alias_model': 'project.task',
248 'privacy_visibility': 'public',
249 'alias_domain': False, # always hide alias during creation
252 # TODO: Why not using a SQL contraints ?
253 def _check_dates(self, cr, uid, ids, context=None):
254 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
255 if leave['date_start'] and leave['date']:
256 if leave['date_start'] > leave['date']:
261 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
264 def set_template(self, cr, uid, ids, context=None):
265 res = self.setActive(cr, uid, ids, value=False, context=context)
268 def set_done(self, cr, uid, ids, context=None):
269 task_obj = self.pool.get('project.task')
270 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
271 task_obj.case_close(cr, uid, task_ids, context=context)
272 self.write(cr, uid, ids, {'state':'close'}, context=context)
273 self.set_close_send_note(cr, uid, ids, context=context)
276 def set_cancel(self, cr, uid, ids, context=None):
277 task_obj = self.pool.get('project.task')
278 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
279 task_obj.case_cancel(cr, uid, task_ids, context=context)
280 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
281 self.set_cancel_send_note(cr, uid, ids, context=context)
284 def set_pending(self, cr, uid, ids, context=None):
285 self.write(cr, uid, ids, {'state':'pending'}, context=context)
286 self.set_pending_send_note(cr, uid, ids, context=context)
289 def set_open(self, cr, uid, ids, context=None):
290 self.write(cr, uid, ids, {'state':'open'}, context=context)
291 self.set_open_send_note(cr, uid, ids, context=context)
294 def reset_project(self, cr, uid, ids, context=None):
295 res = self.setActive(cr, uid, ids, value=True, context=context)
296 self.set_open_send_note(cr, uid, ids, context=context)
299 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
300 """ copy and map tasks from old to new project """
304 task_obj = self.pool.get('project.task')
305 proj = self.browse(cr, uid, old_project_id, context=context)
306 for task in proj.tasks:
307 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
308 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
309 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
312 def copy(self, cr, uid, id, default={}, context=None):
316 default = default or {}
317 context['active_test'] = False
318 default['state'] = 'open'
319 default['tasks'] = []
320 default.pop('alias_name', None)
321 default.pop('alias_id', None)
322 proj = self.browse(cr, uid, id, context=context)
323 if not default.get('name', False):
324 default['name'] = proj.name + _(' (copy)')
325 res = super(project, self).copy(cr, uid, id, default, context)
326 self.map_tasks(cr,uid,id,res,context)
329 def duplicate_template(self, cr, uid, ids, context=None):
332 data_obj = self.pool.get('ir.model.data')
334 for proj in self.browse(cr, uid, ids, context=context):
335 parent_id = context.get('parent_id', False)
336 context.update({'analytic_project_copy': True})
337 new_date_start = time.strftime('%Y-%m-%d')
339 if proj.date_start and proj.date:
340 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
341 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
342 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
343 context.update({'copy':True})
344 new_id = self.copy(cr, uid, proj.id, default = {
345 'name': proj.name +_(' (copy)'),
347 'date_start':new_date_start,
349 'parent_id':parent_id}, context=context)
350 result.append(new_id)
352 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
353 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
355 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
357 if result and len(result):
359 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
360 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
361 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
362 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
363 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
364 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
366 'name': _('Projects'),
368 'view_mode': 'form,tree',
369 'res_model': 'project.project',
372 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
373 'type': 'ir.actions.act_window',
374 'search_view_id': search_view['res_id'],
378 # set active value for a project, its sub projects and its tasks
379 def setActive(self, cr, uid, ids, value=True, context=None):
380 task_obj = self.pool.get('project.task')
381 for proj in self.browse(cr, uid, ids, context=None):
382 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
383 cr.execute('select id from project_task where project_id=%s', (proj.id,))
384 tasks_id = [x[0] for x in cr.fetchall()]
386 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
387 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
389 self.setActive(cr, uid, child_ids, value, context=None)
392 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
393 context = context or {}
394 if type(ids) in (long, int,):
396 projects = self.browse(cr, uid, ids, context=context)
398 for project in projects:
399 if (not project.members) and force_members:
400 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
402 resource_pool = self.pool.get('resource.resource')
404 result = "from openerp.addons.resource.faces import *\n"
405 result += "import datetime\n"
406 for project in self.browse(cr, uid, ids, context=context):
407 u_ids = [i.id for i in project.members]
408 if project.user_id and (project.user_id.id not in u_ids):
409 u_ids.append(project.user_id.id)
410 for task in project.tasks:
411 if task.state in ('done','cancelled'):
413 if task.user_id and (task.user_id.id not in u_ids):
414 u_ids.append(task.user_id.id)
415 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
416 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
417 for key, vals in resource_objs.items():
419 class User_%s(Resource):
421 ''' % (key, vals.get('efficiency', False))
428 def _schedule_project(self, cr, uid, project, context=None):
429 resource_pool = self.pool.get('resource.resource')
430 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
431 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
432 # TODO: check if we need working_..., default values are ok.
433 puids = [x.id for x in project.members]
435 puids.append(project.user_id.id)
443 project.date_start, working_days,
444 '|'.join(['User_'+str(x) for x in puids])
446 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
453 #TODO: DO Resource allocation and compute availability
454 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
460 def schedule_tasks(self, cr, uid, ids, context=None):
461 context = context or {}
462 if type(ids) in (long, int,):
464 projects = self.browse(cr, uid, ids, context=context)
465 result = self._schedule_header(cr, uid, ids, False, context=context)
466 for project in projects:
467 result += self._schedule_project(cr, uid, project, context=context)
468 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
471 exec result in local_dict
472 projects_gantt = Task.BalancedProject(local_dict['Project'])
474 for project in projects:
475 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
476 for task in project.tasks:
477 if task.state in ('done','cancelled'):
480 p = getattr(project_gantt, 'Task_%d' % (task.id,))
482 self.pool.get('project.task').write(cr, uid, [task.id], {
483 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
484 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
486 if (not task.user_id) and (p.booked_resource):
487 self.pool.get('project.task').write(cr, uid, [task.id], {
488 'user_id': int(p.booked_resource[0].name[5:]),
492 # ------------------------------------------------
493 # OpenChatter methods and notifications
494 # ------------------------------------------------
496 def create(self, cr, uid, vals, context=None):
497 if context is None: context = {}
498 # Prevent double project creation when 'use_tasks' is checked!
499 context = dict(context, project_creation_in_progress=True)
500 mail_alias = self.pool.get('mail.alias')
501 if not vals.get('alias_id'):
502 vals.pop('alias_name', None) # prevent errors during copy()
503 alias_id = mail_alias.create_unique_alias(cr, uid,
504 # Using '+' allows using subaddressing for those who don't
505 # have a catchall domain setup.
506 {'alias_name': "project+"+short_name(vals['name'])},
507 model_name=vals.get('alias_model', 'project.task'),
509 vals['alias_id'] = alias_id
510 project_id = super(project, self).create(cr, uid, vals, context)
511 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
512 self.create_send_note(cr, uid, [project_id], context=context)
515 def create_send_note(self, cr, uid, ids, context=None):
516 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), subtype="new", context=context)
518 def set_open_send_note(self, cr, uid, ids, context=None):
519 message = _("Project has been <b>opened</b>.")
520 return self.message_post(cr, uid, ids, body=message, subtype="open", context=context)
522 def set_pending_send_note(self, cr, uid, ids, context=None):
523 message = _("Project is now <b>pending</b>.")
524 return self.message_post(cr, uid, ids, body=message, subtype="pending", context=context)
526 def set_cancel_send_note(self, cr, uid, ids, context=None):
527 message = _("Project has been <b>cancelled</b>.")
528 return self.message_post(cr, uid, ids, body=message, subtype="cancelled", context=context)
530 def set_close_send_note(self, cr, uid, ids, context=None):
531 message = _("Project has been <b>closed</b>.")
532 return self.message_post(cr, uid, ids, body=message, subtype="closed", context=context)
534 def write(self, cr, uid, ids, vals, context=None):
535 # if alias_model has been changed, update alias_model_id accordingly
536 if vals.get('alias_model'):
537 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
538 vals.update(alias_model_id=model_ids[0])
539 return super(project, self).write(cr, uid, ids, vals, context=context)
541 class task(base_stage, osv.osv):
542 _name = "project.task"
543 _description = "Task"
544 _date_name = "date_start"
545 _inherit = ['mail.thread', 'ir.needaction_mixin']
547 def _get_default_project_id(self, cr, uid, context=None):
548 """ Gives default section by checking if present in the context """
549 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
551 def _get_default_stage_id(self, cr, uid, context=None):
552 """ Gives default stage_id """
553 project_id = self._get_default_project_id(cr, uid, context=context)
554 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
556 def _resolve_project_id_from_context(self, cr, uid, context=None):
557 """ Returns ID of project based on the value of 'default_project_id'
558 context key, or None if it cannot be resolved to a single
561 if context is None: context = {}
562 if type(context.get('default_project_id')) in (int, long):
563 return context['default_project_id']
564 if isinstance(context.get('default_project_id'), basestring):
565 project_name = context['default_project_id']
566 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
567 if len(project_ids) == 1:
568 return project_ids[0][0]
571 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
572 stage_obj = self.pool.get('project.task.type')
573 order = stage_obj._order
574 access_rights_uid = access_rights_uid or uid
575 # lame way to allow reverting search, should just work in the trivial case
576 if read_group_order == 'stage_id desc':
577 order = '%s desc' % order
578 # retrieve section_id from the context and write the domain
579 # - ('id', 'in', 'ids'): add columns that should be present
580 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
581 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
583 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
585 search_domain += ['|', ('project_ids', '=', project_id)]
586 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
587 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
588 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
589 # restore order of the search
590 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
593 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
594 fold[stage.id] = stage.fold or False
597 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
598 res_users = self.pool.get('res.users')
599 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
600 access_rights_uid = access_rights_uid or uid
602 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
603 order = res_users._order
604 # lame way to allow reverting search, should just work in the trivial case
605 if read_group_order == 'user_id desc':
606 order = '%s desc' % order
607 # de-duplicate and apply search order
608 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
609 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
610 # restore order of the search
611 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
615 'stage_id': _read_group_stage_ids,
616 'user_id': _read_group_user_id,
619 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
620 obj_project = self.pool.get('project.project')
622 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
623 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
624 if id and isinstance(id, (long, int)):
625 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
626 args.append(('active', '=', False))
627 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
629 def _str_get(self, task, level=0, border='***', context=None):
630 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'+ \
631 border[0]+' '+(task.name or '')+'\n'+ \
632 (task.description or '')+'\n\n'
634 # Compute: effective_hours, total_hours, progress
635 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
637 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
638 hours = dict(cr.fetchall())
639 for task in self.browse(cr, uid, ids, context=context):
640 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)}
641 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
642 res[task.id]['progress'] = 0.0
643 if (task.remaining_hours + hours.get(task.id, 0.0)):
644 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
645 if task.state in ('done','cancelled'):
646 res[task.id]['progress'] = 100.0
649 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
650 if remaining and not planned:
651 return {'value':{'planned_hours': remaining}}
654 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
655 return {'value':{'remaining_hours': planned - effective}}
657 def onchange_project(self, cr, uid, id, project_id):
660 data = self.pool.get('project.project').browse(cr, uid, [project_id])
661 partner_id=data and data[0].partner_id
663 return {'value':{'partner_id':partner_id.id}}
666 def duplicate_task(self, cr, uid, map_ids, context=None):
667 for new in map_ids.values():
668 task = self.browse(cr, uid, new, context)
669 child_ids = [ ch.id for ch in task.child_ids]
671 for child in task.child_ids:
672 if child.id in map_ids.keys():
673 child_ids.remove(child.id)
674 child_ids.append(map_ids[child.id])
676 parent_ids = [ ch.id for ch in task.parent_ids]
678 for parent in task.parent_ids:
679 if parent.id in map_ids.keys():
680 parent_ids.remove(parent.id)
681 parent_ids.append(map_ids[parent.id])
682 #FIXME why there is already the copy and the old one
683 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
685 def copy_data(self, cr, uid, id, default={}, context=None):
686 default = default or {}
687 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
688 if not default.get('remaining_hours', False):
689 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
690 default['active'] = True
691 default['stage_id'] = False
692 if not default.get('name', False):
693 default['name'] = self.browse(cr, uid, id, context=context).name or ''
694 if not context.get('copy',False):
695 new_name = _("%s (copy)")%default.get('name','')
696 default.update({'name':new_name})
697 return super(task, self).copy_data(cr, uid, id, default, context)
700 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
702 for task in self.browse(cr, uid, ids, context=context):
705 if task.project_id.active == False or task.project_id.state == 'template':
709 def _get_task(self, cr, uid, ids, context=None):
711 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
712 if work.task_id: result[work.task_id.id] = True
716 '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."),
717 'name': fields.char('Task Summary', size=128, required=True, select=True),
718 'description': fields.text('Description'),
719 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
720 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
721 'stage_id': fields.many2one('project.task.type', 'Stage',
722 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
723 'state': fields.related('stage_id', 'state', type="selection", store=True,
724 selection=_TASK_STATE, string="State", readonly=True,
725 help='The state is set to \'Draft\', when a case is created.\
726 If the case is in progress the state is set to \'Open\'.\
727 When the case is over, the state is set to \'Done\'.\
728 If the case needs to be reviewed then the state is \
729 set to \'Pending\'.'),
730 'categ_ids': fields.many2many('project.category', string='Categories'),
731 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
732 help="A task's kanban state indicates special situations affecting it:\n"
733 " * Normal is the default situation\n"
734 " * Blocked indicates something is preventing the progress of this task\n"
735 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
736 readonly=True, required=False),
737 'create_date': fields.datetime('Create Date', readonly=True,select=True),
738 'date_start': fields.datetime('Starting Date',select=True),
739 'date_end': fields.datetime('Ending Date',select=True),
740 'date_deadline': fields.date('Deadline',select=True),
741 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
742 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
743 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
744 'notes': fields.text('Notes'),
745 '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.'),
746 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
748 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
749 'project.task.work': (_get_task, ['hours'], 10),
751 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
752 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
754 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
755 'project.task.work': (_get_task, ['hours'], 10),
757 '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",
759 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
760 'project.task.work': (_get_task, ['hours'], 10),
762 '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.",
764 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
765 'project.task.work': (_get_task, ['hours'], 10),
767 'user_id': fields.many2one('res.users', 'Assigned to'),
768 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
769 'partner_id': fields.many2one('res.partner', 'Contact'),
770 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
771 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
772 'company_id': fields.many2one('res.company', 'Company'),
773 'id': fields.integer('ID', readonly=True),
774 'color': fields.integer('Color Index'),
775 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
779 'stage_id': _get_default_stage_id,
780 'project_id': _get_default_project_id,
782 'kanban_state': 'normal',
787 'user_id': lambda obj, cr, uid, context: uid,
788 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
791 _order = "priority, sequence, date_start, name, id"
793 def set_priority(self, cr, uid, ids, priority, *args):
796 return self.write(cr, uid, ids, {'priority' : priority})
798 def set_high_priority(self, cr, uid, ids, *args):
799 """Set task priority to high
801 return self.set_priority(cr, uid, ids, '1')
803 def set_normal_priority(self, cr, uid, ids, *args):
804 """Set task priority to normal
806 return self.set_priority(cr, uid, ids, '2')
808 def _check_recursion(self, cr, uid, ids, context=None):
810 visited_branch = set()
812 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
818 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
819 if id in visited_branch: #Cycle
822 if id in visited_node: #Already tested don't work one more time for nothing
825 visited_branch.add(id)
828 #visit child using DFS
829 task = self.browse(cr, uid, id, context=context)
830 for child in task.child_ids:
831 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
835 visited_branch.remove(id)
838 def _check_dates(self, cr, uid, ids, context=None):
841 obj_task = self.browse(cr, uid, ids[0], context=context)
842 start = obj_task.date_start or False
843 end = obj_task.date_end or False
850 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
851 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
854 # Override view according to the company definition
856 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
857 users_obj = self.pool.get('res.users')
858 if context is None: context = {}
859 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
860 # this should be safe (no context passed to avoid side-effects)
861 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
862 tm = obj_tm and obj_tm.name or 'Hours'
864 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
866 if tm in ['Hours','Hour']:
869 eview = etree.fromstring(res['arch'])
871 def _check_rec(eview):
872 if eview.attrib.get('widget','') == 'float_time':
873 eview.set('widget','float')
880 res['arch'] = etree.tostring(eview)
882 for f in res['fields']:
883 if 'Hours' in res['fields'][f]['string']:
884 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
887 # ****************************************
889 # ****************************************
891 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
892 """ Override of the base.stage method
893 Parameter of the stage search taken from the lead:
894 - section_id: if set, stages must belong to this section or
895 be a default stage; if not set, stages must be default
898 if isinstance(cases, (int, long)):
899 cases = self.browse(cr, uid, cases, context=context)
900 # collect all section_ids
903 section_ids.append(section_id)
906 section_ids.append(task.project_id.id)
907 # OR all section_ids and OR with case_default
910 search_domain += [('|')] * len(section_ids)
911 for section_id in section_ids:
912 search_domain.append(('project_ids', '=', section_id))
913 search_domain.append(('case_default', '=', True))
914 # AND with the domain in parameter
915 search_domain += list(domain)
916 # perform search, return the first found
917 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
922 def _check_child_task(self, cr, uid, ids, context=None):
925 tasks = self.browse(cr, uid, ids, context=context)
928 for child in task.child_ids:
929 if child.state in ['draft', 'open', 'pending']:
930 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
933 def action_close(self, cr, uid, ids, context=None):
934 """ This action closes the task
936 task_id = len(ids) and ids[0] or False
937 self._check_child_task(cr, uid, ids, context=context)
938 if not task_id: return False
939 return self.do_close(cr, uid, [task_id], context=context)
941 def do_close(self, cr, uid, ids, context=None):
942 """ Compatibility when changing to case_close. """
943 return self.case_close(cr, uid, ids, context=context)
945 def case_close(self, cr, uid, ids, context=None):
947 if not isinstance(ids, list): ids = [ids]
948 for task in self.browse(cr, uid, ids, context=context):
950 project = task.project_id
951 for parent_id in task.parent_ids:
952 if parent_id.state in ('pending','draft'):
954 for child in parent_id.child_ids:
955 if child.id != task.id and child.state not in ('done','cancelled'):
958 self.do_reopen(cr, uid, [parent_id.id], context=context)
960 vals['remaining_hours'] = 0.0
961 if not task.date_end:
962 vals['date_end'] = fields.datetime.now()
963 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
964 self.case_close_send_note(cr, uid, [task.id], context=context)
967 def do_reopen(self, cr, uid, ids, context=None):
968 for task in self.browse(cr, uid, ids, context=context):
969 project = task.project_id
970 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
971 self.case_open_send_note(cr, uid, [task.id], context)
974 def do_cancel(self, cr, uid, ids, context=None):
975 """ Compatibility when changing to case_cancel. """
976 return self.case_cancel(cr, uid, ids, context=context)
978 def case_cancel(self, cr, uid, ids, context=None):
979 tasks = self.browse(cr, uid, ids, context=context)
980 self._check_child_task(cr, uid, ids, context=context)
982 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
983 self.case_cancel_send_note(cr, uid, [task.id], context=context)
986 def do_open(self, cr, uid, ids, context=None):
987 """ Compatibility when changing to case_open. """
988 return self.case_open(cr, uid, ids, context=context)
990 def case_open(self, cr, uid, ids, context=None):
991 if not isinstance(ids,list): ids = [ids]
992 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
993 self.case_open_send_note(cr, uid, ids, context)
996 def do_draft(self, cr, uid, ids, context=None):
997 """ Compatibility when changing to case_draft. """
998 return self.case_draft(cr, uid, ids, context=context)
1000 def case_draft(self, cr, uid, ids, context=None):
1001 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1002 self.case_draft_send_note(cr, uid, ids, context=context)
1005 def do_pending(self, cr, uid, ids, context=None):
1006 """ Compatibility when changing to case_pending. """
1007 return self.case_pending(cr, uid, ids, context=context)
1009 def case_pending(self, cr, uid, ids, context=None):
1010 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1011 return self.case_pending_send_note(cr, uid, ids, context=context)
1013 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1014 attachment = self.pool.get('ir.attachment')
1015 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1016 new_attachment_ids = []
1017 for attachment_id in attachment_ids:
1018 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1019 return new_attachment_ids
1021 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1023 Delegate Task to another users.
1025 assert delegate_data['user_id'], _("Delegated User should be specified")
1026 delegated_tasks = {}
1027 for task in self.browse(cr, uid, ids, context=context):
1028 delegated_task_id = self.copy(cr, uid, task.id, {
1029 'name': delegate_data['name'],
1030 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1031 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1032 'planned_hours': delegate_data['planned_hours'] or 0.0,
1033 'parent_ids': [(6, 0, [task.id])],
1035 'description': delegate_data['new_task_description'] or '',
1039 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1040 newname = delegate_data['prefix'] or ''
1042 'remaining_hours': delegate_data['planned_hours_me'],
1043 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1046 if delegate_data['state'] == 'pending':
1047 self.do_pending(cr, uid, [task.id], context=context)
1048 elif delegate_data['state'] == 'done':
1049 self.do_close(cr, uid, [task.id], context=context)
1050 self.do_delegation_send_note(cr, uid, [task.id], context)
1051 delegated_tasks[task.id] = delegated_task_id
1052 return delegated_tasks
1054 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1055 for task in self.browse(cr, uid, ids, context=context):
1056 if (task.state=='draft') or (task.planned_hours==0.0):
1057 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1058 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1061 def set_remaining_time_1(self, cr, uid, ids, context=None):
1062 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1064 def set_remaining_time_2(self, cr, uid, ids, context=None):
1065 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1067 def set_remaining_time_5(self, cr, uid, ids, context=None):
1068 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1070 def set_remaining_time_10(self, cr, uid, ids, context=None):
1071 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1073 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1074 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1077 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1078 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1081 def set_kanban_state_done(self, cr, uid, ids, context=None):
1082 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1085 def _store_history(self, cr, uid, ids, context=None):
1086 for task in self.browse(cr, uid, ids, context=context):
1087 self.pool.get('project.task.history').create(cr, uid, {
1089 'remaining_hours': task.remaining_hours,
1090 'planned_hours': task.planned_hours,
1091 'kanban_state': task.kanban_state,
1092 'type_id': task.stage_id.id,
1093 'state': task.state,
1094 'user_id': task.user_id.id
1099 def create(self, cr, uid, vals, context=None):
1100 task_id = super(task, self).create(cr, uid, vals, context=context)
1101 task_record = self.browse(cr, uid, task_id, context=context)
1102 if task_record.project_id:
1103 project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1104 self.message_subscribe(cr, uid, [task_id], project_follower_ids, context=context)
1105 self._store_history(cr, uid, [task_id], context=context)
1106 self.create_send_note(cr, uid, [task_id], context=context)
1109 # Overridden to reset the kanban_state to normal whenever
1110 # the stage (stage_id) of the task changes.
1111 def write(self, cr, uid, ids, vals, context=None):
1112 if isinstance(ids, (int, long)):
1114 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1115 new_stage = vals.get('stage_id')
1116 vals_reset_kstate = dict(vals, kanban_state='normal')
1117 for t in self.browse(cr, uid, ids, context=context):
1118 #TO FIX:Kanban view doesn't raise warning
1119 #stages = [stage.id for stage in t.project_id.type_ids]
1120 #if new_stage not in stages:
1121 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1122 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1123 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1124 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1127 result = super(task,self).write(cr, uid, ids, vals, context=context)
1128 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1129 self._store_history(cr, uid, ids, context=context)
1132 def unlink(self, cr, uid, ids, context=None):
1135 self._check_child_task(cr, uid, ids, context=context)
1136 res = super(task, self).unlink(cr, uid, ids, context)
1139 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1140 context = context or {}
1144 if task.state in ('done','cancelled'):
1149 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1151 for t2 in task.parent_ids:
1152 start.append("up.Task_%s.end" % (t2.id,))
1156 ''' % (ident,','.join(start))
1161 ''' % (ident, 'User_'+str(task.user_id.id))
1166 # ---------------------------------------------------
1168 # ---------------------------------------------------
1170 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1171 """ Override to updates the document according to the email. """
1172 if custom_values is None: custom_values = {}
1173 custom_values.update({
1175 'planned_hours': 0.0,
1176 'subject': msg.get('subject'),
1178 return super(project_tasks,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1180 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1181 """ Override to update the task according to the email. """
1182 if update_vals is None: update_vals = {}
1185 'cost':'planned_hours',
1187 for line in msg['body'].split('\n'):
1189 res = tools.misc.command_re.match(line)
1191 match = res.group(1).lower()
1192 field = maps.get(match)
1195 update_vals[field] = float(res.group(2).lower())
1196 except (ValueError, TypeError):
1198 elif match.lower() == 'state' \
1199 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1200 act = 'do_%s' % res.group(2).lower()
1202 getattr(self,act)(cr, uid, ids, context=context)
1203 return super(project_tasks,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1205 # ---------------------------------------------------
1206 # OpenChatter methods and notifications
1207 # ---------------------------------------------------
1209 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1210 """ Override of default prefix for notifications. """
1213 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1214 """ Returns the user_ids that have to perform an action.
1215 Add to the previous results given by super the document responsible
1217 :return: dict { record_id: [user_ids], }
1219 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1220 for obj in self.browse(cr, uid, ids, context=context):
1221 if obj.state == 'draft' and obj.user_id:
1222 result[obj.id].append(obj.user_id.id)
1225 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1226 """ Add 'user_id' and 'manager_id' to the monitored fields """
1227 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1228 return res + ['user_id', 'manager_id']
1230 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1231 """ Override of the (void) default notification method. """
1232 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1233 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), subtype="stage change", context=context)
1235 def create_send_note(self, cr, uid, ids, context=None):
1236 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), subtype="new", context=context)
1238 def case_draft_send_note(self, cr, uid, ids, context=None):
1239 msg = _('Task has been set as <b>draft</b>.')
1240 return self.message_post(cr, uid, ids, body=msg, context=context)
1242 def do_delegation_send_note(self, cr, uid, ids, context=None):
1243 for task in self.browse(cr, uid, ids, context=context):
1244 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1245 self.message_post(cr, uid, [task.id], body=msg, context=context)
1249 class project_work(osv.osv):
1250 _name = "project.task.work"
1251 _description = "Project Task Work"
1253 'name': fields.char('Work summary', size=128),
1254 'date': fields.datetime('Date', select="1"),
1255 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1256 'hours': fields.float('Time Spent'),
1257 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1258 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1262 'user_id': lambda obj, cr, uid, context: uid,
1263 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1266 _order = "date desc"
1267 def create(self, cr, uid, vals, *args, **kwargs):
1268 if 'hours' in vals and (not vals['hours']):
1269 vals['hours'] = 0.00
1270 if 'task_id' in vals:
1271 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1272 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1274 def write(self, cr, uid, ids, vals, context=None):
1275 if 'hours' in vals and (not vals['hours']):
1276 vals['hours'] = 0.00
1278 for work in self.browse(cr, uid, ids, context=context):
1279 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))
1280 return super(project_work,self).write(cr, uid, ids, vals, context)
1282 def unlink(self, cr, uid, ids, *args, **kwargs):
1283 for work in self.browse(cr, uid, ids):
1284 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1285 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1288 class account_analytic_account(osv.osv):
1289 _inherit = 'account.analytic.account'
1290 _description = 'Analytic Account'
1292 '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"),
1293 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1296 def on_change_template(self, cr, uid, ids, template_id, context=None):
1297 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1298 if template_id and 'value' in res:
1299 template = self.browse(cr, uid, template_id, context=context)
1300 res['value']['use_tasks'] = template.use_tasks
1303 def _trigger_project_creation(self, cr, uid, vals, context=None):
1305 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.
1307 if context is None: context = {}
1308 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1310 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1312 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.
1314 project_pool = self.pool.get('project.project')
1315 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1316 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1318 'name': vals.get('name'),
1319 'analytic_account_id': analytic_account_id,
1321 return project_pool.create(cr, uid, project_values, context=context)
1324 def create(self, cr, uid, vals, context=None):
1327 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1328 vals['child_ids'] = []
1329 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1330 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1331 return analytic_account_id
1333 def write(self, cr, uid, ids, vals, context=None):
1334 name = vals.get('name')
1335 for account in self.browse(cr, uid, ids, context=context):
1337 vals['name'] = account.name
1338 self.project_create(cr, uid, account.id, vals, context=context)
1339 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1341 def unlink(self, cr, uid, ids, *args, **kwargs):
1342 project_obj = self.pool.get('project.project')
1343 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1345 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1346 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1348 class project_project(osv.osv):
1349 _inherit = 'project.project'
1356 # Tasks History, used for cumulative flow charts (Lean/Agile)
1359 class project_task_history(osv.osv):
1360 _name = 'project.task.history'
1361 _description = 'History of Tasks'
1362 _rec_name = 'task_id'
1364 def _get_date(self, cr, uid, ids, name, arg, context=None):
1366 for history in self.browse(cr, uid, ids, context=context):
1367 if history.state in ('done','cancelled'):
1368 result[history.id] = history.date
1370 cr.execute('''select
1373 project_task_history
1377 order by id limit 1''', (history.task_id.id, history.id))
1379 result[history.id] = res and res[0] or False
1382 def _get_related_date(self, cr, uid, ids, context=None):
1384 for history in self.browse(cr, uid, ids, context=context):
1385 cr.execute('''select
1388 project_task_history
1392 order by id desc limit 1''', (history.task_id.id, history.id))
1395 result.append(res[0])
1399 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1400 'type_id': fields.many2one('project.task.type', 'Stage'),
1401 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1402 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1403 'date': fields.date('Date', select=True),
1404 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1405 'project.task.history': (_get_related_date, None, 20)
1407 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1408 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1409 'user_id': fields.many2one('res.users', 'Responsible'),
1412 'date': fields.date.context_today,
1416 class project_task_history_cumulative(osv.osv):
1417 _name = 'project.task.history.cumulative'
1418 _table = 'project_task_history_cumulative'
1419 _inherit = 'project.task.history'
1422 'end_date': fields.date('End Date'),
1423 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1426 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1428 history.date::varchar||'-'||history.history_id::varchar as id,
1429 history.date as end_date,
1434 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1435 task_id, type_id, user_id, kanban_state, state,
1436 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1438 project_task_history
1444 class project_category(osv.osv):
1445 """ Category of project's task (or issue) """
1446 _name = "project.category"
1447 _description = "Category of project's task, issue, ..."
1449 'name': fields.char('Name', size=64, required=True, translate=True),