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 datetime import datetime, date
23 from lxml import etree
26 from openerp import SUPERUSER_ID
27 from openerp import tools
28 from openerp.osv import fields, osv
29 from openerp.tools.translate import _
31 from openerp.addons.base_status.base_stage import base_stage
32 from openerp.addons.resource.faces import task as Task
34 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
36 class project_task_type(osv.osv):
37 _name = 'project.task.type'
38 _description = 'Task Stage'
41 'name': fields.char('Stage Name', required=True, size=64, translate=True),
42 'description': fields.text('Description'),
43 'sequence': fields.integer('Sequence'),
44 'case_default': fields.boolean('Default for New Projects',
45 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."),
46 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
47 'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
48 help="The status of your document is automatically changed regarding the selected stage. " \
49 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
50 'fold': fields.boolean('Folded by Default',
51 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
53 def _get_default_project_id(self, cr, uid, ctx={}):
54 proj = ctx.get('default_project_id', False)
62 'case_default': False,
63 'project_ids': _get_default_project_id
68 """Keep first word(s) of name to make it small enough
70 if not name: return name
71 # keep 7 chars + end of the last word
72 keep_words = name[:7].strip().split()
73 return ' '.join(name.split()[:len(keep_words)])
75 class project(osv.osv):
76 _name = "project.project"
77 _description = "Project"
78 _inherits = {'account.analytic.account': "analytic_account_id",
79 "mail.alias": "alias_id"}
80 _inherit = ['mail.thread', 'ir.needaction_mixin']
82 def _auto_init(self, cr, context=None):
83 """ Installation hook: aliases, project.project """
84 # create aliases for all projects and avoid constraint errors
85 alias_context = dict(context, alias_model_name='project.task')
86 return self.pool.get('mail.alias').migrate_to_alias(cr, self._name, self._table, super(project, self)._auto_init,
87 self._columns['alias_id'], 'id', alias_prefix='project+', alias_defaults={'project_id':'id'}, context=alias_context)
89 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
91 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
92 if context and context.get('user_preference'):
93 cr.execute("""SELECT project.id FROM project_project project
94 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
95 LEFT JOIN project_user_rel rel ON rel.project_id = project.id
96 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
97 return [(r[0]) for r in cr.fetchall()]
98 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
99 context=context, count=count)
101 def _complete_name(self, cr, uid, ids, name, args, context=None):
103 for m in self.browse(cr, uid, ids, context=context):
104 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
107 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
108 partner_obj = self.pool.get('res.partner')
112 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
113 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
114 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
115 val['pricelist_id'] = pricelist_id
116 return {'value': val}
118 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
119 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
120 project_ids = [task.project_id.id for task in tasks if task.project_id]
121 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
123 def _get_project_and_parents(self, cr, uid, ids, context=None):
124 """ return the project ids and all their parent projects """
128 SELECT DISTINCT parent.id
129 FROM project_project project, project_project parent, account_analytic_account account
130 WHERE project.analytic_account_id = account.id
131 AND parent.analytic_account_id = account.parent_id
134 ids = [t[0] for t in cr.fetchall()]
138 def _get_project_and_children(self, cr, uid, ids, context=None):
139 """ retrieve all children projects of project ids;
140 return a dictionary mapping each project to its parent project (or None)
142 res = dict.fromkeys(ids, None)
145 SELECT project.id, parent.id
146 FROM project_project project, project_project parent, account_analytic_account account
147 WHERE project.analytic_account_id = account.id
148 AND parent.analytic_account_id = account.parent_id
151 dic = dict(cr.fetchall())
156 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
157 child_parent = self._get_project_and_children(cr, uid, ids, context)
158 # compute planned_hours, total_hours, effective_hours specific to each project
160 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
161 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
162 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
164 """, (tuple(child_parent.keys()),))
165 # aggregate results into res
166 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
167 for id, planned, total, effective in cr.fetchall():
168 # add the values specific to id to all parent projects of id in the result
171 res[id]['planned_hours'] += planned
172 res[id]['total_hours'] += total
173 res[id]['effective_hours'] += effective
174 id = child_parent[id]
175 # compute progress rates
177 if res[id]['total_hours']:
178 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
180 res[id]['progress_rate'] = 0.0
183 def unlink(self, cr, uid, ids, context=None):
185 mail_alias = self.pool.get('mail.alias')
186 for proj in self.browse(cr, uid, ids, context=context):
188 raise osv.except_osv(_('Invalid Action!'),
189 _('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.'))
191 alias_ids.append(proj.alias_id.id)
192 res = super(project, self).unlink(cr, uid, ids, context=context)
193 mail_alias.unlink(cr, uid, alias_ids, context=context)
196 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
198 attachment = self.pool.get('ir.attachment')
199 task = self.pool.get('project.task')
201 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
202 task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
203 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
204 res[id] = (project_attachments or 0) + (task_attachments or 0)
207 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
210 res = dict.fromkeys(ids, 0)
212 ctx['active_test'] = False
213 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)], context=ctx)
214 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
215 res[task.project_id.id] += 1
218 def _get_alias_models(self, cr, uid, context=None):
219 """Overriden in project_issue to offer more options"""
220 return [('project.task', "Tasks")]
222 def _get_visibility_selection(self, cr, uid, context=None):
223 """ Overriden in portal_project to offer more options """
224 return [('public', 'All Users'),
225 ('employees', 'Employees Only'),
226 ('followers', 'Followers Only')]
228 def attachment_tree_view(self, cr, uid, ids, context):
229 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
232 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
233 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)
235 res_id = ids and ids[0] or False
237 'name': _('Attachments'),
239 'res_model': 'ir.attachment',
240 'type': 'ir.actions.act_window',
242 'view_mode': 'tree,form',
245 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
247 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
248 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
249 _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs)
252 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
253 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
254 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
255 '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),
256 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
257 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
258 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)]}),
259 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
260 '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.",
262 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
263 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
265 '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.",
267 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
268 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
270 '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.",
272 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
273 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
275 '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.",
277 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
278 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
280 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
281 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
282 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
283 'color': fields.integer('Color Index'),
284 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="restrict", required=True,
285 help="Internal email associated with this project. Incoming emails are automatically synchronized"
286 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
287 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
288 help="The kind of document created when an email is received on this project's email alias"),
289 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy / Visibility', required=True),
290 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
291 'doc_count': fields.function(
292 _get_attached_docs, string="Number of documents attached", type='integer'
296 def _get_type_common(self, cr, uid, context):
297 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
300 _order = "sequence, id"
307 'type_ids': _get_type_common,
308 'alias_model': 'project.task',
309 'privacy_visibility': 'employees',
310 'alias_domain': False, # always hide alias during creation
313 # TODO: Why not using a SQL contraints ?
314 def _check_dates(self, cr, uid, ids, context=None):
315 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
316 if leave['date_start'] and leave['date']:
317 if leave['date_start'] > leave['date']:
322 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
325 def set_template(self, cr, uid, ids, context=None):
326 res = self.setActive(cr, uid, ids, value=False, context=context)
329 def set_done(self, cr, uid, ids, context=None):
330 task_obj = self.pool.get('project.task')
331 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
332 task_obj.case_close(cr, uid, task_ids, context=context)
333 return self.write(cr, uid, ids, {'state':'close'}, context=context)
335 def set_cancel(self, cr, uid, ids, context=None):
336 task_obj = self.pool.get('project.task')
337 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
338 task_obj.case_cancel(cr, uid, task_ids, context=context)
339 return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
341 def set_pending(self, cr, uid, ids, context=None):
342 return self.write(cr, uid, ids, {'state':'pending'}, context=context)
344 def set_open(self, cr, uid, ids, context=None):
345 return self.write(cr, uid, ids, {'state':'open'}, context=context)
347 def reset_project(self, cr, uid, ids, context=None):
348 return self.setActive(cr, uid, ids, value=True, context=context)
350 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
351 """ copy and map tasks from old to new project """
355 task_obj = self.pool.get('project.task')
356 proj = self.browse(cr, uid, old_project_id, context=context)
357 for task in proj.tasks:
358 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
359 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
360 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
363 def copy(self, cr, uid, id, default=None, context=None):
369 context['active_test'] = False
370 default['state'] = 'open'
371 default['line_ids'] = []
372 default['tasks'] = []
374 # Don't prepare (expensive) data to copy children (analytic accounts),
375 # they are discarded in analytic.copy(), and handled in duplicate_template()
376 default['child_ids'] = []
378 default.pop('alias_name', None)
379 default.pop('alias_id', None)
380 proj = self.browse(cr, uid, id, context=context)
381 if not default.get('name', False):
382 default.update(name=_("%s (copy)") % (proj.name))
383 res = super(project, self).copy(cr, uid, id, default, context)
384 self.map_tasks(cr,uid,id,res,context)
387 def duplicate_template(self, cr, uid, ids, context=None):
390 data_obj = self.pool.get('ir.model.data')
392 for proj in self.browse(cr, uid, ids, context=context):
393 parent_id = context.get('parent_id', False)
394 context.update({'analytic_project_copy': True})
395 new_date_start = time.strftime('%Y-%m-%d')
397 if proj.date_start and proj.date:
398 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
399 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
400 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
401 context.update({'copy':True})
402 new_id = self.copy(cr, uid, proj.id, default = {
403 'name':_("%s (copy)") % (proj.name),
405 'date_start':new_date_start,
407 'parent_id':parent_id}, context=context)
408 result.append(new_id)
410 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
411 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
413 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
415 if result and len(result):
417 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
418 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
419 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
420 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
421 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
422 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
424 'name': _('Projects'),
426 'view_mode': 'form,tree',
427 'res_model': 'project.project',
430 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
431 'type': 'ir.actions.act_window',
432 'search_view_id': search_view['res_id'],
436 # set active value for a project, its sub projects and its tasks
437 def setActive(self, cr, uid, ids, value=True, context=None):
438 task_obj = self.pool.get('project.task')
439 for proj in self.browse(cr, uid, ids, context=None):
440 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
441 cr.execute('select id from project_task where project_id=%s', (proj.id,))
442 tasks_id = [x[0] for x in cr.fetchall()]
444 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
445 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
447 self.setActive(cr, uid, child_ids, value, context=None)
450 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
451 context = context or {}
452 if type(ids) in (long, int,):
454 projects = self.browse(cr, uid, ids, context=context)
456 for project in projects:
457 if (not project.members) and force_members:
458 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
460 resource_pool = self.pool.get('resource.resource')
462 result = "from openerp.addons.resource.faces import *\n"
463 result += "import datetime\n"
464 for project in self.browse(cr, uid, ids, context=context):
465 u_ids = [i.id for i in project.members]
466 if project.user_id and (project.user_id.id not in u_ids):
467 u_ids.append(project.user_id.id)
468 for task in project.tasks:
469 if task.state in ('done','cancelled'):
471 if task.user_id and (task.user_id.id not in u_ids):
472 u_ids.append(task.user_id.id)
473 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
474 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
475 for key, vals in resource_objs.items():
477 class User_%s(Resource):
479 ''' % (key, vals.get('efficiency', False))
486 def _schedule_project(self, cr, uid, project, context=None):
487 resource_pool = self.pool.get('resource.resource')
488 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
489 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
490 # TODO: check if we need working_..., default values are ok.
491 puids = [x.id for x in project.members]
493 puids.append(project.user_id.id)
501 project.date_start or time.strftime('%Y-%m-%d'), working_days,
502 '|'.join(['User_'+str(x) for x in puids]) or 'None'
504 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
511 #TODO: DO Resource allocation and compute availability
512 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
518 def schedule_tasks(self, cr, uid, ids, context=None):
519 context = context or {}
520 if type(ids) in (long, int,):
522 projects = self.browse(cr, uid, ids, context=context)
523 result = self._schedule_header(cr, uid, ids, False, context=context)
524 for project in projects:
525 result += self._schedule_project(cr, uid, project, context=context)
526 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
529 exec result in local_dict
530 projects_gantt = Task.BalancedProject(local_dict['Project'])
532 for project in projects:
533 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
534 for task in project.tasks:
535 if task.state in ('done','cancelled'):
538 p = getattr(project_gantt, 'Task_%d' % (task.id,))
540 self.pool.get('project.task').write(cr, uid, [task.id], {
541 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
542 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
544 if (not task.user_id) and (p.booked_resource):
545 self.pool.get('project.task').write(cr, uid, [task.id], {
546 'user_id': int(p.booked_resource[0].name[5:]),
550 # ------------------------------------------------
551 # OpenChatter methods and notifications
552 # ------------------------------------------------
554 def create(self, cr, uid, vals, context=None):
555 if context is None: context = {}
556 # Prevent double project creation when 'use_tasks' is checked!
557 context = dict(context, project_creation_in_progress=True)
558 mail_alias = self.pool.get('mail.alias')
559 if not vals.get('alias_id') and vals.get('name', False):
560 vals.pop('alias_name', None) # prevent errors during copy()
561 alias_id = mail_alias.create_unique_alias(cr, uid,
562 # Using '+' allows using subaddressing for those who don't
563 # have a catchall domain setup.
564 {'alias_name': "project+"+short_name(vals['name'])},
565 model_name=vals.get('alias_model', 'project.task'),
567 vals['alias_id'] = alias_id
568 if vals.get('type', False) not in ('template','contract'):
569 vals['type'] = 'contract'
570 project_id = super(project, self).create(cr, uid, vals, context)
571 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
574 def write(self, cr, uid, ids, vals, context=None):
575 # if alias_model has been changed, update alias_model_id accordingly
576 if vals.get('alias_model'):
577 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
578 vals.update(alias_model_id=model_ids[0])
579 return super(project, self).write(cr, uid, ids, vals, context=context)
581 class task(base_stage, osv.osv):
582 _name = "project.task"
583 _description = "Task"
584 _date_name = "date_start"
585 _inherit = ['mail.thread', 'ir.needaction_mixin']
589 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
590 'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
591 'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
594 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'done', 'open'],
596 'kanban_state': { # kanban state: tracked, but only block subtype
597 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
601 def _get_default_partner(self, cr, uid, context=None):
602 """ Override of base_stage to add project specific behavior """
603 project_id = self._get_default_project_id(cr, uid, context)
605 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
606 if project and project.partner_id:
607 return project.partner_id.id
608 return super(task, self)._get_default_partner(cr, uid, context=context)
610 def _get_default_project_id(self, cr, uid, context=None):
611 """ Gives default section by checking if present in the context """
612 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
614 def _get_default_stage_id(self, cr, uid, context=None):
615 """ Gives default stage_id """
616 project_id = self._get_default_project_id(cr, uid, context=context)
617 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
619 def _resolve_project_id_from_context(self, cr, uid, context=None):
620 """ Returns ID of project based on the value of 'default_project_id'
621 context key, or None if it cannot be resolved to a single
626 if type(context.get('default_project_id')) in (int, long):
627 return context['default_project_id']
628 if isinstance(context.get('default_project_id'), basestring):
629 project_name = context['default_project_id']
630 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
631 if len(project_ids) == 1:
632 return project_ids[0][0]
635 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
636 stage_obj = self.pool.get('project.task.type')
637 order = stage_obj._order
638 access_rights_uid = access_rights_uid or uid
639 if read_group_order == 'stage_id desc':
640 order = '%s desc' % order
642 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
644 search_domain += ['|', ('project_ids', '=', project_id)]
645 search_domain += [('id', 'in', ids)]
646 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
647 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
648 # restore order of the search
649 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
652 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
653 fold[stage.id] = stage.fold or False
656 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
657 res_users = self.pool.get('res.users')
658 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
659 access_rights_uid = access_rights_uid or uid
661 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
662 order = res_users._order
663 # lame way to allow reverting search, should just work in the trivial case
664 if read_group_order == 'user_id desc':
665 order = '%s desc' % order
666 # de-duplicate and apply search order
667 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
668 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
669 # restore order of the search
670 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
674 'stage_id': _read_group_stage_ids,
675 'user_id': _read_group_user_id,
678 def _str_get(self, task, level=0, border='***', context=None):
679 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'+ \
680 border[0]+' '+(task.name or '')+'\n'+ \
681 (task.description or '')+'\n\n'
683 # Compute: effective_hours, total_hours, progress
684 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
686 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
687 hours = dict(cr.fetchall())
688 for task in self.browse(cr, uid, ids, context=context):
689 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)}
690 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
691 res[task.id]['progress'] = 0.0
692 if (task.remaining_hours + hours.get(task.id, 0.0)):
693 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
694 if task.state in ('done','cancelled'):
695 res[task.id]['progress'] = 100.0
698 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
699 if remaining and not planned:
700 return {'value':{'planned_hours': remaining}}
703 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
704 return {'value':{'remaining_hours': planned - effective}}
706 def onchange_project(self, cr, uid, id, project_id, context=None):
708 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
709 if project and project.partner_id:
710 return {'value': {'partner_id': project.partner_id.id}}
713 def duplicate_task(self, cr, uid, map_ids, context=None):
714 mapper = lambda t: map_ids.get(t.id, t.id)
715 for task in self.browse(cr, uid, map_ids.values(), context):
716 new_child_ids = set(map(mapper, task.child_ids))
717 new_parent_ids = set(map(mapper, task.parent_ids))
718 if new_child_ids or new_parent_ids:
719 task.write({'parent_ids': [(6,0,list(new_parent_ids))],
720 'child_ids': [(6,0,list(new_child_ids))]})
722 def copy_data(self, cr, uid, id, default=None, context=None):
725 default = default or {}
726 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
727 if not default.get('remaining_hours', False):
728 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
729 default['active'] = True
730 if not default.get('name', False):
731 default['name'] = self.browse(cr, uid, id, context=context).name or ''
732 if not context.get('copy',False):
733 new_name = _("%s (copy)") % (default.get('name', ''))
734 default.update({'name':new_name})
735 return super(task, self).copy_data(cr, uid, id, default, context)
737 def copy(self, cr, uid, id, default=None, context=None):
742 if not context.get('copy', False):
743 stage = self._get_default_stage_id(cr, uid, context=context)
745 default['stage_id'] = stage
746 return super(task, self).copy(cr, uid, id, default, context)
748 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
750 for task in self.browse(cr, uid, ids, context=context):
753 if task.project_id.active == False or task.project_id.state == 'template':
757 def _get_task(self, cr, uid, ids, context=None):
759 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
760 if work.task_id: result[work.task_id.id] = True
764 '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."),
765 'name': fields.char('Task Summary', size=128, required=True, select=True),
766 'description': fields.text('Description'),
767 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
768 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
769 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange', select=True,
770 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
771 'state': fields.related('stage_id', 'state', type="selection", store=True,
772 selection=_TASK_STATE, string="Status", readonly=True, select=True,
773 help='The status is set to \'Draft\', when a case is created.\
774 If the case is in progress the status is set to \'Open\'.\
775 When the case is over, the status is set to \'Done\'.\
776 If the case needs to be reviewed then the status is \
777 set to \'Pending\'.'),
778 'categ_ids': fields.many2many('project.category', string='Tags'),
779 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
780 track_visibility='onchange',
781 help="A task's kanban state indicates special situations affecting it:\n"
782 " * Normal is the default situation\n"
783 " * Blocked indicates something is preventing the progress of this task\n"
784 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
785 readonly=True, required=False),
786 'create_date': fields.datetime('Create Date', readonly=True, select=True),
787 'write_date': fields.datetime('Last Modification Date', readonly=True, select=True), #not displayed in the view but it might be useful with base_action_rule module (and it needs to be defined first for that)
788 'date_start': fields.datetime('Starting Date',select=True),
789 'date_end': fields.datetime('Ending Date',select=True),
790 'date_deadline': fields.date('Deadline',select=True),
791 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select=True, track_visibility='onchange'),
792 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
793 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
794 'notes': fields.text('Notes'),
795 '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.'),
796 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
798 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
799 'project.task.work': (_get_task, ['hours'], 10),
801 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
802 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
804 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
805 'project.task.work': (_get_task, ['hours'], 10),
807 '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",
809 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours', 'state', 'stage_id'], 10),
810 'project.task.work': (_get_task, ['hours'], 10),
812 '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.",
814 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
815 'project.task.work': (_get_task, ['hours'], 10),
817 'user_id': fields.many2one('res.users', 'Assigned to', select=True, track_visibility='onchange'),
818 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
819 'partner_id': fields.many2one('res.partner', 'Customer'),
820 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
821 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
822 'company_id': fields.many2one('res.company', 'Company'),
823 'id': fields.integer('ID', readonly=True),
824 'color': fields.integer('Color Index'),
825 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
828 'stage_id': _get_default_stage_id,
829 'project_id': _get_default_project_id,
830 'kanban_state': 'normal',
835 'user_id': lambda obj, cr, uid, ctx=None: uid,
836 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
837 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
839 _order = "priority, sequence, date_start, name, id"
841 def set_high_priority(self, cr, uid, ids, *args):
842 """Set task priority to high
844 return self.write(cr, uid, ids, {'priority' : '0'})
846 def set_normal_priority(self, cr, uid, ids, *args):
847 """Set task priority to normal
849 return self.write(cr, uid, ids, {'priority' : '2'})
851 def _check_recursion(self, cr, uid, ids, context=None):
853 visited_branch = set()
855 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
861 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
862 if id in visited_branch: #Cycle
865 if id in visited_node: #Already tested don't work one more time for nothing
868 visited_branch.add(id)
871 #visit child using DFS
872 task = self.browse(cr, uid, id, context=context)
873 for child in task.child_ids:
874 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
878 visited_branch.remove(id)
881 def _check_dates(self, cr, uid, ids, context=None):
884 obj_task = self.browse(cr, uid, ids[0], context=context)
885 start = obj_task.date_start or False
886 end = obj_task.date_end or False
893 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
894 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
897 # Override view according to the company definition
898 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
899 users_obj = self.pool.get('res.users')
900 if context is None: context = {}
901 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
902 # this should be safe (no context passed to avoid side-effects)
903 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
904 tm = obj_tm and obj_tm.name or 'Hours'
906 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
908 if tm in ['Hours','Hour']:
911 eview = etree.fromstring(res['arch'])
913 def _check_rec(eview):
914 if eview.attrib.get('widget','') == 'float_time':
915 eview.set('widget','float')
922 res['arch'] = etree.tostring(eview)
924 for f in res['fields']:
925 if 'Hours' in res['fields'][f]['string']:
926 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
929 # ----------------------------------------
931 # ----------------------------------------
933 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
934 """ Override of the base.stage method
935 Parameter of the stage search taken from the lead:
936 - section_id: if set, stages must belong to this section or
937 be a default stage; if not set, stages must be default
940 if isinstance(cases, (int, long)):
941 cases = self.browse(cr, uid, cases, context=context)
942 # collect all section_ids
945 section_ids.append(section_id)
948 section_ids.append(task.project_id.id)
951 search_domain = [('|')] * (len(section_ids)-1)
952 for section_id in section_ids:
953 search_domain.append(('project_ids', '=', section_id))
954 search_domain += list(domain)
955 # perform search, return the first found
956 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
961 def _check_child_task(self, cr, uid, ids, context=None):
964 tasks = self.browse(cr, uid, ids, context=context)
967 for child in task.child_ids:
968 if child.state in ['draft', 'open', 'pending']:
969 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
972 def action_close(self, cr, uid, ids, context=None):
973 """ This action closes the task
975 task_id = len(ids) and ids[0] or False
976 self._check_child_task(cr, uid, ids, context=context)
977 if not task_id: return False
978 return self.do_close(cr, uid, [task_id], context=context)
980 def do_close(self, cr, uid, ids, context=None):
981 """ Compatibility when changing to case_close. """
982 return self.case_close(cr, uid, ids, context=context)
984 def case_close(self, cr, uid, ids, context=None):
986 if not isinstance(ids, list): ids = [ids]
987 for task in self.browse(cr, uid, ids, context=context):
989 project = task.project_id
990 for parent_id in task.parent_ids:
991 if parent_id.state in ('pending','draft'):
993 for child in parent_id.child_ids:
994 if child.id != task.id and child.state not in ('done','cancelled'):
997 self.do_reopen(cr, uid, [parent_id.id], context=context)
999 vals['remaining_hours'] = 0.0
1000 if not task.date_end:
1001 vals['date_end'] = fields.datetime.now()
1002 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
1005 def do_reopen(self, cr, uid, ids, context=None):
1006 for task in self.browse(cr, uid, ids, context=context):
1007 project = task.project_id
1008 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1011 def do_cancel(self, cr, uid, ids, context=None):
1012 """ Compatibility when changing to case_cancel. """
1013 return self.case_cancel(cr, uid, ids, context=context)
1015 def case_cancel(self, cr, uid, ids, context=None):
1016 tasks = self.browse(cr, uid, ids, context=context)
1017 self._check_child_task(cr, uid, ids, context=context)
1019 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1022 def do_open(self, cr, uid, ids, context=None):
1023 """ Compatibility when changing to case_open. """
1024 return self.case_open(cr, uid, ids, context=context)
1026 def case_open(self, cr, uid, ids, context=None):
1027 if not isinstance(ids,list): ids = [ids]
1028 return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1030 def do_draft(self, cr, uid, ids, context=None):
1031 """ Compatibility when changing to case_draft. """
1032 return self.case_draft(cr, uid, ids, context=context)
1034 def case_draft(self, cr, uid, ids, context=None):
1035 return self.case_set(cr, uid, ids, 'draft', {}, context=context)
1037 def do_pending(self, cr, uid, ids, context=None):
1038 """ Compatibility when changing to case_pending. """
1039 return self.case_pending(cr, uid, ids, context=context)
1041 def case_pending(self, cr, uid, ids, context=None):
1042 return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1044 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1045 attachment = self.pool.get('ir.attachment')
1046 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1047 new_attachment_ids = []
1048 for attachment_id in attachment_ids:
1049 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1050 return new_attachment_ids
1052 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1054 Delegate Task to another users.
1056 if delegate_data is None:
1058 assert delegate_data['user_id'], _("Delegated User should be specified")
1059 delegated_tasks = {}
1060 for task in self.browse(cr, uid, ids, context=context):
1061 delegated_task_id = self.copy(cr, uid, task.id, {
1062 'name': delegate_data['name'],
1063 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1064 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1065 'planned_hours': delegate_data['planned_hours'] or 0.0,
1066 'parent_ids': [(6, 0, [task.id])],
1067 'description': delegate_data['new_task_description'] or '',
1071 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1072 newname = delegate_data['prefix'] or ''
1074 'remaining_hours': delegate_data['planned_hours_me'],
1075 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1078 if delegate_data['state'] == 'pending':
1079 self.do_pending(cr, uid, [task.id], context=context)
1080 elif delegate_data['state'] == 'done':
1081 self.do_close(cr, uid, [task.id], context=context)
1082 delegated_tasks[task.id] = delegated_task_id
1083 return delegated_tasks
1085 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1086 for task in self.browse(cr, uid, ids, context=context):
1087 if (task.state=='draft') or (task.planned_hours==0.0):
1088 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1089 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1092 def set_remaining_time_1(self, cr, uid, ids, context=None):
1093 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1095 def set_remaining_time_2(self, cr, uid, ids, context=None):
1096 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1098 def set_remaining_time_5(self, cr, uid, ids, context=None):
1099 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1101 def set_remaining_time_10(self, cr, uid, ids, context=None):
1102 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1104 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1105 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1107 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1108 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1110 def set_kanban_state_done(self, cr, uid, ids, context=None):
1111 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1114 def _store_history(self, cr, uid, ids, context=None):
1115 for task in self.browse(cr, uid, ids, context=context):
1116 self.pool.get('project.task.history').create(cr, uid, {
1118 'remaining_hours': task.remaining_hours,
1119 'planned_hours': task.planned_hours,
1120 'kanban_state': task.kanban_state,
1121 'type_id': task.stage_id.id,
1122 'state': task.state,
1123 'user_id': task.user_id.id
1128 def create(self, cr, uid, vals, context=None):
1131 if vals.get('project_id') and not context.get('default_project_id'):
1132 context['default_project_id'] = vals.get('project_id')
1134 # context: no_log, because subtype already handle this
1135 create_context = dict(context, mail_create_nolog=True)
1136 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1137 self._store_history(cr, uid, [task_id], context=context)
1140 # Overridden to reset the kanban_state to normal whenever
1141 # the stage (stage_id) of the task changes.
1142 def write(self, cr, uid, ids, vals, context=None):
1143 if isinstance(ids, (int, long)):
1145 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1146 new_stage = vals.get('stage_id')
1147 vals_reset_kstate = dict(vals, kanban_state='normal')
1148 for t in self.browse(cr, uid, ids, context=context):
1149 #TO FIX:Kanban view doesn't raise warning
1150 #stages = [stage.id for stage in t.project_id.type_ids]
1151 #if new_stage not in stages:
1152 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1153 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1154 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1157 result = super(task, self).write(cr, uid, ids, vals, context=context)
1158 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1159 self._store_history(cr, uid, ids, context=context)
1162 def unlink(self, cr, uid, ids, context=None):
1165 self._check_child_task(cr, uid, ids, context=context)
1166 res = super(task, self).unlink(cr, uid, ids, context)
1169 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1170 context = context or {}
1174 if task.state in ('done','cancelled'):
1179 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1181 for t2 in task.parent_ids:
1182 start.append("up.Task_%s.end" % (t2.id,))
1186 ''' % (ident,','.join(start))
1191 ''' % (ident, 'User_'+str(task.user_id.id))
1196 # ---------------------------------------------------
1198 # ---------------------------------------------------
1200 def message_get_reply_to(self, cr, uid, ids, context=None):
1201 """ Override to get the reply_to of the parent project. """
1202 return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1203 for task in self.browse(cr, uid, ids, context=context)]
1205 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1206 """ Override to updates the document according to the email. """
1207 if custom_values is None:
1210 'name': msg.get('subject'),
1211 'planned_hours': 0.0,
1213 defaults.update(custom_values)
1214 return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1216 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1217 """ Override to update the task according to the email. """
1218 if update_vals is None:
1221 'cost': 'planned_hours',
1223 for line in msg['body'].split('\n'):
1225 res = tools.command_re.match(line)
1227 match = res.group(1).lower()
1228 field = maps.get(match)
1231 update_vals[field] = float(res.group(2).lower())
1232 except (ValueError, TypeError):
1234 return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1236 def project_task_reevaluate(self, cr, uid, ids, context=None):
1237 if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1239 'view_type': 'form',
1240 "view_mode": 'form',
1241 'res_model': 'project.task.reevaluate',
1242 'type': 'ir.actions.act_window',
1245 return self.do_reopen(cr, uid, ids, context=context)
1247 class project_work(osv.osv):
1248 _name = "project.task.work"
1249 _description = "Project Task Work"
1251 'name': fields.char('Work summary', size=128),
1252 'date': fields.datetime('Date', select="1"),
1253 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1254 'hours': fields.float('Time Spent'),
1255 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1256 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1260 'user_id': lambda obj, cr, uid, context: uid,
1261 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1264 _order = "date desc"
1265 def create(self, cr, uid, vals, *args, **kwargs):
1266 if 'hours' in vals and (not vals['hours']):
1267 vals['hours'] = 0.00
1268 if 'task_id' in vals:
1269 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1270 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1272 def write(self, cr, uid, ids, vals, context=None):
1273 if 'hours' in vals and (not vals['hours']):
1274 vals['hours'] = 0.00
1276 for work in self.browse(cr, uid, ids, context=context):
1277 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))
1278 return super(project_work,self).write(cr, uid, ids, vals, context)
1280 def unlink(self, cr, uid, ids, *args, **kwargs):
1281 for work in self.browse(cr, uid, ids):
1282 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1283 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1286 class account_analytic_account(osv.osv):
1287 _inherit = 'account.analytic.account'
1288 _description = 'Analytic Account'
1290 'use_tasks': fields.boolean('Tasks',help="If checked, this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1291 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1294 def on_change_template(self, cr, uid, ids, template_id, context=None):
1295 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1296 if template_id and 'value' in res:
1297 template = self.browse(cr, uid, template_id, context=context)
1298 res['value']['use_tasks'] = template.use_tasks
1301 def _trigger_project_creation(self, cr, uid, vals, context=None):
1303 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.
1305 if context is None: context = {}
1306 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1308 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1310 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.
1312 project_pool = self.pool.get('project.project')
1313 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1314 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1316 'name': vals.get('name'),
1317 'analytic_account_id': analytic_account_id,
1318 'type': vals.get('type','contract'),
1320 return project_pool.create(cr, uid, project_values, context=context)
1323 def create(self, cr, uid, vals, context=None):
1326 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1327 vals['child_ids'] = []
1328 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1329 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1330 return analytic_account_id
1332 def write(self, cr, uid, ids, vals, context=None):
1333 if isinstance(ids, (int, long)):
1335 vals_for_project = vals.copy()
1336 for account in self.browse(cr, uid, ids, context=context):
1337 if not vals.get('name'):
1338 vals_for_project['name'] = account.name
1339 if not vals.get('type'):
1340 vals_for_project['type'] = account.type
1341 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1342 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1344 def unlink(self, cr, uid, ids, *args, **kwargs):
1345 project_obj = self.pool.get('project.project')
1346 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1348 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1349 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1351 def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
1356 if context.get('current_model') == 'project.project':
1357 project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context)
1358 return self.name_get(cr, uid, project_ids, context=context)
1360 return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit)
1363 class project_project(osv.osv):
1364 _inherit = 'project.project'
1369 class project_task_history(osv.osv):
1371 Tasks History, used for cumulative flow charts (Lean/Agile)
1373 _name = 'project.task.history'
1374 _description = 'History of Tasks'
1375 _rec_name = 'task_id'
1378 def _get_date(self, cr, uid, ids, name, arg, context=None):
1380 for history in self.browse(cr, uid, ids, context=context):
1381 if history.state in ('done','cancelled'):
1382 result[history.id] = history.date
1384 cr.execute('''select
1387 project_task_history
1391 order by id limit 1''', (history.task_id.id, history.id))
1393 result[history.id] = res and res[0] or False
1396 def _get_related_date(self, cr, uid, ids, context=None):
1398 for history in self.browse(cr, uid, ids, context=context):
1399 cr.execute('''select
1402 project_task_history
1406 order by id desc limit 1''', (history.task_id.id, history.id))
1409 result.append(res[0])
1413 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1414 'type_id': fields.many2one('project.task.type', 'Stage'),
1415 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1416 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1417 'date': fields.date('Date', select=True),
1418 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1419 'project.task.history': (_get_related_date, None, 20)
1421 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1422 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1423 'user_id': fields.many2one('res.users', 'Responsible'),
1426 'date': fields.date.context_today,
1429 class project_task_history_cumulative(osv.osv):
1430 _name = 'project.task.history.cumulative'
1431 _table = 'project_task_history_cumulative'
1432 _inherit = 'project.task.history'
1436 'end_date': fields.date('End Date'),
1437 'project_id': fields.many2one('project.project', 'Project'),
1441 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1443 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1445 history.date::varchar||'-'||history.history_id::varchar AS id,
1446 history.date AS end_date,
1451 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1452 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1453 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1456 project_task_history AS h
1457 JOIN project_task AS t ON (h.task_id = t.id)
1463 class project_category(osv.osv):
1464 """ Category of project's task (or issue) """
1465 _name = "project.category"
1466 _description = "Category of project's task, issue, ..."
1468 'name': fields.char('Name', size=64, required=True, translate=True),
1470 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: