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 search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
84 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
85 if context and context.get('user_preference'):
86 cr.execute("""SELECT project.id FROM project_project project
87 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
88 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
89 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
90 return [(r[0]) for r in cr.fetchall()]
91 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
92 context=context, count=count)
94 def _complete_name(self, cr, uid, ids, name, args, context=None):
96 for m in self.browse(cr, uid, ids, context=context):
97 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
100 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
101 partner_obj = self.pool.get('res.partner')
105 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
106 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
107 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
108 val['pricelist_id'] = pricelist_id
109 return {'value': val}
111 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
112 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
113 project_ids = [task.project_id.id for task in tasks if task.project_id]
114 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
116 def _get_project_and_parents(self, cr, uid, ids, context=None):
117 """ return the project ids and all their parent projects """
121 SELECT DISTINCT parent.id
122 FROM project_project project, project_project parent, account_analytic_account account
123 WHERE project.analytic_account_id = account.id
124 AND parent.analytic_account_id = account.parent_id
127 ids = [t[0] for t in cr.fetchall()]
131 def _get_project_and_children(self, cr, uid, ids, context=None):
132 """ retrieve all children projects of project ids;
133 return a dictionary mapping each project to its parent project (or None)
135 res = dict.fromkeys(ids, None)
138 SELECT project.id, parent.id
139 FROM project_project project, project_project parent, account_analytic_account account
140 WHERE project.analytic_account_id = account.id
141 AND parent.analytic_account_id = account.parent_id
144 dic = dict(cr.fetchall())
149 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
150 child_parent = self._get_project_and_children(cr, uid, ids, context)
151 # compute planned_hours, total_hours, effective_hours specific to each project
153 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
154 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
155 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
157 """, (tuple(child_parent.keys()),))
158 # aggregate results into res
159 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
160 for id, planned, total, effective in cr.fetchall():
161 # add the values specific to id to all parent projects of id in the result
164 res[id]['planned_hours'] += planned
165 res[id]['total_hours'] += total
166 res[id]['effective_hours'] += effective
167 id = child_parent[id]
168 # compute progress rates
170 if res[id]['total_hours']:
171 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
173 res[id]['progress_rate'] = 0.0
176 def unlink(self, cr, uid, ids, context=None):
178 mail_alias = self.pool.get('mail.alias')
179 for proj in self.browse(cr, uid, ids, context=context):
181 raise osv.except_osv(_('Invalid Action!'),
182 _('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.'))
184 alias_ids.append(proj.alias_id.id)
185 res = super(project, self).unlink(cr, uid, ids, context=context)
186 mail_alias.unlink(cr, uid, alias_ids, context=context)
189 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
191 attachment = self.pool.get('ir.attachment')
192 task = self.pool.get('project.task')
194 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
195 task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
196 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
197 res[id] = (project_attachments or 0) + (task_attachments or 0)
200 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
203 res = dict.fromkeys(ids, 0)
205 ctx['active_test'] = False
206 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)], context=ctx)
207 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
208 res[task.project_id.id] += 1
211 def _get_alias_models(self, cr, uid, context=None):
212 """Overriden in project_issue to offer more options"""
213 return [('project.task', "Tasks")]
215 def _get_visibility_selection(self, cr, uid, context=None):
216 """ Overriden in portal_project to offer more options """
217 return [('public', 'All Users'),
218 ('employees', 'Employees Only'),
219 ('followers', 'Followers Only')]
221 def attachment_tree_view(self, cr, uid, ids, context):
222 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
225 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
226 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)
228 res_id = ids and ids[0] or False
230 'name': _('Attachments'),
232 'res_model': 'ir.attachment',
233 'type': 'ir.actions.act_window',
235 'view_mode': 'tree,form',
238 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
240 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
241 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
242 _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs)
245 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
246 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
247 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
248 '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),
249 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
250 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
251 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)]}),
252 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
253 '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.",
255 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
256 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
258 '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.",
260 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
261 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
263 '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.",
265 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
266 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
268 '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.",
270 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
271 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
273 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
274 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
275 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
276 'color': fields.integer('Color Index'),
277 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
278 help="Internal email associated with this project. Incoming emails are automatically synchronized"
279 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
280 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
281 help="The kind of document created when an email is received on this project's email alias"),
282 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy / Visibility', required=True),
283 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
284 'doc_count':fields.function(_get_attached_docs, string="Number of documents attached", type='int')
287 def _get_type_common(self, cr, uid, context):
288 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
291 _order = "sequence, id"
298 'type_ids': _get_type_common,
299 'alias_model': 'project.task',
300 'privacy_visibility': 'employees',
301 'alias_domain': False, # always hide alias during creation
304 # TODO: Why not using a SQL contraints ?
305 def _check_dates(self, cr, uid, ids, context=None):
306 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
307 if leave['date_start'] and leave['date']:
308 if leave['date_start'] > leave['date']:
313 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
316 def set_template(self, cr, uid, ids, context=None):
317 res = self.setActive(cr, uid, ids, value=False, context=context)
320 def set_done(self, cr, uid, ids, context=None):
321 task_obj = self.pool.get('project.task')
322 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
323 task_obj.case_close(cr, uid, task_ids, context=context)
324 return self.write(cr, uid, ids, {'state':'close'}, context=context)
326 def set_cancel(self, cr, uid, ids, context=None):
327 task_obj = self.pool.get('project.task')
328 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
329 task_obj.case_cancel(cr, uid, task_ids, context=context)
330 return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
332 def set_pending(self, cr, uid, ids, context=None):
333 return self.write(cr, uid, ids, {'state':'pending'}, context=context)
335 def set_open(self, cr, uid, ids, context=None):
336 return self.write(cr, uid, ids, {'state':'open'}, context=context)
338 def reset_project(self, cr, uid, ids, context=None):
339 return self.setActive(cr, uid, ids, value=True, context=context)
341 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
342 """ copy and map tasks from old to new project """
346 task_obj = self.pool.get('project.task')
347 proj = self.browse(cr, uid, old_project_id, context=context)
348 for task in proj.tasks:
349 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
350 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
351 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
354 def copy(self, cr, uid, id, default=None, context=None):
360 context['active_test'] = False
361 default['state'] = 'open'
362 default['line_ids'] = []
363 default['tasks'] = []
364 default.pop('alias_name', None)
365 default.pop('alias_id', None)
366 proj = self.browse(cr, uid, id, context=context)
367 if not default.get('name', False):
368 default.update(name=_("%s (copy)") % (proj.name))
369 res = super(project, self).copy(cr, uid, id, default, context)
370 self.map_tasks(cr,uid,id,res,context)
373 def duplicate_template(self, cr, uid, ids, context=None):
376 data_obj = self.pool.get('ir.model.data')
378 for proj in self.browse(cr, uid, ids, context=context):
379 parent_id = context.get('parent_id', False)
380 context.update({'analytic_project_copy': True})
381 new_date_start = time.strftime('%Y-%m-%d')
383 if proj.date_start and proj.date:
384 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
385 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
386 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
387 context.update({'copy':True})
388 new_id = self.copy(cr, uid, proj.id, default = {
389 'name':_("%s (copy)") % (proj.name),
391 'date_start':new_date_start,
393 'parent_id':parent_id}, context=context)
394 result.append(new_id)
396 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
397 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
399 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
401 if result and len(result):
403 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
404 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
405 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
406 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
407 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
408 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
410 'name': _('Projects'),
412 'view_mode': 'form,tree',
413 'res_model': 'project.project',
416 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
417 'type': 'ir.actions.act_window',
418 'search_view_id': search_view['res_id'],
422 # set active value for a project, its sub projects and its tasks
423 def setActive(self, cr, uid, ids, value=True, context=None):
424 task_obj = self.pool.get('project.task')
425 for proj in self.browse(cr, uid, ids, context=None):
426 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
427 cr.execute('select id from project_task where project_id=%s', (proj.id,))
428 tasks_id = [x[0] for x in cr.fetchall()]
430 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
431 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
433 self.setActive(cr, uid, child_ids, value, context=None)
436 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
437 context = context or {}
438 if type(ids) in (long, int,):
440 projects = self.browse(cr, uid, ids, context=context)
442 for project in projects:
443 if (not project.members) and force_members:
444 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
446 resource_pool = self.pool.get('resource.resource')
448 result = "from openerp.addons.resource.faces import *\n"
449 result += "import datetime\n"
450 for project in self.browse(cr, uid, ids, context=context):
451 u_ids = [i.id for i in project.members]
452 if project.user_id and (project.user_id.id not in u_ids):
453 u_ids.append(project.user_id.id)
454 for task in project.tasks:
455 if task.state in ('done','cancelled'):
457 if task.user_id and (task.user_id.id not in u_ids):
458 u_ids.append(task.user_id.id)
459 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
460 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
461 for key, vals in resource_objs.items():
463 class User_%s(Resource):
465 ''' % (key, vals.get('efficiency', False))
472 def _schedule_project(self, cr, uid, project, context=None):
473 resource_pool = self.pool.get('resource.resource')
474 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
475 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
476 # TODO: check if we need working_..., default values are ok.
477 puids = [x.id for x in project.members]
479 puids.append(project.user_id.id)
487 project.date_start or time.strftime('%Y-%m-%d'), working_days,
488 '|'.join(['User_'+str(x) for x in puids])
490 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
497 #TODO: DO Resource allocation and compute availability
498 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
504 def schedule_tasks(self, cr, uid, ids, context=None):
505 context = context or {}
506 if type(ids) in (long, int,):
508 projects = self.browse(cr, uid, ids, context=context)
509 result = self._schedule_header(cr, uid, ids, False, context=context)
510 for project in projects:
511 result += self._schedule_project(cr, uid, project, context=context)
512 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
515 exec result in local_dict
516 projects_gantt = Task.BalancedProject(local_dict['Project'])
518 for project in projects:
519 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
520 for task in project.tasks:
521 if task.state in ('done','cancelled'):
524 p = getattr(project_gantt, 'Task_%d' % (task.id,))
526 self.pool.get('project.task').write(cr, uid, [task.id], {
527 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
528 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
530 if (not task.user_id) and (p.booked_resource):
531 self.pool.get('project.task').write(cr, uid, [task.id], {
532 'user_id': int(p.booked_resource[0].name[5:]),
536 # ------------------------------------------------
537 # OpenChatter methods and notifications
538 # ------------------------------------------------
540 def create(self, cr, uid, vals, context=None):
541 if context is None: context = {}
542 # Prevent double project creation when 'use_tasks' is checked!
543 context = dict(context, project_creation_in_progress=True)
544 mail_alias = self.pool.get('mail.alias')
545 if not vals.get('alias_id') and vals.get('name', False):
546 vals.pop('alias_name', None) # prevent errors during copy()
547 alias_id = mail_alias.create_unique_alias(cr, uid,
548 # Using '+' allows using subaddressing for those who don't
549 # have a catchall domain setup.
550 {'alias_name': "project+"+short_name(vals['name'])},
551 model_name=vals.get('alias_model', 'project.task'),
553 vals['alias_id'] = alias_id
554 if vals.get('type', False) not in ('template','contract'):
555 vals['type'] = 'contract'
556 project_id = super(project, self).create(cr, uid, vals, context)
557 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
560 def write(self, cr, uid, ids, vals, context=None):
561 # if alias_model has been changed, update alias_model_id accordingly
562 if vals.get('alias_model'):
563 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
564 vals.update(alias_model_id=model_ids[0])
565 return super(project, self).write(cr, uid, ids, vals, context=context)
567 class task(base_stage, osv.osv):
568 _name = "project.task"
569 _description = "Task"
570 _date_name = "date_start"
571 _inherit = ['mail.thread', 'ir.needaction_mixin']
575 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'new',
576 'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
577 'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
580 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'done', 'open'],
582 'kanban_state': { # kanban state: tracked, but only block subtype
583 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
587 def _get_default_project_id(self, cr, uid, context=None):
588 """ Gives default section by checking if present in the context """
589 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
591 def _get_default_stage_id(self, cr, uid, context=None):
592 """ Gives default stage_id """
593 project_id = self._get_default_project_id(cr, uid, context=context)
594 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
596 def _resolve_project_id_from_context(self, cr, uid, context=None):
597 """ Returns ID of project based on the value of 'default_project_id'
598 context key, or None if it cannot be resolved to a single
601 if context is None: context = {}
602 if type(context.get('default_project_id')) in (int, long):
603 return context['default_project_id']
604 if isinstance(context.get('default_project_id'), basestring):
605 project_name = context['default_project_id']
606 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
607 if len(project_ids) == 1:
608 return project_ids[0][0]
611 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
612 stage_obj = self.pool.get('project.task.type')
613 order = stage_obj._order
614 access_rights_uid = access_rights_uid or uid
615 if read_group_order == 'stage_id desc':
616 order = '%s desc' % order
618 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
620 search_domain += ['|', ('project_ids', '=', project_id)]
621 search_domain += [('id', 'in', ids)]
622 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
623 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
624 # restore order of the search
625 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
628 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
629 fold[stage.id] = stage.fold or False
632 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
633 res_users = self.pool.get('res.users')
634 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
635 access_rights_uid = access_rights_uid or uid
637 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
638 order = res_users._order
639 # lame way to allow reverting search, should just work in the trivial case
640 if read_group_order == 'user_id desc':
641 order = '%s desc' % order
642 # de-duplicate and apply search order
643 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
644 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
645 # restore order of the search
646 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
650 'stage_id': _read_group_stage_ids,
651 'user_id': _read_group_user_id,
654 def _str_get(self, task, level=0, border='***', context=None):
655 return border+' '+(task.user_id and task.user_id.name.upper() or '')+(level and (': L'+str(level)) or '')+(' - %.1fh / %.1fh'%(task.effective_hours or 0.0,task.planned_hours))+' '+border+'\n'+ \
656 border[0]+' '+(task.name or '')+'\n'+ \
657 (task.description or '')+'\n\n'
659 # Compute: effective_hours, total_hours, progress
660 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
662 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
663 hours = dict(cr.fetchall())
664 for task in self.browse(cr, uid, ids, context=context):
665 res[task.id] = {'effective_hours': hours.get(task.id, 0.0), 'total_hours': (task.remaining_hours or 0.0) + hours.get(task.id, 0.0)}
666 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
667 res[task.id]['progress'] = 0.0
668 if (task.remaining_hours + hours.get(task.id, 0.0)):
669 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
670 if task.state in ('done','cancelled'):
671 res[task.id]['progress'] = 100.0
674 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
675 if remaining and not planned:
676 return {'value':{'planned_hours': remaining}}
679 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
680 return {'value':{'remaining_hours': planned - effective}}
682 def onchange_project(self, cr, uid, id, project_id):
685 data = self.pool.get('project.project').browse(cr, uid, [project_id])
686 partner_id=data and data[0].partner_id
688 return {'value':{'partner_id':partner_id.id}}
691 def duplicate_task(self, cr, uid, map_ids, context=None):
692 for new in map_ids.values():
693 task = self.browse(cr, uid, new, context)
694 child_ids = [ ch.id for ch in task.child_ids]
696 for child in task.child_ids:
697 if child.id in map_ids.keys():
698 child_ids.remove(child.id)
699 child_ids.append(map_ids[child.id])
701 parent_ids = [ ch.id for ch in task.parent_ids]
703 for parent in task.parent_ids:
704 if parent.id in map_ids.keys():
705 parent_ids.remove(parent.id)
706 parent_ids.append(map_ids[parent.id])
707 #FIXME why there is already the copy and the old one
708 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
710 def copy_data(self, cr, uid, id, default=None, context=None):
713 default = default or {}
714 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
715 if not default.get('remaining_hours', False):
716 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
717 default['active'] = True
718 if not default.get('name', False):
719 default['name'] = self.browse(cr, uid, id, context=context).name or ''
720 if not context.get('copy',False):
721 new_name = _("%s (copy)") % (default.get('name', ''))
722 default.update({'name':new_name})
723 return super(task, self).copy_data(cr, uid, id, default, context)
725 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
727 for task in self.browse(cr, uid, ids, context=context):
730 if task.project_id.active == False or task.project_id.state == 'template':
734 def _get_task(self, cr, uid, ids, context=None):
736 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
737 if work.task_id: result[work.task_id.id] = True
741 'active': fields.function(_is_template, store=True, string='Not a Template Task', type='boolean', help="This field is computed automatically and have the same behavior than the boolean 'active' field: if the task is linked to a template or unactivated project, it will be hidden unless specifically asked."),
742 'name': fields.char('Task Summary', size=128, required=True, select=True),
743 'description': fields.text('Description'),
744 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
745 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
746 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange',
747 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
748 'state': fields.related('stage_id', 'state', type="selection", store=True,
749 selection=_TASK_STATE, string="Status", readonly=True,
750 help='The status is set to \'Draft\', when a case is created.\
751 If the case is in progress the status is set to \'Open\'.\
752 When the case is over, the status is set to \'Done\'.\
753 If the case needs to be reviewed then the status is \
754 set to \'Pending\'.'),
755 'categ_ids': fields.many2many('project.category', string='Tags'),
756 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
757 track_visibility='onchange',
758 help="A task's kanban state indicates special situations affecting it:\n"
759 " * Normal is the default situation\n"
760 " * Blocked indicates something is preventing the progress of this task\n"
761 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
762 readonly=True, required=False),
763 'create_date': fields.datetime('Create Date', readonly=True,select=True),
764 'date_start': fields.datetime('Starting Date',select=True),
765 'date_end': fields.datetime('Ending Date',select=True),
766 'date_deadline': fields.date('Deadline',select=True),
767 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", track_visibility='onchange'),
768 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
769 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
770 'notes': fields.text('Notes'),
771 '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.'),
772 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
774 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
775 'project.task.work': (_get_task, ['hours'], 10),
777 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
778 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
780 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
781 'project.task.work': (_get_task, ['hours'], 10),
783 '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",
785 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
786 'project.task.work': (_get_task, ['hours'], 10),
788 '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.",
790 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
791 'project.task.work': (_get_task, ['hours'], 10),
793 'user_id': fields.many2one('res.users', 'Assigned to', track_visibility='onchange'),
794 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
795 'partner_id': fields.many2one('res.partner', 'Customer'),
796 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
797 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
798 'company_id': fields.many2one('res.company', 'Company'),
799 'id': fields.integer('ID', readonly=True),
800 'color': fields.integer('Color Index'),
801 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
804 'stage_id': _get_default_stage_id,
805 'project_id': _get_default_project_id,
806 'kanban_state': 'normal',
811 'user_id': lambda obj, cr, uid, context: uid,
812 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
814 _order = "priority, sequence, date_start, name, id"
816 def set_high_priority(self, cr, uid, ids, *args):
817 """Set task priority to high
819 return self.write(cr, uid, ids, {'priority' : '0'})
821 def set_normal_priority(self, cr, uid, ids, *args):
822 """Set task priority to normal
824 return self.write(cr, uid, ids, {'priority' : '2'})
826 def _check_recursion(self, cr, uid, ids, context=None):
828 visited_branch = set()
830 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
836 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
837 if id in visited_branch: #Cycle
840 if id in visited_node: #Already tested don't work one more time for nothing
843 visited_branch.add(id)
846 #visit child using DFS
847 task = self.browse(cr, uid, id, context=context)
848 for child in task.child_ids:
849 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
853 visited_branch.remove(id)
856 def _check_dates(self, cr, uid, ids, context=None):
859 obj_task = self.browse(cr, uid, ids[0], context=context)
860 start = obj_task.date_start or False
861 end = obj_task.date_end or False
868 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
869 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
872 # Override view according to the company definition
873 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
874 users_obj = self.pool.get('res.users')
875 if context is None: context = {}
876 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
877 # this should be safe (no context passed to avoid side-effects)
878 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
879 tm = obj_tm and obj_tm.name or 'Hours'
881 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
883 if tm in ['Hours','Hour']:
886 eview = etree.fromstring(res['arch'])
888 def _check_rec(eview):
889 if eview.attrib.get('widget','') == 'float_time':
890 eview.set('widget','float')
897 res['arch'] = etree.tostring(eview)
899 for f in res['fields']:
900 if 'Hours' in res['fields'][f]['string']:
901 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
904 # ----------------------------------------
906 # ----------------------------------------
908 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
909 """ Override of the base.stage method
910 Parameter of the stage search taken from the lead:
911 - section_id: if set, stages must belong to this section or
912 be a default stage; if not set, stages must be default
915 if isinstance(cases, (int, long)):
916 cases = self.browse(cr, uid, cases, context=context)
917 # collect all section_ids
920 section_ids.append(section_id)
923 section_ids.append(task.project_id.id)
926 search_domain = [('|')] * (len(section_ids)-1)
927 for section_id in section_ids:
928 search_domain.append(('project_ids', '=', section_id))
929 search_domain += list(domain)
930 # perform search, return the first found
931 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
936 def _check_child_task(self, cr, uid, ids, context=None):
939 tasks = self.browse(cr, uid, ids, context=context)
942 for child in task.child_ids:
943 if child.state in ['draft', 'open', 'pending']:
944 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
947 def action_close(self, cr, uid, ids, context=None):
948 """ This action closes the task
950 task_id = len(ids) and ids[0] or False
951 self._check_child_task(cr, uid, ids, context=context)
952 if not task_id: return False
953 return self.do_close(cr, uid, [task_id], context=context)
955 def do_close(self, cr, uid, ids, context=None):
956 """ Compatibility when changing to case_close. """
957 return self.case_close(cr, uid, ids, context=context)
959 def case_close(self, cr, uid, ids, context=None):
961 if not isinstance(ids, list): ids = [ids]
962 for task in self.browse(cr, uid, ids, context=context):
964 project = task.project_id
965 for parent_id in task.parent_ids:
966 if parent_id.state in ('pending','draft'):
968 for child in parent_id.child_ids:
969 if child.id != task.id and child.state not in ('done','cancelled'):
972 self.do_reopen(cr, uid, [parent_id.id], context=context)
974 vals['remaining_hours'] = 0.0
975 if not task.date_end:
976 vals['date_end'] = fields.datetime.now()
977 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
980 def do_reopen(self, cr, uid, ids, context=None):
981 for task in self.browse(cr, uid, ids, context=context):
982 project = task.project_id
983 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
986 def do_cancel(self, cr, uid, ids, context=None):
987 """ Compatibility when changing to case_cancel. """
988 return self.case_cancel(cr, uid, ids, context=context)
990 def case_cancel(self, cr, uid, ids, context=None):
991 tasks = self.browse(cr, uid, ids, context=context)
992 self._check_child_task(cr, uid, ids, context=context)
994 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
997 def do_open(self, cr, uid, ids, context=None):
998 """ Compatibility when changing to case_open. """
999 return self.case_open(cr, uid, ids, context=context)
1001 def case_open(self, cr, uid, ids, context=None):
1002 if not isinstance(ids,list): ids = [ids]
1003 return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1005 def do_draft(self, cr, uid, ids, context=None):
1006 """ Compatibility when changing to case_draft. """
1007 return self.case_draft(cr, uid, ids, context=context)
1009 def case_draft(self, cr, uid, ids, context=None):
1010 return self.case_set(cr, uid, ids, 'draft', {}, context=context)
1012 def do_pending(self, cr, uid, ids, context=None):
1013 """ Compatibility when changing to case_pending. """
1014 return self.case_pending(cr, uid, ids, context=context)
1016 def case_pending(self, cr, uid, ids, context=None):
1017 return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1019 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1020 attachment = self.pool.get('ir.attachment')
1021 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1022 new_attachment_ids = []
1023 for attachment_id in attachment_ids:
1024 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1025 return new_attachment_ids
1027 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1029 Delegate Task to another users.
1031 if delegate_data is None:
1033 assert delegate_data['user_id'], _("Delegated User should be specified")
1034 delegated_tasks = {}
1035 for task in self.browse(cr, uid, ids, context=context):
1036 delegated_task_id = self.copy(cr, uid, task.id, {
1037 'name': delegate_data['name'],
1038 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1039 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1040 'planned_hours': delegate_data['planned_hours'] or 0.0,
1041 'parent_ids': [(6, 0, [task.id])],
1042 'description': delegate_data['new_task_description'] or '',
1046 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1047 newname = delegate_data['prefix'] or ''
1049 'remaining_hours': delegate_data['planned_hours_me'],
1050 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1053 if delegate_data['state'] == 'pending':
1054 self.do_pending(cr, uid, [task.id], context=context)
1055 elif delegate_data['state'] == 'done':
1056 self.do_close(cr, uid, [task.id], context=context)
1057 delegated_tasks[task.id] = delegated_task_id
1058 return delegated_tasks
1060 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1061 for task in self.browse(cr, uid, ids, context=context):
1062 if (task.state=='draft') or (task.planned_hours==0.0):
1063 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1064 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1067 def set_remaining_time_1(self, cr, uid, ids, context=None):
1068 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1070 def set_remaining_time_2(self, cr, uid, ids, context=None):
1071 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1073 def set_remaining_time_5(self, cr, uid, ids, context=None):
1074 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1076 def set_remaining_time_10(self, cr, uid, ids, context=None):
1077 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1079 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1080 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1082 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1083 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1085 def set_kanban_state_done(self, cr, uid, ids, context=None):
1086 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1089 def _store_history(self, cr, uid, ids, context=None):
1090 for task in self.browse(cr, uid, ids, context=context):
1091 self.pool.get('project.task.history').create(cr, uid, {
1093 'remaining_hours': task.remaining_hours,
1094 'planned_hours': task.planned_hours,
1095 'kanban_state': task.kanban_state,
1096 'type_id': task.stage_id.id,
1097 'state': task.state,
1098 'user_id': task.user_id.id
1103 def create(self, cr, uid, vals, context=None):
1106 if not vals.get('stage_id'):
1107 ctx = context.copy()
1108 if vals.get('project_id'):
1109 ctx['default_project_id'] = vals['project_id']
1110 vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
1111 task_id = super(task, self).create(cr, uid, vals, context=context)
1112 self._store_history(cr, uid, [task_id], context=context)
1115 # Overridden to reset the kanban_state to normal whenever
1116 # the stage (stage_id) of the task changes.
1117 def write(self, cr, uid, ids, vals, context=None):
1118 if isinstance(ids, (int, long)):
1120 if vals.get('project_id'):
1121 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1123 vals.setdefault('message_follower_ids', [])
1124 vals['message_follower_ids'] += [(6, 0,[follower.id]) for follower in project_id.message_follower_ids]
1125 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1126 new_stage = vals.get('stage_id')
1127 vals_reset_kstate = dict(vals, kanban_state='normal')
1128 for t in self.browse(cr, uid, ids, context=context):
1129 #TO FIX:Kanban view doesn't raise warning
1130 #stages = [stage.id for stage in t.project_id.type_ids]
1131 #if new_stage not in stages:
1132 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1133 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1134 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1137 result = super(task, self).write(cr, uid, ids, vals, context=context)
1138 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1139 self._store_history(cr, uid, ids, context=context)
1142 def unlink(self, cr, uid, ids, context=None):
1145 self._check_child_task(cr, uid, ids, context=context)
1146 res = super(task, self).unlink(cr, uid, ids, context)
1149 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1150 context = context or {}
1154 if task.state in ('done','cancelled'):
1159 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1161 for t2 in task.parent_ids:
1162 start.append("up.Task_%s.end" % (t2.id,))
1166 ''' % (ident,','.join(start))
1171 ''' % (ident, 'User_'+str(task.user_id.id))
1176 # ---------------------------------------------------
1178 # ---------------------------------------------------
1180 def message_get_reply_to(self, cr, uid, ids, context=None):
1181 """ Override to get the reply_to of the parent project. """
1182 return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1183 for task in self.browse(cr, uid, ids, context=context)]
1185 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1186 """ Override to updates the document according to the email. """
1187 if custom_values is None: custom_values = {}
1189 'name': msg.get('subject'),
1190 'planned_hours': 0.0,
1192 defaults.update(custom_values)
1193 return super(task,self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1195 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1196 """ Override to update the task according to the email. """
1197 if update_vals is None: update_vals = {}
1200 'cost':'planned_hours',
1202 for line in msg['body'].split('\n'):
1204 res = tools.command_re.match(line)
1206 match = res.group(1).lower()
1207 field = maps.get(match)
1210 update_vals[field] = float(res.group(2).lower())
1211 except (ValueError, TypeError):
1213 elif match.lower() == 'state' \
1214 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1215 act = 'do_%s' % res.group(2).lower()
1217 getattr(self,act)(cr, uid, ids, context=context)
1218 return super(task,self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1220 def project_task_reevaluate(self, cr, uid, ids, context=None):
1221 if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1223 'view_type': 'form',
1224 "view_mode": 'form',
1225 'res_model': 'project.task.reevaluate',
1226 'type': 'ir.actions.act_window',
1229 return self.do_reopen(cr, uid, ids, context=context)
1231 class project_work(osv.osv):
1232 _name = "project.task.work"
1233 _description = "Project Task Work"
1235 'name': fields.char('Work summary', size=128),
1236 'date': fields.datetime('Date', select="1"),
1237 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1238 'hours': fields.float('Time Spent'),
1239 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1240 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1244 'user_id': lambda obj, cr, uid, context: uid,
1245 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1248 _order = "date desc"
1249 def create(self, cr, uid, vals, *args, **kwargs):
1250 if 'hours' in vals and (not vals['hours']):
1251 vals['hours'] = 0.00
1252 if 'task_id' in vals:
1253 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1254 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1256 def write(self, cr, uid, ids, vals, context=None):
1257 if 'hours' in vals and (not vals['hours']):
1258 vals['hours'] = 0.00
1260 for work in self.browse(cr, uid, ids, context=context):
1261 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))
1262 return super(project_work,self).write(cr, uid, ids, vals, context)
1264 def unlink(self, cr, uid, ids, *args, **kwargs):
1265 for work in self.browse(cr, uid, ids):
1266 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1267 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1270 class account_analytic_account(osv.osv):
1271 _inherit = 'account.analytic.account'
1272 _description = 'Analytic Account'
1274 '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"),
1275 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1278 def on_change_template(self, cr, uid, ids, template_id, context=None):
1279 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1280 if template_id and 'value' in res:
1281 template = self.browse(cr, uid, template_id, context=context)
1282 res['value']['use_tasks'] = template.use_tasks
1285 def _trigger_project_creation(self, cr, uid, vals, context=None):
1287 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.
1289 if context is None: context = {}
1290 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1292 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1294 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.
1296 project_pool = self.pool.get('project.project')
1297 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1298 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1300 'name': vals.get('name'),
1301 'analytic_account_id': analytic_account_id,
1302 'type': vals.get('type','contract'),
1304 return project_pool.create(cr, uid, project_values, context=context)
1307 def create(self, cr, uid, vals, context=None):
1310 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1311 vals['child_ids'] = []
1312 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1313 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1314 return analytic_account_id
1316 def write(self, cr, uid, ids, vals, context=None):
1317 vals_for_project = vals.copy()
1318 for account in self.browse(cr, uid, ids, context=context):
1319 if not vals.get('name'):
1320 vals_for_project['name'] = account.name
1321 if not vals.get('type'):
1322 vals_for_project['type'] = account.type
1323 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1324 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1326 def unlink(self, cr, uid, ids, *args, **kwargs):
1327 project_obj = self.pool.get('project.project')
1328 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1330 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1331 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1333 class project_project(osv.osv):
1334 _inherit = 'project.project'
1339 class project_task_history(osv.osv):
1341 Tasks History, used for cumulative flow charts (Lean/Agile)
1343 _name = 'project.task.history'
1344 _description = 'History of Tasks'
1345 _rec_name = 'task_id'
1348 def _get_date(self, cr, uid, ids, name, arg, context=None):
1350 for history in self.browse(cr, uid, ids, context=context):
1351 if history.state in ('done','cancelled'):
1352 result[history.id] = history.date
1354 cr.execute('''select
1357 project_task_history
1361 order by id limit 1''', (history.task_id.id, history.id))
1363 result[history.id] = res and res[0] or False
1366 def _get_related_date(self, cr, uid, ids, context=None):
1368 for history in self.browse(cr, uid, ids, context=context):
1369 cr.execute('''select
1372 project_task_history
1376 order by id desc limit 1''', (history.task_id.id, history.id))
1379 result.append(res[0])
1383 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1384 'type_id': fields.many2one('project.task.type', 'Stage'),
1385 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1386 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1387 'date': fields.date('Date', select=True),
1388 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1389 'project.task.history': (_get_related_date, None, 20)
1391 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1392 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1393 'user_id': fields.many2one('res.users', 'Responsible'),
1396 'date': fields.date.context_today,
1399 class project_task_history_cumulative(osv.osv):
1400 _name = 'project.task.history.cumulative'
1401 _table = 'project_task_history_cumulative'
1402 _inherit = 'project.task.history'
1406 'end_date': fields.date('End Date'),
1407 'project_id': fields.many2one('project.project', 'Project'),
1411 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1413 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1415 history.date::varchar||'-'||history.history_id::varchar AS id,
1416 history.date AS end_date,
1421 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1422 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1423 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1426 project_task_history AS h
1427 JOIN project_task AS t ON (h.task_id = t.id)
1433 class project_category(osv.osv):
1434 """ Category of project's task (or issue) """
1435 _name = "project.category"
1436 _description = "Category of project's task, issue, ..."
1438 'name': fields.char('Name', size=64, required=True, translate=True),
1440 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: