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 ##############################################################################
23 from lxml import etree
24 from datetime import datetime, date
27 from base_status.base_stage import base_stage
28 from osv import fields, osv
29 from openerp.addons.resource.faces import task as Task
30 from tools.translate import _
31 from openerp import SUPERUSER_ID
33 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
35 class project_task_type(osv.osv):
36 _name = 'project.task.type'
37 _description = 'Task Stage'
40 'name': fields.char('Stage Name', required=True, size=64, translate=True),
41 'description': fields.text('Description'),
42 'sequence': fields.integer('Sequence'),
43 'case_default': fields.boolean('Common to All Projects',
44 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."),
45 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
46 'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
47 help="The status of your document is automatically changed regarding the selected stage. " \
48 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
49 'fold': fields.boolean('Hide in views if empty',
50 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
61 """Keep first word(s) of name to make it small enough
63 if not name: return name
64 # keep 7 chars + end of the last word
65 keep_words = name[:7].strip().split()
66 return ' '.join(name.split()[:len(keep_words)])
68 class project(osv.osv):
69 _name = "project.project"
70 _description = "Project"
71 _inherits = {'account.analytic.account': "analytic_account_id",
72 "mail.alias": "alias_id"}
73 _inherit = ['mail.thread', 'ir.needaction_mixin']
75 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
77 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
78 if context and context.get('user_preference'):
79 cr.execute("""SELECT project.id FROM project_project project
80 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
81 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
82 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
83 return [(r[0]) for r in cr.fetchall()]
84 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
85 context=context, count=count)
87 def _complete_name(self, cr, uid, ids, name, args, context=None):
89 for m in self.browse(cr, uid, ids, context=context):
90 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
93 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
94 partner_obj = self.pool.get('res.partner')
98 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
99 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
100 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
101 val['pricelist_id'] = pricelist_id
102 return {'value': val}
104 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
105 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
106 project_ids = [task.project_id.id for task in tasks if task.project_id]
107 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
109 def _get_project_and_parents(self, cr, uid, ids, context=None):
110 """ return the project ids and all their parent projects """
114 SELECT DISTINCT parent.id
115 FROM project_project project, project_project parent, account_analytic_account account
116 WHERE project.analytic_account_id = account.id
117 AND parent.analytic_account_id = account.parent_id
120 ids = [t[0] for t in cr.fetchall()]
124 def _get_project_and_children(self, cr, uid, ids, context=None):
125 """ retrieve all children projects of project ids;
126 return a dictionary mapping each project to its parent project (or None)
128 res = dict.fromkeys(ids, None)
131 SELECT project.id, parent.id
132 FROM project_project project, project_project parent, account_analytic_account account
133 WHERE project.analytic_account_id = account.id
134 AND parent.analytic_account_id = account.parent_id
137 dic = dict(cr.fetchall())
142 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
143 child_parent = self._get_project_and_children(cr, uid, ids, context)
144 # compute planned_hours, total_hours, effective_hours specific to each project
146 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
147 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
148 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
150 """, (tuple(child_parent.keys()),))
151 # aggregate results into res
152 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
153 for id, planned, total, effective in cr.fetchall():
154 # add the values specific to id to all parent projects of id in the result
157 res[id]['planned_hours'] += planned
158 res[id]['total_hours'] += total
159 res[id]['effective_hours'] += effective
160 id = child_parent[id]
161 # compute progress rates
163 if res[id]['total_hours']:
164 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
166 res[id]['progress_rate'] = 0.0
169 def unlink(self, cr, uid, ids, *args, **kwargs):
171 mail_alias = self.pool.get('mail.alias')
172 for proj in self.browse(cr, uid, ids):
174 raise osv.except_osv(_('Invalid Action!'),
175 _('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.'))
177 alias_ids.append(proj.alias_id.id)
178 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
179 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
182 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
183 res = dict.fromkeys(ids, 0)
184 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
185 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
186 res[task.project_id.id] += 1
189 def _get_alias_models(self, cr, uid, context=None):
190 """Overriden in project_issue to offer more options"""
191 return [('project.task', "Tasks")]
193 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
194 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
196 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
197 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
198 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
199 '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),
200 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
201 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
202 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)]}),
203 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
204 '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.",
206 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
207 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
209 '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.",
211 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
212 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
214 '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.",
216 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
217 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
219 '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.",
221 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
222 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
224 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
225 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
226 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
227 'color': fields.integer('Color Index'),
228 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
229 help="Internal email associated with this project. Incoming emails are automatically synchronized"
230 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
231 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
232 help="The kind of document created when an email is received on this project's email alias"),
233 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
234 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
237 def _get_type_common(self, cr, uid, context):
238 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
248 'type_ids': _get_type_common,
249 'alias_model': 'project.task',
250 'privacy_visibility': 'public',
251 'alias_domain': False, # always hide alias during creation
254 # TODO: Why not using a SQL contraints ?
255 def _check_dates(self, cr, uid, ids, context=None):
256 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
257 if leave['date_start'] and leave['date']:
258 if leave['date_start'] > leave['date']:
263 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
266 def set_template(self, cr, uid, ids, context=None):
267 res = self.setActive(cr, uid, ids, value=False, context=context)
270 def set_done(self, cr, uid, ids, context=None):
271 task_obj = self.pool.get('project.task')
272 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
273 task_obj.case_close(cr, uid, task_ids, context=context)
274 self.write(cr, uid, ids, {'state':'close'}, context=context)
275 self.set_close_send_note(cr, uid, ids, context=context)
278 def set_cancel(self, cr, uid, ids, context=None):
279 task_obj = self.pool.get('project.task')
280 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
281 task_obj.case_cancel(cr, uid, task_ids, context=context)
282 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
283 self.set_cancel_send_note(cr, uid, ids, context=context)
286 def set_pending(self, cr, uid, ids, context=None):
287 self.write(cr, uid, ids, {'state':'pending'}, context=context)
288 self.set_pending_send_note(cr, uid, ids, context=context)
291 def set_open(self, cr, uid, ids, context=None):
292 self.write(cr, uid, ids, {'state':'open'}, context=context)
293 self.set_open_send_note(cr, uid, ids, context=context)
296 def reset_project(self, cr, uid, ids, context=None):
297 res = self.setActive(cr, uid, ids, value=True, context=context)
298 self.set_open_send_note(cr, uid, ids, context=context)
301 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
302 """ copy and map tasks from old to new project """
306 task_obj = self.pool.get('project.task')
307 proj = self.browse(cr, uid, old_project_id, context=context)
308 for task in proj.tasks:
309 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
310 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
311 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
314 def copy(self, cr, uid, id, default=None, context=None):
320 context['active_test'] = False
321 default['state'] = 'open'
322 default['tasks'] = []
323 default.pop('alias_name', None)
324 default.pop('alias_id', None)
325 proj = self.browse(cr, uid, id, context=context)
326 if not default.get('name', False):
327 default.update(name=_("%s (copy)") % (proj.name))
328 res = super(project, self).copy(cr, uid, id, default, context)
329 self.map_tasks(cr,uid,id,res,context)
332 def duplicate_template(self, cr, uid, ids, context=None):
335 data_obj = self.pool.get('ir.model.data')
337 for proj in self.browse(cr, uid, ids, context=context):
338 parent_id = context.get('parent_id', False)
339 context.update({'analytic_project_copy': True})
340 new_date_start = time.strftime('%Y-%m-%d')
342 if proj.date_start and proj.date:
343 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
344 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
345 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
346 context.update({'copy':True})
347 new_id = self.copy(cr, uid, proj.id, default = {
348 'name':_("%s (copy)") % (proj.name),
350 'date_start':new_date_start,
352 'parent_id':parent_id}, context=context)
353 result.append(new_id)
355 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
356 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
358 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
360 if result and len(result):
362 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
363 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
364 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
365 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
366 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
367 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
369 'name': _('Projects'),
371 'view_mode': 'form,tree',
372 'res_model': 'project.project',
375 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
376 'type': 'ir.actions.act_window',
377 'search_view_id': search_view['res_id'],
381 # set active value for a project, its sub projects and its tasks
382 def setActive(self, cr, uid, ids, value=True, context=None):
383 task_obj = self.pool.get('project.task')
384 for proj in self.browse(cr, uid, ids, context=None):
385 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
386 cr.execute('select id from project_task where project_id=%s', (proj.id,))
387 tasks_id = [x[0] for x in cr.fetchall()]
389 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
390 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
392 self.setActive(cr, uid, child_ids, value, context=None)
395 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
396 context = context or {}
397 if type(ids) in (long, int,):
399 projects = self.browse(cr, uid, ids, context=context)
401 for project in projects:
402 if (not project.members) and force_members:
403 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
405 resource_pool = self.pool.get('resource.resource')
407 result = "from openerp.addons.resource.faces import *\n"
408 result += "import datetime\n"
409 for project in self.browse(cr, uid, ids, context=context):
410 u_ids = [i.id for i in project.members]
411 if project.user_id and (project.user_id.id not in u_ids):
412 u_ids.append(project.user_id.id)
413 for task in project.tasks:
414 if task.state in ('done','cancelled'):
416 if task.user_id and (task.user_id.id not in u_ids):
417 u_ids.append(task.user_id.id)
418 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
419 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
420 for key, vals in resource_objs.items():
422 class User_%s(Resource):
424 ''' % (key, vals.get('efficiency', False))
431 def _schedule_project(self, cr, uid, project, context=None):
432 if not project.date_start:
433 raise osv.except_osv(_('Warning !'),_("You must assign Start Date on the Project '%s' !") % (project.name,))
434 resource_pool = self.pool.get('resource.resource')
435 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
436 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
437 # TODO: check if we need working_..., default values are ok.
438 puids = [x.id for x in project.members]
440 puids.append(project.user_id.id)
448 project.date_start, working_days,
449 '|'.join(['User_'+str(x) for x in puids])
451 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
458 #TODO: DO Resource allocation and compute availability
459 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
465 def schedule_tasks(self, cr, uid, ids, context=None):
466 context = context or {}
467 if type(ids) in (long, int,):
469 projects = self.browse(cr, uid, ids, context=context)
470 result = self._schedule_header(cr, uid, ids, False, context=context)
471 for project in projects:
472 result += self._schedule_project(cr, uid, project, context=context)
473 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
476 exec result in local_dict
477 projects_gantt = Task.BalancedProject(local_dict['Project'])
479 for project in projects:
480 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
481 for task in project.tasks:
482 if task.state in ('done','cancelled'):
485 p = getattr(project_gantt, 'Task_%d' % (task.id,))
487 self.pool.get('project.task').write(cr, uid, [task.id], {
488 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
489 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
491 if (not task.user_id) and (p.booked_resource):
492 self.pool.get('project.task').write(cr, uid, [task.id], {
493 'user_id': int(p.booked_resource[0].name[5:]),
497 # ------------------------------------------------
498 # OpenChatter methods and notifications
499 # ------------------------------------------------
501 def create(self, cr, uid, vals, context=None):
502 if context is None: context = {}
503 # Prevent double project creation when 'use_tasks' is checked!
504 context = dict(context, project_creation_in_progress=True)
505 mail_alias = self.pool.get('mail.alias')
506 if not vals.get('alias_id'):
507 vals.pop('alias_name', None) # prevent errors during copy()
508 alias_id = mail_alias.create_unique_alias(cr, uid,
509 # Using '+' allows using subaddressing for those who don't
510 # have a catchall domain setup.
511 {'alias_name': "project+"+short_name(vals['name'])},
512 model_name=vals.get('alias_model', 'project.task'),
514 vals['alias_id'] = alias_id
515 vals['type'] = 'contract'
516 project_id = super(project, self).create(cr, uid, vals, context)
517 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
518 self.create_send_note(cr, uid, [project_id], context=context)
521 def create_send_note(self, cr, uid, ids, context=None):
522 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
524 def set_open_send_note(self, cr, uid, ids, context=None):
525 return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
527 def set_pending_send_note(self, cr, uid, ids, context=None):
528 return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
530 def set_cancel_send_note(self, cr, uid, ids, context=None):
531 return self.message_post(cr, uid, ids, body=_("Project has been <b>canceled</b>."), context=context)
533 def set_close_send_note(self, cr, uid, ids, context=None):
534 return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), context=context)
536 def write(self, cr, uid, ids, vals, context=None):
537 # if alias_model has been changed, update alias_model_id accordingly
538 if vals.get('alias_model'):
539 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
540 vals.update(alias_model_id=model_ids[0])
541 return super(project, self).write(cr, uid, ids, vals, context=context)
543 class task(base_stage, osv.osv):
544 _name = "project.task"
545 _description = "Task"
546 _date_name = "date_start"
547 _inherit = ['mail.thread', 'ir.needaction_mixin']
549 def _get_default_project_id(self, cr, uid, context=None):
550 """ Gives default section by checking if present in the context """
551 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
553 def _get_default_stage_id(self, cr, uid, context=None):
554 """ Gives default stage_id """
555 project_id = self._get_default_project_id(cr, uid, context=context)
556 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
558 def _resolve_project_id_from_context(self, cr, uid, context=None):
559 """ Returns ID of project based on the value of 'default_project_id'
560 context key, or None if it cannot be resolved to a single
563 if context is None: context = {}
564 if type(context.get('default_project_id')) in (int, long):
565 return context['default_project_id']
566 if isinstance(context.get('default_project_id'), basestring):
567 project_name = context['default_project_id']
568 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
569 if len(project_ids) == 1:
570 return project_ids[0][0]
573 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
574 stage_obj = self.pool.get('project.task.type')
575 order = stage_obj._order
576 access_rights_uid = access_rights_uid or uid
577 # lame way to allow reverting search, should just work in the trivial case
578 if read_group_order == 'stage_id desc':
579 order = '%s desc' % order
580 # retrieve section_id from the context and write the domain
581 # - ('id', 'in', 'ids'): add columns that should be present
582 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
583 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
585 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
587 search_domain += ['|', ('project_ids', '=', project_id)]
588 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
589 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
590 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
591 # restore order of the search
592 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
595 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
596 fold[stage.id] = stage.fold or False
599 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
600 res_users = self.pool.get('res.users')
601 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
602 access_rights_uid = access_rights_uid or uid
604 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
605 order = res_users._order
606 # lame way to allow reverting search, should just work in the trivial case
607 if read_group_order == 'user_id desc':
608 order = '%s desc' % order
609 # de-duplicate and apply search order
610 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
611 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
612 # restore order of the search
613 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
617 'stage_id': _read_group_stage_ids,
618 'user_id': _read_group_user_id,
621 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
622 obj_project = self.pool.get('project.project')
624 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
625 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
626 if id and isinstance(id, (long, int)):
627 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
628 args.append(('active', '=', False))
629 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
631 def _str_get(self, task, level=0, border='***', context=None):
632 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'+ \
633 border[0]+' '+(task.name or '')+'\n'+ \
634 (task.description or '')+'\n\n'
636 # Compute: effective_hours, total_hours, progress
637 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
639 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
640 hours = dict(cr.fetchall())
641 for task in self.browse(cr, uid, ids, context=context):
642 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)}
643 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
644 res[task.id]['progress'] = 0.0
645 if (task.remaining_hours + hours.get(task.id, 0.0)):
646 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
647 if task.state in ('done','cancelled'):
648 res[task.id]['progress'] = 100.0
651 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
652 if remaining and not planned:
653 return {'value':{'planned_hours': remaining}}
656 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
657 return {'value':{'remaining_hours': planned - effective}}
659 def onchange_project(self, cr, uid, id, project_id):
662 data = self.pool.get('project.project').browse(cr, uid, [project_id])
663 partner_id=data and data[0].partner_id
665 return {'value':{'partner_id':partner_id.id}}
668 def duplicate_task(self, cr, uid, map_ids, context=None):
669 for new in map_ids.values():
670 task = self.browse(cr, uid, new, context)
671 child_ids = [ ch.id for ch in task.child_ids]
673 for child in task.child_ids:
674 if child.id in map_ids.keys():
675 child_ids.remove(child.id)
676 child_ids.append(map_ids[child.id])
678 parent_ids = [ ch.id for ch in task.parent_ids]
680 for parent in task.parent_ids:
681 if parent.id in map_ids.keys():
682 parent_ids.remove(parent.id)
683 parent_ids.append(map_ids[parent.id])
684 #FIXME why there is already the copy and the old one
685 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
687 def copy_data(self, cr, uid, id, default=None, context=None):
690 default = default or {}
691 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
692 if not default.get('remaining_hours', False):
693 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
694 default['active'] = True
695 if not default.get('name', False):
696 default['name'] = self.browse(cr, uid, id, context=context).name or ''
697 if not context.get('copy',False):
698 new_name = _("%s (copy)") % (default.get('name', ''))
699 default.update({'name':new_name})
700 return super(task, self).copy_data(cr, uid, id, default, context)
702 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
704 for task in self.browse(cr, uid, ids, context=context):
707 if task.project_id.active == False or task.project_id.state == 'template':
711 def _get_task(self, cr, uid, ids, context=None):
713 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
714 if work.task_id: result[work.task_id.id] = True
718 '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."),
719 'name': fields.char('Task Summary', size=128, required=True, select=True),
720 'description': fields.text('Description'),
721 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
722 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
723 'stage_id': fields.many2one('project.task.type', 'Stage',
724 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
725 'state': fields.related('stage_id', 'state', type="selection", store=True,
726 selection=_TASK_STATE, string="Status", readonly=True,
727 help='The status is set to \'Draft\', when a case is created.\
728 If the case is in progress the status is set to \'Open\'.\
729 When the case is over, the status is set to \'Done\'.\
730 If the case needs to be reviewed then the status is \
731 set to \'Pending\'.'),
732 'categ_ids': fields.many2many('project.category', string='Tags'),
733 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
734 help="A task's kanban state indicates special situations affecting it:\n"
735 " * Normal is the default situation\n"
736 " * Blocked indicates something is preventing the progress of this task\n"
737 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
738 readonly=True, required=False),
739 'create_date': fields.datetime('Create Date', readonly=True,select=True),
740 'date_start': fields.datetime('Starting Date',select=True),
741 'date_end': fields.datetime('Ending Date',select=True),
742 'date_deadline': fields.date('Deadline',select=True),
743 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
744 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
745 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
746 'notes': fields.text('Notes'),
747 '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.'),
748 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
750 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
751 'project.task.work': (_get_task, ['hours'], 10),
753 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
754 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
756 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
757 'project.task.work': (_get_task, ['hours'], 10),
759 '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",
761 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
762 'project.task.work': (_get_task, ['hours'], 10),
764 '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.",
766 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
767 'project.task.work': (_get_task, ['hours'], 10),
769 'user_id': fields.many2one('res.users', 'Assigned to'),
770 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
771 'partner_id': fields.many2one('res.partner', 'Customer'),
772 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
773 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
774 'company_id': fields.many2one('res.company', 'Company'),
775 'id': fields.integer('ID', readonly=True),
776 'color': fields.integer('Color Index'),
777 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
780 'stage_id': _get_default_stage_id,
781 '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),
790 _order = "priority, sequence, date_start, name, id"
792 def set_priority(self, cr, uid, ids, priority, *args):
795 return self.write(cr, uid, ids, {'priority' : priority})
797 def set_high_priority(self, cr, uid, ids, *args):
798 """Set task priority to high
800 return self.set_priority(cr, uid, ids, '1')
802 def set_normal_priority(self, cr, uid, ids, *args):
803 """Set task priority to normal
805 return self.set_priority(cr, uid, ids, '2')
807 def _check_recursion(self, cr, uid, ids, context=None):
809 visited_branch = set()
811 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
817 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
818 if id in visited_branch: #Cycle
821 if id in visited_node: #Already tested don't work one more time for nothing
824 visited_branch.add(id)
827 #visit child using DFS
828 task = self.browse(cr, uid, id, context=context)
829 for child in task.child_ids:
830 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
834 visited_branch.remove(id)
837 def _check_dates(self, cr, uid, ids, context=None):
840 obj_task = self.browse(cr, uid, ids[0], context=context)
841 start = obj_task.date_start or False
842 end = obj_task.date_end or False
849 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
850 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
853 # Override view according to the company definition
854 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
855 users_obj = self.pool.get('res.users')
856 if context is None: context = {}
857 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
858 # this should be safe (no context passed to avoid side-effects)
859 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
860 tm = obj_tm and obj_tm.name or 'Hours'
862 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
864 if tm in ['Hours','Hour']:
867 eview = etree.fromstring(res['arch'])
869 def _check_rec(eview):
870 if eview.attrib.get('widget','') == 'float_time':
871 eview.set('widget','float')
878 res['arch'] = etree.tostring(eview)
880 for f in res['fields']:
881 if 'Hours' in res['fields'][f]['string']:
882 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
885 # ----------------------------------------
887 # ----------------------------------------
889 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
890 """ Override of the base.stage method
891 Parameter of the stage search taken from the lead:
892 - section_id: if set, stages must belong to this section or
893 be a default stage; if not set, stages must be default
896 if isinstance(cases, (int, long)):
897 cases = self.browse(cr, uid, cases, context=context)
898 # collect all section_ids
901 section_ids.append(section_id)
904 section_ids.append(task.project_id.id)
905 # OR all section_ids and OR with case_default
908 search_domain += [('|')] * len(section_ids)
909 for section_id in section_ids:
910 search_domain.append(('project_ids', '=', section_id))
911 search_domain.append(('case_default', '=', True))
912 # AND with the domain in parameter
913 search_domain += list(domain)
914 # perform search, return the first found
915 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
920 def _check_child_task(self, cr, uid, ids, context=None):
923 tasks = self.browse(cr, uid, ids, context=context)
926 for child in task.child_ids:
927 if child.state in ['draft', 'open', 'pending']:
928 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
931 def action_close(self, cr, uid, ids, context=None):
932 """ This action closes the task
934 task_id = len(ids) and ids[0] or False
935 self._check_child_task(cr, uid, ids, context=context)
936 if not task_id: return False
937 return self.do_close(cr, uid, [task_id], context=context)
939 def do_close(self, cr, uid, ids, context=None):
940 """ Compatibility when changing to case_close. """
941 return self.case_close(cr, uid, ids, context=context)
943 def case_close(self, cr, uid, ids, context=None):
945 if not isinstance(ids, list): ids = [ids]
946 for task in self.browse(cr, uid, ids, context=context):
948 project = task.project_id
949 for parent_id in task.parent_ids:
950 if parent_id.state in ('pending','draft'):
952 for child in parent_id.child_ids:
953 if child.id != task.id and child.state not in ('done','cancelled'):
956 self.do_reopen(cr, uid, [parent_id.id], context=context)
958 vals['remaining_hours'] = 0.0
959 if not task.date_end:
960 vals['date_end'] = fields.datetime.now()
961 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
962 self.case_close_send_note(cr, uid, [task.id], context=context)
965 def do_reopen(self, cr, uid, ids, context=None):
966 for task in self.browse(cr, uid, ids, context=context):
967 project = task.project_id
968 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
969 self.case_open_send_note(cr, uid, [task.id], context)
972 def do_cancel(self, cr, uid, ids, context=None):
973 """ Compatibility when changing to case_cancel. """
974 return self.case_cancel(cr, uid, ids, context=context)
976 def case_cancel(self, cr, uid, ids, context=None):
977 tasks = self.browse(cr, uid, ids, context=context)
978 self._check_child_task(cr, uid, ids, context=context)
980 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
981 self.case_cancel_send_note(cr, uid, [task.id], context=context)
984 def do_open(self, cr, uid, ids, context=None):
985 """ Compatibility when changing to case_open. """
986 return self.case_open(cr, uid, ids, context=context)
988 def case_open(self, cr, uid, ids, context=None):
989 if not isinstance(ids,list): ids = [ids]
990 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
991 self.case_open_send_note(cr, uid, ids, context)
994 def do_draft(self, cr, uid, ids, context=None):
995 """ Compatibility when changing to case_draft. """
996 return self.case_draft(cr, uid, ids, context=context)
998 def case_draft(self, cr, uid, ids, context=None):
999 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1000 self.case_draft_send_note(cr, uid, ids, context=context)
1003 def do_pending(self, cr, uid, ids, context=None):
1004 """ Compatibility when changing to case_pending. """
1005 return self.case_pending(cr, uid, ids, context=context)
1007 def case_pending(self, cr, uid, ids, context=None):
1008 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1009 return self.case_pending_send_note(cr, uid, ids, context=context)
1011 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1012 attachment = self.pool.get('ir.attachment')
1013 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1014 new_attachment_ids = []
1015 for attachment_id in attachment_ids:
1016 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1017 return new_attachment_ids
1019 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1021 Delegate Task to another users.
1023 if delegate_data is None:
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])],
1034 'description': delegate_data['new_task_description'] or '',
1038 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1039 newname = delegate_data['prefix'] or ''
1041 'remaining_hours': delegate_data['planned_hours_me'],
1042 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1045 if delegate_data['state'] == 'pending':
1046 self.do_pending(cr, uid, [task.id], context=context)
1047 elif delegate_data['state'] == 'done':
1048 self.do_close(cr, uid, [task.id], context=context)
1049 self.do_delegation_send_note(cr, uid, [task.id], context)
1050 delegated_tasks[task.id] = delegated_task_id
1051 return delegated_tasks
1053 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1054 for task in self.browse(cr, uid, ids, context=context):
1055 if (task.state=='draft') or (task.planned_hours==0.0):
1056 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1057 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1060 def set_remaining_time_1(self, cr, uid, ids, context=None):
1061 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1063 def set_remaining_time_2(self, cr, uid, ids, context=None):
1064 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1066 def set_remaining_time_5(self, cr, uid, ids, context=None):
1067 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1069 def set_remaining_time_10(self, cr, uid, ids, context=None):
1070 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1072 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1073 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1076 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1077 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1080 def set_kanban_state_done(self, cr, uid, ids, context=None):
1081 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1084 def _store_history(self, cr, uid, ids, context=None):
1085 for task in self.browse(cr, uid, ids, context=context):
1086 self.pool.get('project.task.history').create(cr, uid, {
1088 'remaining_hours': task.remaining_hours,
1089 'planned_hours': task.planned_hours,
1090 'kanban_state': task.kanban_state,
1091 'type_id': task.stage_id.id,
1092 'state': task.state,
1093 'user_id': task.user_id.id
1098 def create(self, cr, uid, vals, context=None):
1099 task_id = super(task, self).create(cr, uid, vals, context=context)
1100 task_record = self.browse(cr, uid, task_id, context=context)
1101 if task_record.project_id:
1102 project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1103 self.message_subscribe(cr, uid, [task_id], project_follower_ids,
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.get('project_id'):
1115 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1116 vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1117 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1118 new_stage = vals.get('stage_id')
1119 vals_reset_kstate = dict(vals, kanban_state='normal')
1120 for t in self.browse(cr, uid, ids, context=context):
1121 #TO FIX:Kanban view doesn't raise warning
1122 #stages = [stage.id for stage in t.project_id.type_ids]
1123 #if new_stage not in stages:
1124 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1125 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1126 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1127 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1130 result = super(task,self).write(cr, uid, ids, vals, context=context)
1131 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1132 self._store_history(cr, uid, ids, context=context)
1135 def unlink(self, cr, uid, ids, context=None):
1138 self._check_child_task(cr, uid, ids, context=context)
1139 res = super(task, self).unlink(cr, uid, ids, context)
1142 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1143 context = context or {}
1147 if task.state in ('done','cancelled'):
1152 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1154 for t2 in task.parent_ids:
1155 start.append("up.Task_%s.end" % (t2.id,))
1159 ''' % (ident,','.join(start))
1164 ''' % (ident, 'User_'+str(task.user_id.id))
1169 # ---------------------------------------------------
1171 # ---------------------------------------------------
1173 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1174 """ Override to updates the document according to the email. """
1175 if custom_values is None: custom_values = {}
1176 custom_values.update({
1177 'name': msg.get('subject'),
1178 'planned_hours': 0.0,
1180 return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1182 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1183 """ Override to update the task according to the email. """
1184 if update_vals is None: update_vals = {}
1187 'cost':'planned_hours',
1189 for line in msg['body'].split('\n'):
1191 res = tools.misc.command_re.match(line)
1193 match = res.group(1).lower()
1194 field = maps.get(match)
1197 update_vals[field] = float(res.group(2).lower())
1198 except (ValueError, TypeError):
1200 elif match.lower() == 'state' \
1201 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1202 act = 'do_%s' % res.group(2).lower()
1204 getattr(self,act)(cr, uid, ids, context=context)
1205 return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1207 # ---------------------------------------------------
1208 # OpenChatter methods and notifications
1209 # ---------------------------------------------------
1211 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1212 """ Override of default prefix for notifications. """
1215 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1216 """ Returns the user_ids that have to perform an action.
1217 Add to the previous results given by super the document responsible
1219 :return: dict { record_id: [user_ids], }
1221 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1222 for obj in self.browse(cr, uid, ids, context=context):
1223 if obj.state == 'draft' and obj.user_id:
1224 result[obj.id].append(obj.user_id.id)
1227 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1228 """ Add 'user_id' and 'manager_id' to the monitored fields """
1229 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1230 return res + ['user_id', 'manager_id']
1232 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1233 """ Override of the (void) default notification method. """
1234 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1235 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1238 def create_send_note(self, cr, uid, ids, context=None):
1239 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1241 def case_draft_send_note(self, cr, uid, ids, context=None):
1242 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1244 def do_delegation_send_note(self, cr, uid, ids, context=None):
1245 for task in self.browse(cr, uid, ids, context=context):
1246 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1247 self.message_post(cr, uid, [task.id], body=msg, context=context)
1251 class project_work(osv.osv):
1252 _name = "project.task.work"
1253 _description = "Project Task Work"
1255 'name': fields.char('Work summary', size=128),
1256 'date': fields.datetime('Date', select="1"),
1257 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1258 'hours': fields.float('Time Spent'),
1259 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1260 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1264 'user_id': lambda obj, cr, uid, context: uid,
1265 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1268 _order = "date desc"
1269 def create(self, cr, uid, vals, *args, **kwargs):
1270 if 'hours' in vals and (not vals['hours']):
1271 vals['hours'] = 0.00
1272 if 'task_id' in vals:
1273 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1274 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1276 def write(self, cr, uid, ids, vals, context=None):
1277 if 'hours' in vals and (not vals['hours']):
1278 vals['hours'] = 0.00
1280 for work in self.browse(cr, uid, ids, context=context):
1281 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))
1282 return super(project_work,self).write(cr, uid, ids, vals, context)
1284 def unlink(self, cr, uid, ids, *args, **kwargs):
1285 for work in self.browse(cr, uid, ids):
1286 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1287 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1290 class account_analytic_account(osv.osv):
1291 _inherit = 'account.analytic.account'
1292 _description = 'Analytic Account'
1294 '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"),
1295 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1298 def on_change_template(self, cr, uid, ids, template_id, context=None):
1299 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1300 if template_id and 'value' in res:
1301 template = self.browse(cr, uid, template_id, context=context)
1302 res['value']['use_tasks'] = template.use_tasks
1305 def _trigger_project_creation(self, cr, uid, vals, context=None):
1307 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.
1309 if context is None: context = {}
1310 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1312 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1314 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.
1316 project_pool = self.pool.get('project.project')
1317 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1318 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1320 'name': vals.get('name'),
1321 'analytic_account_id': analytic_account_id,
1323 return project_pool.create(cr, uid, project_values, context=context)
1326 def create(self, cr, uid, vals, context=None):
1329 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1330 vals['child_ids'] = []
1331 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1332 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1333 return analytic_account_id
1335 def write(self, cr, uid, ids, vals, context=None):
1336 name = vals.get('name')
1337 for account in self.browse(cr, uid, ids, context=context):
1339 vals['name'] = account.name
1340 self.project_create(cr, uid, account.id, vals, context=context)
1341 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1343 def unlink(self, cr, uid, ids, *args, **kwargs):
1344 project_obj = self.pool.get('project.project')
1345 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1347 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1348 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1350 class project_project(osv.osv):
1351 _inherit = 'project.project'
1356 class project_task_history(osv.osv):
1358 Tasks History, used for cumulative flow charts (Lean/Agile)
1360 _name = 'project.task.history'
1361 _description = 'History of Tasks'
1362 _rec_name = 'task_id'
1365 def _get_date(self, cr, uid, ids, name, arg, context=None):
1367 for history in self.browse(cr, uid, ids, context=context):
1368 if history.state in ('done','cancelled'):
1369 result[history.id] = history.date
1371 cr.execute('''select
1374 project_task_history
1378 order by id limit 1''', (history.task_id.id, history.id))
1380 result[history.id] = res and res[0] or False
1383 def _get_related_date(self, cr, uid, ids, context=None):
1385 for history in self.browse(cr, uid, ids, context=context):
1386 cr.execute('''select
1389 project_task_history
1393 order by id desc limit 1''', (history.task_id.id, history.id))
1396 result.append(res[0])
1400 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1401 'type_id': fields.many2one('project.task.type', 'Stage'),
1402 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1403 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1404 'date': fields.date('Date', select=True),
1405 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1406 'project.task.history': (_get_related_date, None, 20)
1408 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1409 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1410 'user_id': fields.many2one('res.users', 'Responsible'),
1413 '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'
1423 'end_date': fields.date('End Date'),
1424 'project_id': fields.many2one('project.project', 'Project'),
1428 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1430 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1432 history.date::varchar||'-'||history.history_id::varchar AS id,
1433 history.date AS end_date,
1438 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1439 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1440 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1443 project_task_history AS h
1444 JOIN project_task AS t ON (h.task_id = t.id)
1450 class project_category(osv.osv):
1451 """ Category of project's task (or issue) """
1452 _name = "project.category"
1453 _description = "Category of project's task, issue, ..."
1455 'name': fields.char('Name', size=64, required=True, translate=True),