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, 'Related Status', required=True,
45 help="The status of your document is automatically changed regarding the selected stage. " \
46 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
47 'fold': fields.boolean('Hide in views if empty',
48 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
59 """Keep first word(s) of name to make it small enough
61 if not name: return name
62 # keep 7 chars + end of the last word
63 keep_words = name[:7].strip().split()
64 return ' '.join(name.split()[:len(keep_words)])
66 class project(osv.osv):
67 _name = "project.project"
68 _description = "Project"
69 _inherits = {'account.analytic.account': "analytic_account_id",
70 "mail.alias": "alias_id"}
71 _inherit = ['mail.thread', 'ir.needaction_mixin']
73 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
75 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
76 if context and context.get('user_preference'):
77 cr.execute("""SELECT project.id FROM project_project project
78 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
79 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
80 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
81 return [(r[0]) for r in cr.fetchall()]
82 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
83 context=context, count=count)
85 def _complete_name(self, cr, uid, ids, name, args, context=None):
87 for m in self.browse(cr, uid, ids, context=context):
88 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
91 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
92 partner_obj = self.pool.get('res.partner')
96 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
97 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
98 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
99 val['pricelist_id'] = pricelist_id
100 return {'value': val}
102 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
103 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
104 project_ids = [task.project_id.id for task in tasks if task.project_id]
105 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
107 def _get_project_and_parents(self, cr, uid, ids, context=None):
108 """ return the project ids and all their parent projects """
112 SELECT DISTINCT parent.id
113 FROM project_project project, project_project parent, account_analytic_account account
114 WHERE project.analytic_account_id = account.id
115 AND parent.analytic_account_id = account.parent_id
118 ids = [t[0] for t in cr.fetchall()]
122 def _get_project_and_children(self, cr, uid, ids, context=None):
123 """ retrieve all children projects of project ids;
124 return a dictionary mapping each project to its parent project (or None)
126 res = dict.fromkeys(ids, None)
129 SELECT project.id, parent.id
130 FROM project_project project, project_project parent, account_analytic_account account
131 WHERE project.analytic_account_id = account.id
132 AND parent.analytic_account_id = account.parent_id
135 dic = dict(cr.fetchall())
140 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
141 child_parent = self._get_project_and_children(cr, uid, ids, context)
142 # compute planned_hours, total_hours, effective_hours specific to each project
144 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
145 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
146 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
148 """, (tuple(child_parent.keys()),))
149 # aggregate results into res
150 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
151 for id, planned, total, effective in cr.fetchall():
152 # add the values specific to id to all parent projects of id in the result
155 res[id]['planned_hours'] += planned
156 res[id]['total_hours'] += total
157 res[id]['effective_hours'] += effective
158 id = child_parent[id]
159 # compute progress rates
161 if res[id]['total_hours']:
162 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
164 res[id]['progress_rate'] = 0.0
167 def unlink(self, cr, uid, ids, *args, **kwargs):
169 mail_alias = self.pool.get('mail.alias')
170 for proj in self.browse(cr, uid, ids):
172 raise osv.except_osv(_('Invalid Action!'),
173 _('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.'))
175 alias_ids.append(proj.alias_id.id)
176 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
177 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
180 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
181 res = dict.fromkeys(ids, 0)
182 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
183 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
184 res[task.project_id.id] += 1
187 def _get_alias_models(self, cr, uid, context=None):
188 """Overriden in project_issue to offer more options"""
189 return [('project.task', "Tasks")]
191 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
192 _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=None, context=None):
318 context['active_test'] = False
319 default['state'] = 'open'
320 default['tasks'] = []
321 default.pop('alias_name', None)
322 default.pop('alias_id', None)
323 proj = self.browse(cr, uid, id, context=context)
324 if not default.get('name', False):
325 default.update(name=_("%s (copy)") % (proj.name))
326 res = super(project, self).copy(cr, uid, id, default, context)
327 self.map_tasks(cr,uid,id,res,context)
330 def duplicate_template(self, cr, uid, ids, context=None):
333 data_obj = self.pool.get('ir.model.data')
335 for proj in self.browse(cr, uid, ids, context=context):
336 parent_id = context.get('parent_id', False)
337 context.update({'analytic_project_copy': True})
338 new_date_start = time.strftime('%Y-%m-%d')
340 if proj.date_start and proj.date:
341 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
342 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
343 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
344 context.update({'copy':True})
345 new_id = self.copy(cr, uid, proj.id, default = {
346 'name':_("%s (copy)") % (proj.name),
348 'date_start':new_date_start,
350 'parent_id':parent_id}, context=context)
351 result.append(new_id)
353 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
354 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
356 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
358 if result and len(result):
360 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
361 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
362 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
363 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
364 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
365 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
367 'name': _('Projects'),
369 'view_mode': 'form,tree',
370 'res_model': 'project.project',
373 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
374 'type': 'ir.actions.act_window',
375 'search_view_id': search_view['res_id'],
379 # set active value for a project, its sub projects and its tasks
380 def setActive(self, cr, uid, ids, value=True, context=None):
381 task_obj = self.pool.get('project.task')
382 for proj in self.browse(cr, uid, ids, context=None):
383 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
384 cr.execute('select id from project_task where project_id=%s', (proj.id,))
385 tasks_id = [x[0] for x in cr.fetchall()]
387 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
388 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
390 self.setActive(cr, uid, child_ids, value, context=None)
393 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
394 context = context or {}
395 if type(ids) in (long, int,):
397 projects = self.browse(cr, uid, ids, context=context)
399 for project in projects:
400 if (not project.members) and force_members:
401 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
403 resource_pool = self.pool.get('resource.resource')
405 result = "from openerp.addons.resource.faces import *\n"
406 result += "import datetime\n"
407 for project in self.browse(cr, uid, ids, context=context):
408 u_ids = [i.id for i in project.members]
409 if project.user_id and (project.user_id.id not in u_ids):
410 u_ids.append(project.user_id.id)
411 for task in project.tasks:
412 if task.state in ('done','cancelled'):
414 if task.user_id and (task.user_id.id not in u_ids):
415 u_ids.append(task.user_id.id)
416 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
417 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
418 for key, vals in resource_objs.items():
420 class User_%s(Resource):
422 ''' % (key, vals.get('efficiency', False))
429 def _schedule_project(self, cr, uid, project, context=None):
430 resource_pool = self.pool.get('resource.resource')
431 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
432 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
433 # TODO: check if we need working_..., default values are ok.
434 puids = [x.id for x in project.members]
436 puids.append(project.user_id.id)
444 project.date_start, working_days,
445 '|'.join(['User_'+str(x) for x in puids])
447 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
454 #TODO: DO Resource allocation and compute availability
455 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
461 def schedule_tasks(self, cr, uid, ids, context=None):
462 context = context or {}
463 if type(ids) in (long, int,):
465 projects = self.browse(cr, uid, ids, context=context)
466 result = self._schedule_header(cr, uid, ids, False, context=context)
467 for project in projects:
468 result += self._schedule_project(cr, uid, project, context=context)
469 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
472 exec result in local_dict
473 projects_gantt = Task.BalancedProject(local_dict['Project'])
475 for project in projects:
476 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
477 for task in project.tasks:
478 if task.state in ('done','cancelled'):
481 p = getattr(project_gantt, 'Task_%d' % (task.id,))
483 self.pool.get('project.task').write(cr, uid, [task.id], {
484 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
485 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
487 if (not task.user_id) and (p.booked_resource):
488 self.pool.get('project.task').write(cr, uid, [task.id], {
489 'user_id': int(p.booked_resource[0].name[5:]),
493 # ------------------------------------------------
494 # OpenChatter methods and notifications
495 # ------------------------------------------------
497 def create(self, cr, uid, vals, context=None):
498 if context is None: context = {}
499 # Prevent double project creation when 'use_tasks' is checked!
500 context = dict(context, project_creation_in_progress=True)
501 mail_alias = self.pool.get('mail.alias')
502 if not vals.get('alias_id'):
503 vals.pop('alias_name', None) # prevent errors during copy()
504 alias_id = mail_alias.create_unique_alias(cr, uid,
505 # Using '+' allows using subaddressing for those who don't
506 # have a catchall domain setup.
507 {'alias_name': "project+"+short_name(vals['name'])},
508 model_name=vals.get('alias_model', 'project.task'),
510 vals['alias_id'] = alias_id
511 if vals.get('partner_id', False):
512 vals['type'] = 'contract'
513 project_id = super(project, self).create(cr, uid, vals, context)
514 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
515 self.create_send_note(cr, uid, [project_id], context=context)
518 def create_send_note(self, cr, uid, ids, context=None):
519 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
521 def set_open_send_note(self, cr, uid, ids, context=None):
522 message = _("Project has been <b>opened</b>.")
523 return self.message_post(cr, uid, ids, body=message, context=context)
525 def set_pending_send_note(self, cr, uid, ids, context=None):
526 message = _("Project is now <b>pending</b>.")
527 return self.message_post(cr, uid, ids, body=message, context=context)
529 def set_cancel_send_note(self, cr, uid, ids, context=None):
530 message = _("Project has been <b>cancelled</b>.")
531 return self.message_post(cr, uid, ids, body=message, context=context)
533 def set_close_send_note(self, cr, uid, ids, context=None):
534 message = _("Project has been <b>closed</b>.")
535 return self.message_post(cr, uid, ids, body=message, context=context)
537 def write(self, cr, uid, ids, vals, context=None):
538 # if alias_model has been changed, update alias_model_id accordingly
539 if vals.get('alias_model'):
540 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
541 vals.update(alias_model_id=model_ids[0])
542 return super(project, self).write(cr, uid, ids, vals, context=context)
544 class task(base_stage, osv.osv):
545 _name = "project.task"
546 _description = "Task"
547 _date_name = "date_start"
548 _inherit = ['mail.thread', 'ir.needaction_mixin']
550 def _get_default_project_id(self, cr, uid, context=None):
551 """ Gives default section by checking if present in the context """
552 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
554 def _get_default_stage_id(self, cr, uid, context=None):
555 """ Gives default stage_id """
556 project_id = self._get_default_project_id(cr, uid, context=context)
557 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
559 def _resolve_project_id_from_context(self, cr, uid, context=None):
560 """ Returns ID of project based on the value of 'default_project_id'
561 context key, or None if it cannot be resolved to a single
564 if context is None: context = {}
565 if type(context.get('default_project_id')) in (int, long):
566 return context['default_project_id']
567 if isinstance(context.get('default_project_id'), basestring):
568 project_name = context['default_project_id']
569 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
570 if len(project_ids) == 1:
571 return project_ids[0][0]
574 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
575 stage_obj = self.pool.get('project.task.type')
576 order = stage_obj._order
577 access_rights_uid = access_rights_uid or uid
578 # lame way to allow reverting search, should just work in the trivial case
579 if read_group_order == 'stage_id desc':
580 order = '%s desc' % order
581 # retrieve section_id from the context and write the domain
582 # - ('id', 'in', 'ids'): add columns that should be present
583 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
584 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
586 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
588 search_domain += ['|', ('project_ids', '=', project_id)]
589 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
590 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
591 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
592 # restore order of the search
593 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
596 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
597 fold[stage.id] = stage.fold or False
600 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
601 res_users = self.pool.get('res.users')
602 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
603 access_rights_uid = access_rights_uid or uid
605 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
606 order = res_users._order
607 # lame way to allow reverting search, should just work in the trivial case
608 if read_group_order == 'user_id desc':
609 order = '%s desc' % order
610 # de-duplicate and apply search order
611 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
612 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
613 # restore order of the search
614 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
618 'stage_id': _read_group_stage_ids,
619 'user_id': _read_group_user_id,
622 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
623 obj_project = self.pool.get('project.project')
625 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
626 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
627 if id and isinstance(id, (long, int)):
628 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
629 args.append(('active', '=', False))
630 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
632 def _str_get(self, task, level=0, border='***', context=None):
633 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'+ \
634 border[0]+' '+(task.name or '')+'\n'+ \
635 (task.description or '')+'\n\n'
637 # Compute: effective_hours, total_hours, progress
638 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
640 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
641 hours = dict(cr.fetchall())
642 for task in self.browse(cr, uid, ids, context=context):
643 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)}
644 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
645 res[task.id]['progress'] = 0.0
646 if (task.remaining_hours + hours.get(task.id, 0.0)):
647 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
648 if task.state in ('done','cancelled'):
649 res[task.id]['progress'] = 100.0
652 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
653 if remaining and not planned:
654 return {'value':{'planned_hours': remaining}}
657 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
658 return {'value':{'remaining_hours': planned - effective}}
660 def onchange_project(self, cr, uid, id, project_id):
663 data = self.pool.get('project.project').browse(cr, uid, [project_id])
664 partner_id=data and data[0].partner_id
666 return {'value':{'partner_id':partner_id.id}}
669 def duplicate_task(self, cr, uid, map_ids, context=None):
670 for new in map_ids.values():
671 task = self.browse(cr, uid, new, context)
672 child_ids = [ ch.id for ch in task.child_ids]
674 for child in task.child_ids:
675 if child.id in map_ids.keys():
676 child_ids.remove(child.id)
677 child_ids.append(map_ids[child.id])
679 parent_ids = [ ch.id for ch in task.parent_ids]
681 for parent in task.parent_ids:
682 if parent.id in map_ids.keys():
683 parent_ids.remove(parent.id)
684 parent_ids.append(map_ids[parent.id])
685 #FIXME why there is already the copy and the old one
686 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
688 def copy_data(self, cr, uid, id, default=None, context=None):
691 default = default or {}
692 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
693 if not default.get('remaining_hours', False):
694 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
695 default['active'] = True
696 if not default.get('name', False):
697 default['name'] = self.browse(cr, uid, id, context=context).name or ''
698 if not context.get('copy',False):
699 new_name = _("%s (copy)") % (default.get('name', ''))
700 default.update({'name':new_name})
701 return super(task, self).copy_data(cr, uid, id, default, context)
703 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
705 for task in self.browse(cr, uid, ids, context=context):
708 if task.project_id.active == False or task.project_id.state == 'template':
712 def _get_task(self, cr, uid, ids, context=None):
714 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
715 if work.task_id: result[work.task_id.id] = True
719 '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."),
720 'name': fields.char('Task Summary', size=128, required=True, select=True),
721 'description': fields.text('Description'),
722 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
723 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
724 'stage_id': fields.many2one('project.task.type', 'Stage',
725 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
726 'state': fields.related('stage_id', 'state', type="selection", store=True,
727 selection=_TASK_STATE, string="State", readonly=True,
728 help='The state is set to \'Draft\', when a case is created.\
729 If the case is in progress the state is set to \'Open\'.\
730 When the case is over, the state is set to \'Done\'.\
731 If the case needs to be reviewed then the state is \
732 set to \'Pending\'.'),
733 'categ_ids': fields.many2many('project.category', string='Tags'),
734 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
735 help="A task's kanban state indicates special situations affecting it:\n"
736 " * Normal is the default situation\n"
737 " * Blocked indicates something is preventing the progress of this task\n"
738 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
739 readonly=True, required=False),
740 'create_date': fields.datetime('Create Date', readonly=True,select=True),
741 'date_start': fields.datetime('Starting Date',select=True),
742 'date_end': fields.datetime('Ending Date',select=True),
743 'date_deadline': fields.date('Deadline',select=True),
744 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
745 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
746 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
747 'notes': fields.text('Notes'),
748 '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.'),
749 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
751 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
752 'project.task.work': (_get_task, ['hours'], 10),
754 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
755 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
757 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
758 'project.task.work': (_get_task, ['hours'], 10),
760 '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",
762 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
763 'project.task.work': (_get_task, ['hours'], 10),
765 '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.",
767 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
768 'project.task.work': (_get_task, ['hours'], 10),
770 'user_id': fields.many2one('res.users', 'Assigned to'),
771 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
772 'partner_id': fields.many2one('res.partner', 'Customer'),
773 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
774 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
775 'company_id': fields.many2one('res.company', 'Company'),
776 'id': fields.integer('ID', readonly=True),
777 'color': fields.integer('Color Index'),
778 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
781 'stage_id': _get_default_stage_id,
782 'project_id': _get_default_project_id,
784 'kanban_state': 'normal',
789 'user_id': lambda obj, cr, uid, context: uid,
790 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
792 _order = "priority, sequence, date_start, name, id"
794 def set_priority(self, cr, uid, ids, priority, *args):
797 return self.write(cr, uid, ids, {'priority' : priority})
799 def set_high_priority(self, cr, uid, ids, *args):
800 """Set task priority to high
802 return self.set_priority(cr, uid, ids, '1')
804 def set_normal_priority(self, cr, uid, ids, *args):
805 """Set task priority to normal
807 return self.set_priority(cr, uid, ids, '2')
809 def _check_recursion(self, cr, uid, ids, context=None):
811 visited_branch = set()
813 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
819 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
820 if id in visited_branch: #Cycle
823 if id in visited_node: #Already tested don't work one more time for nothing
826 visited_branch.add(id)
829 #visit child using DFS
830 task = self.browse(cr, uid, id, context=context)
831 for child in task.child_ids:
832 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
836 visited_branch.remove(id)
839 def _check_dates(self, cr, uid, ids, context=None):
842 obj_task = self.browse(cr, uid, ids[0], context=context)
843 start = obj_task.date_start or False
844 end = obj_task.date_end or False
851 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
852 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
855 # 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=None, context=None):
1023 Delegate Task to another users.
1025 if delegate_data is None:
1027 assert delegate_data['user_id'], _("Delegated User should be specified")
1028 delegated_tasks = {}
1029 for task in self.browse(cr, uid, ids, context=context):
1030 delegated_task_id = self.copy(cr, uid, task.id, {
1031 'name': delegate_data['name'],
1032 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1033 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1034 'planned_hours': delegate_data['planned_hours'] or 0.0,
1035 'parent_ids': [(6, 0, [task.id])],
1037 'description': delegate_data['new_task_description'] or '',
1041 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1042 newname = delegate_data['prefix'] or ''
1044 'remaining_hours': delegate_data['planned_hours_me'],
1045 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1048 if delegate_data['state'] == 'pending':
1049 self.do_pending(cr, uid, [task.id], context=context)
1050 elif delegate_data['state'] == 'done':
1051 self.do_close(cr, uid, [task.id], context=context)
1052 self.do_delegation_send_note(cr, uid, [task.id], context)
1053 delegated_tasks[task.id] = delegated_task_id
1054 return delegated_tasks
1056 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1057 for task in self.browse(cr, uid, ids, context=context):
1058 if (task.state=='draft') or (task.planned_hours==0.0):
1059 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1060 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1063 def set_remaining_time_1(self, cr, uid, ids, context=None):
1064 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1066 def set_remaining_time_2(self, cr, uid, ids, context=None):
1067 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1069 def set_remaining_time_5(self, cr, uid, ids, context=None):
1070 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1072 def set_remaining_time_10(self, cr, uid, ids, context=None):
1073 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1075 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1076 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1079 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1080 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1083 def set_kanban_state_done(self, cr, uid, ids, context=None):
1084 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1087 def _store_history(self, cr, uid, ids, context=None):
1088 for task in self.browse(cr, uid, ids, context=context):
1089 self.pool.get('project.task.history').create(cr, uid, {
1091 'remaining_hours': task.remaining_hours,
1092 'planned_hours': task.planned_hours,
1093 'kanban_state': task.kanban_state,
1094 'type_id': task.stage_id.id,
1095 'state': task.state,
1096 'user_id': task.user_id.id
1101 def create(self, cr, uid, vals, context=None):
1102 task_id = super(task, self).create(cr, uid, vals, context=context)
1103 project_id = self.browse(cr, uid, task_id, context=context).project_id
1105 followers = [follower.id for follower in project_id.message_follower_ids]
1106 self.message_subscribe(cr, uid, [task_id], followers, context=context)
1107 self._store_history(cr, uid, [task_id], context=context)
1108 self.create_send_note(cr, uid, [task_id], context=context)
1111 # Overridden to reset the kanban_state to normal whenever
1112 # the stage (stage_id) of the task changes.
1113 def write(self, cr, uid, ids, vals, context=None):
1114 if isinstance(ids, (int, long)):
1116 if vals.get('project_id'):
1117 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1118 vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1119 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1120 new_stage = vals.get('stage_id')
1121 vals_reset_kstate = dict(vals, kanban_state='normal')
1122 for t in self.browse(cr, uid, ids, context=context):
1123 #TO FIX:Kanban view doesn't raise warning
1124 #stages = [stage.id for stage in t.project_id.type_ids]
1125 #if new_stage not in stages:
1126 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1127 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1128 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1129 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1132 result = super(task,self).write(cr, uid, ids, vals, context=context)
1133 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1134 self._store_history(cr, uid, ids, context=context)
1137 def unlink(self, cr, uid, ids, context=None):
1140 self._check_child_task(cr, uid, ids, context=context)
1141 res = super(task, self).unlink(cr, uid, ids, context)
1144 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1145 context = context or {}
1149 if task.state in ('done','cancelled'):
1154 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1156 for t2 in task.parent_ids:
1157 start.append("up.Task_%s.end" % (t2.id,))
1161 ''' % (ident,','.join(start))
1166 ''' % (ident, 'User_'+str(task.user_id.id))
1171 # ---------------------------------------------------
1173 # ---------------------------------------------------
1175 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1176 """ Override to updates the document according to the email. """
1177 if custom_values is None: custom_values = {}
1178 custom_values.update({
1180 'planned_hours': 0.0,
1181 'subject': msg.get('subject'),
1183 return super(project_tasks,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1185 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1186 """ Override to update the task according to the email. """
1187 if update_vals is None: update_vals = {}
1190 'cost':'planned_hours',
1192 for line in msg['body'].split('\n'):
1194 res = tools.misc.command_re.match(line)
1196 match = res.group(1).lower()
1197 field = maps.get(match)
1200 update_vals[field] = float(res.group(2).lower())
1201 except (ValueError, TypeError):
1203 elif match.lower() == 'state' \
1204 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1205 act = 'do_%s' % res.group(2).lower()
1207 getattr(self,act)(cr, uid, ids, context=context)
1208 return super(project_tasks,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1210 # ---------------------------------------------------
1211 # OpenChatter methods and notifications
1212 # ---------------------------------------------------
1214 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1215 """ Override of default prefix for notifications. """
1218 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1219 """ Returns the user_ids that have to perform an action.
1220 Add to the previous results given by super the document responsible
1222 :return: dict { record_id: [user_ids], }
1224 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1225 for obj in self.browse(cr, uid, ids, context=context):
1226 if obj.state == 'draft' and obj.user_id:
1227 result[obj.id].append(obj.user_id.id)
1230 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1231 """ Add 'user_id' and 'manager_id' to the monitored fields """
1232 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1233 return res + ['user_id', 'manager_id']
1235 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1236 """ Override of the (void) default notification method. """
1237 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1238 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1240 def create_send_note(self, cr, uid, ids, context=None):
1241 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1243 def case_draft_send_note(self, cr, uid, ids, context=None):
1244 msg = _('Task has been set as <b>draft</b>.')
1245 return self.message_post(cr, uid, ids, body=msg, context=context)
1247 def do_delegation_send_note(self, cr, uid, ids, context=None):
1248 for task in self.browse(cr, uid, ids, context=context):
1249 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1250 self.message_post(cr, uid, [task.id], body=msg, context=context)
1254 class project_work(osv.osv):
1255 _name = "project.task.work"
1256 _description = "Project Task Work"
1258 'name': fields.char('Work summary', size=128),
1259 'date': fields.datetime('Date', select="1"),
1260 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1261 'hours': fields.float('Time Spent'),
1262 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1263 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1267 'user_id': lambda obj, cr, uid, context: uid,
1268 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1271 _order = "date desc"
1272 def create(self, cr, uid, vals, *args, **kwargs):
1273 if 'hours' in vals and (not vals['hours']):
1274 vals['hours'] = 0.00
1275 if 'task_id' in vals:
1276 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1277 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1279 def write(self, cr, uid, ids, vals, context=None):
1280 if 'hours' in vals and (not vals['hours']):
1281 vals['hours'] = 0.00
1283 for work in self.browse(cr, uid, ids, context=context):
1284 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))
1285 return super(project_work,self).write(cr, uid, ids, vals, context)
1287 def unlink(self, cr, uid, ids, *args, **kwargs):
1288 for work in self.browse(cr, uid, ids):
1289 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1290 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1293 class account_analytic_account(osv.osv):
1294 _inherit = 'account.analytic.account'
1295 _description = 'Analytic Account'
1297 '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"),
1298 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1301 def on_change_template(self, cr, uid, ids, template_id, context=None):
1302 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1303 if template_id and 'value' in res:
1304 template = self.browse(cr, uid, template_id, context=context)
1305 res['value']['use_tasks'] = template.use_tasks
1308 def _trigger_project_creation(self, cr, uid, vals, context=None):
1310 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.
1312 if context is None: context = {}
1313 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1315 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1317 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.
1319 project_pool = self.pool.get('project.project')
1320 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1321 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1323 'name': vals.get('name'),
1324 'analytic_account_id': analytic_account_id,
1326 return project_pool.create(cr, uid, project_values, context=context)
1329 def create(self, cr, uid, vals, context=None):
1332 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1333 vals['child_ids'] = []
1334 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1335 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1336 return analytic_account_id
1338 def write(self, cr, uid, ids, vals, context=None):
1339 name = vals.get('name')
1340 for account in self.browse(cr, uid, ids, context=context):
1342 vals['name'] = account.name
1343 self.project_create(cr, uid, account.id, vals, context=context)
1344 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1346 def unlink(self, cr, uid, ids, *args, **kwargs):
1347 project_obj = self.pool.get('project.project')
1348 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1350 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1351 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1353 class project_project(osv.osv):
1354 _inherit = 'project.project'
1361 # Tasks History, used for cumulative flow charts (Lean/Agile)
1364 class project_task_history(osv.osv):
1365 _name = 'project.task.history'
1366 _description = 'History of Tasks'
1367 _rec_name = 'task_id'
1369 def _get_date(self, cr, uid, ids, name, arg, context=None):
1371 for history in self.browse(cr, uid, ids, context=context):
1372 if history.state in ('done','cancelled'):
1373 result[history.id] = history.date
1375 cr.execute('''select
1378 project_task_history
1382 order by id limit 1''', (history.task_id.id, history.id))
1384 result[history.id] = res and res[0] or False
1387 def _get_related_date(self, cr, uid, ids, context=None):
1389 for history in self.browse(cr, uid, ids, context=context):
1390 cr.execute('''select
1393 project_task_history
1397 order by id desc limit 1''', (history.task_id.id, history.id))
1400 result.append(res[0])
1404 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1405 'type_id': fields.many2one('project.task.type', 'Stage'),
1406 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1407 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1408 'date': fields.date('Date', select=True),
1409 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1410 'project.task.history': (_get_related_date, None, 20)
1412 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1413 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1414 'user_id': fields.many2one('res.users', 'Responsible'),
1417 'date': fields.date.context_today,
1421 class project_task_history_cumulative(osv.osv):
1422 _name = 'project.task.history.cumulative'
1423 _table = 'project_task_history_cumulative'
1424 _inherit = 'project.task.history'
1427 'end_date': fields.date('End Date'),
1428 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1431 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1433 history.date::varchar||'-'||history.history_id::varchar as id,
1434 history.date as end_date,
1439 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1440 task_id, type_id, user_id, kanban_state, state,
1441 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1443 project_task_history
1449 class project_category(osv.osv):
1450 """ Category of project's task (or issue) """
1451 _name = "project.category"
1452 _description = "Category of project's task, issue, ..."
1454 'name': fields.char('Name', size=64, required=True, translate=True),