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 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(_get_attached_docs, string="Number of documents attached", type='int')
294 def _get_type_common(self, cr, uid, context):
295 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
298 _order = "sequence, id"
305 'type_ids': _get_type_common,
306 'alias_model': 'project.task',
307 'privacy_visibility': 'employees',
308 'alias_domain': False, # always hide alias during creation
311 # TODO: Why not using a SQL contraints ?
312 def _check_dates(self, cr, uid, ids, context=None):
313 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
314 if leave['date_start'] and leave['date']:
315 if leave['date_start'] > leave['date']:
320 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
323 def set_template(self, cr, uid, ids, context=None):
324 res = self.setActive(cr, uid, ids, value=False, context=context)
327 def set_done(self, cr, uid, ids, context=None):
328 task_obj = self.pool.get('project.task')
329 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
330 task_obj.case_close(cr, uid, task_ids, context=context)
331 return self.write(cr, uid, ids, {'state':'close'}, context=context)
333 def set_cancel(self, cr, uid, ids, context=None):
334 task_obj = self.pool.get('project.task')
335 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
336 task_obj.case_cancel(cr, uid, task_ids, context=context)
337 return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
339 def set_pending(self, cr, uid, ids, context=None):
340 return self.write(cr, uid, ids, {'state':'pending'}, context=context)
342 def set_open(self, cr, uid, ids, context=None):
343 return self.write(cr, uid, ids, {'state':'open'}, context=context)
345 def reset_project(self, cr, uid, ids, context=None):
346 return self.setActive(cr, uid, ids, value=True, context=context)
348 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
349 """ copy and map tasks from old to new project """
353 task_obj = self.pool.get('project.task')
354 proj = self.browse(cr, uid, old_project_id, context=context)
355 for task in proj.tasks:
356 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
357 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
358 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
361 def copy(self, cr, uid, id, default=None, context=None):
367 context['active_test'] = False
368 default['state'] = 'open'
369 default['line_ids'] = []
370 default['tasks'] = []
371 default.pop('alias_name', None)
372 default.pop('alias_id', None)
373 proj = self.browse(cr, uid, id, context=context)
374 if not default.get('name', False):
375 default.update(name=_("%s (copy)") % (proj.name))
376 res = super(project, self).copy(cr, uid, id, default, context)
377 self.map_tasks(cr,uid,id,res,context)
380 def duplicate_template(self, cr, uid, ids, context=None):
383 data_obj = self.pool.get('ir.model.data')
385 for proj in self.browse(cr, uid, ids, context=context):
386 parent_id = context.get('parent_id', False)
387 context.update({'analytic_project_copy': True})
388 new_date_start = time.strftime('%Y-%m-%d')
390 if proj.date_start and proj.date:
391 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
392 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
393 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
394 context.update({'copy':True})
395 new_id = self.copy(cr, uid, proj.id, default = {
396 'name':_("%s (copy)") % (proj.name),
398 'date_start':new_date_start,
400 'parent_id':parent_id}, context=context)
401 result.append(new_id)
403 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
404 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
406 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
408 if result and len(result):
410 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
411 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
412 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
413 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
414 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
415 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
417 'name': _('Projects'),
419 'view_mode': 'form,tree',
420 'res_model': 'project.project',
423 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
424 'type': 'ir.actions.act_window',
425 'search_view_id': search_view['res_id'],
429 # set active value for a project, its sub projects and its tasks
430 def setActive(self, cr, uid, ids, value=True, context=None):
431 task_obj = self.pool.get('project.task')
432 for proj in self.browse(cr, uid, ids, context=None):
433 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
434 cr.execute('select id from project_task where project_id=%s', (proj.id,))
435 tasks_id = [x[0] for x in cr.fetchall()]
437 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
438 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
440 self.setActive(cr, uid, child_ids, value, context=None)
443 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
444 context = context or {}
445 if type(ids) in (long, int,):
447 projects = self.browse(cr, uid, ids, context=context)
449 for project in projects:
450 if (not project.members) and force_members:
451 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
453 resource_pool = self.pool.get('resource.resource')
455 result = "from openerp.addons.resource.faces import *\n"
456 result += "import datetime\n"
457 for project in self.browse(cr, uid, ids, context=context):
458 u_ids = [i.id for i in project.members]
459 if project.user_id and (project.user_id.id not in u_ids):
460 u_ids.append(project.user_id.id)
461 for task in project.tasks:
462 if task.state in ('done','cancelled'):
464 if task.user_id and (task.user_id.id not in u_ids):
465 u_ids.append(task.user_id.id)
466 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
467 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
468 for key, vals in resource_objs.items():
470 class User_%s(Resource):
472 ''' % (key, vals.get('efficiency', False))
479 def _schedule_project(self, cr, uid, project, context=None):
480 resource_pool = self.pool.get('resource.resource')
481 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
482 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
483 # TODO: check if we need working_..., default values are ok.
484 puids = [x.id for x in project.members]
486 puids.append(project.user_id.id)
494 project.date_start or time.strftime('%Y-%m-%d'), working_days,
495 '|'.join(['User_'+str(x) for x in puids]) or 'None'
497 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
504 #TODO: DO Resource allocation and compute availability
505 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
511 def schedule_tasks(self, cr, uid, ids, context=None):
512 context = context or {}
513 if type(ids) in (long, int,):
515 projects = self.browse(cr, uid, ids, context=context)
516 result = self._schedule_header(cr, uid, ids, False, context=context)
517 for project in projects:
518 result += self._schedule_project(cr, uid, project, context=context)
519 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
522 exec result in local_dict
523 projects_gantt = Task.BalancedProject(local_dict['Project'])
525 for project in projects:
526 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
527 for task in project.tasks:
528 if task.state in ('done','cancelled'):
531 p = getattr(project_gantt, 'Task_%d' % (task.id,))
533 self.pool.get('project.task').write(cr, uid, [task.id], {
534 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
535 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
537 if (not task.user_id) and (p.booked_resource):
538 self.pool.get('project.task').write(cr, uid, [task.id], {
539 'user_id': int(p.booked_resource[0].name[5:]),
543 # ------------------------------------------------
544 # OpenChatter methods and notifications
545 # ------------------------------------------------
547 def create(self, cr, uid, vals, context=None):
548 if context is None: context = {}
549 # Prevent double project creation when 'use_tasks' is checked!
550 context = dict(context, project_creation_in_progress=True)
551 mail_alias = self.pool.get('mail.alias')
552 if not vals.get('alias_id') and vals.get('name', False):
553 vals.pop('alias_name', None) # prevent errors during copy()
554 alias_id = mail_alias.create_unique_alias(cr, uid,
555 # Using '+' allows using subaddressing for those who don't
556 # have a catchall domain setup.
557 {'alias_name': "project+"+short_name(vals['name'])},
558 model_name=vals.get('alias_model', 'project.task'),
560 vals['alias_id'] = alias_id
561 if vals.get('type', False) not in ('template','contract'):
562 vals['type'] = 'contract'
563 project_id = super(project, self).create(cr, uid, vals, context)
564 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
567 def write(self, cr, uid, ids, vals, context=None):
568 # if alias_model has been changed, update alias_model_id accordingly
569 if vals.get('alias_model'):
570 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
571 vals.update(alias_model_id=model_ids[0])
572 return super(project, self).write(cr, uid, ids, vals, context=context)
574 class task(base_stage, osv.osv):
575 _name = "project.task"
576 _description = "Task"
577 _date_name = "date_start"
578 _inherit = ['mail.thread', 'ir.needaction_mixin']
582 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
583 'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
584 'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
587 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'done', 'open'],
589 'kanban_state': { # kanban state: tracked, but only block subtype
590 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
594 def _get_default_partner(self, cr, uid, context=None):
595 """ Override of base_stage to add project specific behavior """
596 project_id = self._get_default_project_id(cr, uid, context)
598 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
599 if project and project.partner_id:
600 return project.partner_id.id
601 return super(task, self)._get_default_partner(cr, uid, context=context)
603 def _get_default_project_id(self, cr, uid, context=None):
604 """ Gives default section by checking if present in the context """
605 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
607 def _get_default_stage_id(self, cr, uid, context=None):
608 """ Gives default stage_id """
609 project_id = self._get_default_project_id(cr, uid, context=context)
610 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
612 def _resolve_project_id_from_context(self, cr, uid, context=None):
613 """ Returns ID of project based on the value of 'default_project_id'
614 context key, or None if it cannot be resolved to a single
619 if type(context.get('default_project_id')) in (int, long):
620 return context['default_project_id']
621 if isinstance(context.get('default_project_id'), basestring):
622 project_name = context['default_project_id']
623 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
624 if len(project_ids) == 1:
625 return project_ids[0][0]
628 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
629 stage_obj = self.pool.get('project.task.type')
630 order = stage_obj._order
631 access_rights_uid = access_rights_uid or uid
632 if read_group_order == 'stage_id desc':
633 order = '%s desc' % order
635 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
637 search_domain += ['|', ('project_ids', '=', project_id)]
638 search_domain += [('id', 'in', ids)]
639 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
640 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
641 # restore order of the search
642 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
645 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
646 fold[stage.id] = stage.fold or False
649 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
650 res_users = self.pool.get('res.users')
651 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
652 access_rights_uid = access_rights_uid or uid
654 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
655 order = res_users._order
656 # lame way to allow reverting search, should just work in the trivial case
657 if read_group_order == 'user_id desc':
658 order = '%s desc' % order
659 # de-duplicate and apply search order
660 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
661 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
662 # restore order of the search
663 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
667 'stage_id': _read_group_stage_ids,
668 'user_id': _read_group_user_id,
671 def _str_get(self, task, level=0, border='***', context=None):
672 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'+ \
673 border[0]+' '+(task.name or '')+'\n'+ \
674 (task.description or '')+'\n\n'
676 # Compute: effective_hours, total_hours, progress
677 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
679 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
680 hours = dict(cr.fetchall())
681 for task in self.browse(cr, uid, ids, context=context):
682 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)}
683 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
684 res[task.id]['progress'] = 0.0
685 if (task.remaining_hours + hours.get(task.id, 0.0)):
686 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
687 if task.state in ('done','cancelled'):
688 res[task.id]['progress'] = 100.0
691 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
692 if remaining and not planned:
693 return {'value':{'planned_hours': remaining}}
696 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
697 return {'value':{'remaining_hours': planned - effective}}
699 def onchange_project(self, cr, uid, id, project_id, context=None):
701 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
702 if project and project.partner_id:
703 return {'value': {'partner_id': project.partner_id.id}}
706 def duplicate_task(self, cr, uid, map_ids, context=None):
707 for new in map_ids.values():
708 task = self.browse(cr, uid, new, context)
709 child_ids = [ ch.id for ch in task.child_ids]
711 for child in task.child_ids:
712 if child.id in map_ids.keys():
713 child_ids.remove(child.id)
714 child_ids.append(map_ids[child.id])
716 parent_ids = [ ch.id for ch in task.parent_ids]
718 for parent in task.parent_ids:
719 if parent.id in map_ids.keys():
720 parent_ids.remove(parent.id)
721 parent_ids.append(map_ids[parent.id])
722 #FIXME why there is already the copy and the old one
723 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
725 def copy_data(self, cr, uid, id, default=None, context=None):
728 default = default or {}
729 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
730 if not default.get('remaining_hours', False):
731 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
732 default['active'] = True
733 if not default.get('name', False):
734 default['name'] = self.browse(cr, uid, id, context=context).name or ''
735 if not context.get('copy',False):
736 new_name = _("%s (copy)") % (default.get('name', ''))
737 default.update({'name':new_name})
738 return super(task, self).copy_data(cr, uid, id, default, context)
740 def copy(self, cr, uid, id, default=None, context=None):
745 stage = self._get_default_stage_id(cr, uid, context=context)
747 default['stage_id'] = stage
748 return super(task, self).copy(cr, uid, id, default, context)
750 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
752 for task in self.browse(cr, uid, ids, context=context):
755 if task.project_id.active == False or task.project_id.state == 'template':
759 def _get_task(self, cr, uid, ids, context=None):
761 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
762 if work.task_id: result[work.task_id.id] = True
766 '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."),
767 'name': fields.char('Task Summary', size=128, required=True, select=True),
768 'description': fields.text('Description'),
769 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
770 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
771 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange',
772 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
773 'state': fields.related('stage_id', 'state', type="selection", store=True,
774 selection=_TASK_STATE, string="Status", readonly=True,
775 help='The status is set to \'Draft\', when a case is created.\
776 If the case is in progress the status is set to \'Open\'.\
777 When the case is over, the status is set to \'Done\'.\
778 If the case needs to be reviewed then the status is \
779 set to \'Pending\'.'),
780 'categ_ids': fields.many2many('project.category', string='Tags'),
781 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
782 track_visibility='onchange',
783 help="A task's kanban state indicates special situations affecting it:\n"
784 " * Normal is the default situation\n"
785 " * Blocked indicates something is preventing the progress of this task\n"
786 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
787 readonly=True, required=False),
788 'create_date': fields.datetime('Create Date', readonly=True, select=True),
789 '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)
790 'date_start': fields.datetime('Starting Date',select=True),
791 'date_end': fields.datetime('Ending Date',select=True),
792 'date_deadline': fields.date('Deadline',select=True),
793 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", track_visibility='onchange'),
794 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
795 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
796 'notes': fields.text('Notes'),
797 '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.'),
798 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
800 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
801 'project.task.work': (_get_task, ['hours'], 10),
803 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
804 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
806 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
807 'project.task.work': (_get_task, ['hours'], 10),
809 '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",
811 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours', 'state', 'stage_id'], 10),
812 'project.task.work': (_get_task, ['hours'], 10),
814 '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.",
816 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
817 'project.task.work': (_get_task, ['hours'], 10),
819 'user_id': fields.many2one('res.users', 'Assigned to', track_visibility='onchange'),
820 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
821 'partner_id': fields.many2one('res.partner', 'Customer'),
822 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
823 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
824 'company_id': fields.many2one('res.company', 'Company'),
825 'id': fields.integer('ID', readonly=True),
826 'color': fields.integer('Color Index'),
827 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
830 'stage_id': _get_default_stage_id,
831 'project_id': _get_default_project_id,
832 'kanban_state': 'normal',
837 'user_id': lambda obj, cr, uid, ctx=None: uid,
838 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
839 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
841 _order = "priority, sequence, date_start, name, id"
843 def set_high_priority(self, cr, uid, ids, *args):
844 """Set task priority to high
846 return self.write(cr, uid, ids, {'priority' : '0'})
848 def set_normal_priority(self, cr, uid, ids, *args):
849 """Set task priority to normal
851 return self.write(cr, uid, ids, {'priority' : '2'})
853 def _check_recursion(self, cr, uid, ids, context=None):
855 visited_branch = set()
857 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
863 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
864 if id in visited_branch: #Cycle
867 if id in visited_node: #Already tested don't work one more time for nothing
870 visited_branch.add(id)
873 #visit child using DFS
874 task = self.browse(cr, uid, id, context=context)
875 for child in task.child_ids:
876 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
880 visited_branch.remove(id)
883 def _check_dates(self, cr, uid, ids, context=None):
886 obj_task = self.browse(cr, uid, ids[0], context=context)
887 start = obj_task.date_start or False
888 end = obj_task.date_end or False
895 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
896 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
899 # Override view according to the company definition
900 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
901 users_obj = self.pool.get('res.users')
902 if context is None: context = {}
903 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
904 # this should be safe (no context passed to avoid side-effects)
905 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
906 tm = obj_tm and obj_tm.name or 'Hours'
908 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
910 if tm in ['Hours','Hour']:
913 eview = etree.fromstring(res['arch'])
915 def _check_rec(eview):
916 if eview.attrib.get('widget','') == 'float_time':
917 eview.set('widget','float')
924 res['arch'] = etree.tostring(eview)
926 for f in res['fields']:
927 if 'Hours' in res['fields'][f]['string']:
928 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
931 # ----------------------------------------
933 # ----------------------------------------
935 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
936 """ Override of the base.stage method
937 Parameter of the stage search taken from the lead:
938 - section_id: if set, stages must belong to this section or
939 be a default stage; if not set, stages must be default
942 if isinstance(cases, (int, long)):
943 cases = self.browse(cr, uid, cases, context=context)
944 # collect all section_ids
947 section_ids.append(section_id)
950 section_ids.append(task.project_id.id)
953 search_domain = [('|')] * (len(section_ids)-1)
954 for section_id in section_ids:
955 search_domain.append(('project_ids', '=', section_id))
956 search_domain += list(domain)
957 # perform search, return the first found
958 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
963 def _check_child_task(self, cr, uid, ids, context=None):
966 tasks = self.browse(cr, uid, ids, context=context)
969 for child in task.child_ids:
970 if child.state in ['draft', 'open', 'pending']:
971 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
974 def action_close(self, cr, uid, ids, context=None):
975 """ This action closes the task
977 task_id = len(ids) and ids[0] or False
978 self._check_child_task(cr, uid, ids, context=context)
979 if not task_id: return False
980 return self.do_close(cr, uid, [task_id], context=context)
982 def do_close(self, cr, uid, ids, context=None):
983 """ Compatibility when changing to case_close. """
984 return self.case_close(cr, uid, ids, context=context)
986 def case_close(self, cr, uid, ids, context=None):
988 if not isinstance(ids, list): ids = [ids]
989 for task in self.browse(cr, uid, ids, context=context):
991 project = task.project_id
992 for parent_id in task.parent_ids:
993 if parent_id.state in ('pending','draft'):
995 for child in parent_id.child_ids:
996 if child.id != task.id and child.state not in ('done','cancelled'):
999 self.do_reopen(cr, uid, [parent_id.id], context=context)
1001 vals['remaining_hours'] = 0.0
1002 if not task.date_end:
1003 vals['date_end'] = fields.datetime.now()
1004 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
1007 def do_reopen(self, cr, uid, ids, context=None):
1008 for task in self.browse(cr, uid, ids, context=context):
1009 project = task.project_id
1010 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1013 def do_cancel(self, cr, uid, ids, context=None):
1014 """ Compatibility when changing to case_cancel. """
1015 return self.case_cancel(cr, uid, ids, context=context)
1017 def case_cancel(self, cr, uid, ids, context=None):
1018 tasks = self.browse(cr, uid, ids, context=context)
1019 self._check_child_task(cr, uid, ids, context=context)
1021 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1024 def do_open(self, cr, uid, ids, context=None):
1025 """ Compatibility when changing to case_open. """
1026 return self.case_open(cr, uid, ids, context=context)
1028 def case_open(self, cr, uid, ids, context=None):
1029 if not isinstance(ids,list): ids = [ids]
1030 return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1032 def do_draft(self, cr, uid, ids, context=None):
1033 """ Compatibility when changing to case_draft. """
1034 return self.case_draft(cr, uid, ids, context=context)
1036 def case_draft(self, cr, uid, ids, context=None):
1037 return self.case_set(cr, uid, ids, 'draft', {}, context=context)
1039 def do_pending(self, cr, uid, ids, context=None):
1040 """ Compatibility when changing to case_pending. """
1041 return self.case_pending(cr, uid, ids, context=context)
1043 def case_pending(self, cr, uid, ids, context=None):
1044 return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1046 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1047 attachment = self.pool.get('ir.attachment')
1048 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1049 new_attachment_ids = []
1050 for attachment_id in attachment_ids:
1051 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1052 return new_attachment_ids
1054 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1056 Delegate Task to another users.
1058 if delegate_data is None:
1060 assert delegate_data['user_id'], _("Delegated User should be specified")
1061 delegated_tasks = {}
1062 for task in self.browse(cr, uid, ids, context=context):
1063 delegated_task_id = self.copy(cr, uid, task.id, {
1064 'name': delegate_data['name'],
1065 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1066 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1067 'planned_hours': delegate_data['planned_hours'] or 0.0,
1068 'parent_ids': [(6, 0, [task.id])],
1069 'description': delegate_data['new_task_description'] or '',
1073 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1074 newname = delegate_data['prefix'] or ''
1076 'remaining_hours': delegate_data['planned_hours_me'],
1077 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1080 if delegate_data['state'] == 'pending':
1081 self.do_pending(cr, uid, [task.id], context=context)
1082 elif delegate_data['state'] == 'done':
1083 self.do_close(cr, uid, [task.id], context=context)
1084 delegated_tasks[task.id] = delegated_task_id
1085 return delegated_tasks
1087 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1088 for task in self.browse(cr, uid, ids, context=context):
1089 if (task.state=='draft') or (task.planned_hours==0.0):
1090 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1091 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1094 def set_remaining_time_1(self, cr, uid, ids, context=None):
1095 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1097 def set_remaining_time_2(self, cr, uid, ids, context=None):
1098 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1100 def set_remaining_time_5(self, cr, uid, ids, context=None):
1101 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1103 def set_remaining_time_10(self, cr, uid, ids, context=None):
1104 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1106 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1107 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1109 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1110 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1112 def set_kanban_state_done(self, cr, uid, ids, context=None):
1113 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1116 def _store_history(self, cr, uid, ids, context=None):
1117 for task in self.browse(cr, uid, ids, context=context):
1118 self.pool.get('project.task.history').create(cr, uid, {
1120 'remaining_hours': task.remaining_hours,
1121 'planned_hours': task.planned_hours,
1122 'kanban_state': task.kanban_state,
1123 'type_id': task.stage_id.id,
1124 'state': task.state,
1125 'user_id': task.user_id.id
1130 def create(self, cr, uid, vals, context=None):
1133 if vals.get('project_id') and not context.get('default_project_id'):
1134 context['default_project_id'] = vals.get('project_id')
1136 # context: no_log, because subtype already handle this
1137 create_context = dict(context, mail_create_nolog=True)
1138 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1139 self._store_history(cr, uid, [task_id], context=context)
1142 # Overridden to reset the kanban_state to normal whenever
1143 # the stage (stage_id) of the task changes.
1144 def write(self, cr, uid, ids, vals, context=None):
1145 if isinstance(ids, (int, long)):
1147 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1148 new_stage = vals.get('stage_id')
1149 vals_reset_kstate = dict(vals, kanban_state='normal')
1150 for t in self.browse(cr, uid, ids, context=context):
1151 #TO FIX:Kanban view doesn't raise warning
1152 #stages = [stage.id for stage in t.project_id.type_ids]
1153 #if new_stage not in stages:
1154 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1155 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1156 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1159 result = super(task, self).write(cr, uid, ids, vals, context=context)
1160 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1161 self._store_history(cr, uid, ids, context=context)
1164 def unlink(self, cr, uid, ids, context=None):
1167 self._check_child_task(cr, uid, ids, context=context)
1168 res = super(task, self).unlink(cr, uid, ids, context)
1171 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1172 context = context or {}
1176 if task.state in ('done','cancelled'):
1181 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1183 for t2 in task.parent_ids:
1184 start.append("up.Task_%s.end" % (t2.id,))
1188 ''' % (ident,','.join(start))
1193 ''' % (ident, 'User_'+str(task.user_id.id))
1198 # ---------------------------------------------------
1200 # ---------------------------------------------------
1202 def message_get_reply_to(self, cr, uid, ids, context=None):
1203 """ Override to get the reply_to of the parent project. """
1204 return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1205 for task in self.browse(cr, uid, ids, context=context)]
1207 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1208 """ Override to updates the document according to the email. """
1209 if custom_values is None:
1212 'name': msg.get('subject'),
1213 'planned_hours': 0.0,
1215 defaults.update(custom_values)
1216 return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1218 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1219 """ Override to update the task according to the email. """
1220 if update_vals is None:
1223 'cost': 'planned_hours',
1225 for line in msg['body'].split('\n'):
1227 res = tools.command_re.match(line)
1229 match = res.group(1).lower()
1230 field = maps.get(match)
1233 update_vals[field] = float(res.group(2).lower())
1234 except (ValueError, TypeError):
1236 return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1238 def project_task_reevaluate(self, cr, uid, ids, context=None):
1239 if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1241 'view_type': 'form',
1242 "view_mode": 'form',
1243 'res_model': 'project.task.reevaluate',
1244 'type': 'ir.actions.act_window',
1247 return self.do_reopen(cr, uid, ids, context=context)
1249 class project_work(osv.osv):
1250 _name = "project.task.work"
1251 _description = "Project Task Work"
1253 'name': fields.char('Work summary', size=128),
1254 'date': fields.datetime('Date', select="1"),
1255 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1256 'hours': fields.float('Time Spent'),
1257 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1258 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1262 'user_id': lambda obj, cr, uid, context: uid,
1263 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1266 _order = "date desc"
1267 def create(self, cr, uid, vals, *args, **kwargs):
1268 if 'hours' in vals and (not vals['hours']):
1269 vals['hours'] = 0.00
1270 if 'task_id' in vals:
1271 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1272 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1274 def write(self, cr, uid, ids, vals, context=None):
1275 if 'hours' in vals and (not vals['hours']):
1276 vals['hours'] = 0.00
1278 for work in self.browse(cr, uid, ids, context=context):
1279 cr.execute('update project_task set remaining_hours=remaining_hours - %s + (%s) where id=%s', (vals.get('hours',0.0), work.hours, work.task_id.id))
1280 return super(project_work,self).write(cr, uid, ids, vals, context)
1282 def unlink(self, cr, uid, ids, *args, **kwargs):
1283 for work in self.browse(cr, uid, ids):
1284 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1285 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1288 class account_analytic_account(osv.osv):
1289 _inherit = 'account.analytic.account'
1290 _description = 'Analytic Account'
1292 'use_tasks': fields.boolean('Tasks',help="If checked, this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1293 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1296 def on_change_template(self, cr, uid, ids, template_id, context=None):
1297 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1298 if template_id and 'value' in res:
1299 template = self.browse(cr, uid, template_id, context=context)
1300 res['value']['use_tasks'] = template.use_tasks
1303 def _trigger_project_creation(self, cr, uid, vals, context=None):
1305 This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise.
1307 if context is None: context = {}
1308 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1310 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1312 This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet.
1314 project_pool = self.pool.get('project.project')
1315 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1316 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1318 'name': vals.get('name'),
1319 'analytic_account_id': analytic_account_id,
1320 'type': vals.get('type','contract'),
1322 return project_pool.create(cr, uid, project_values, context=context)
1325 def create(self, cr, uid, vals, context=None):
1328 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1329 vals['child_ids'] = []
1330 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1331 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1332 return analytic_account_id
1334 def write(self, cr, uid, ids, vals, context=None):
1335 if isinstance(ids, (int, long)):
1337 vals_for_project = vals.copy()
1338 for account in self.browse(cr, uid, ids, context=context):
1339 if not vals.get('name'):
1340 vals_for_project['name'] = account.name
1341 if not vals.get('type'):
1342 vals_for_project['type'] = account.type
1343 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1344 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1346 def unlink(self, cr, uid, ids, *args, **kwargs):
1347 project_obj = self.pool.get('project.project')
1348 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1350 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1351 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1353 def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
1358 if context.get('current_model') == 'project.project':
1359 project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context)
1360 return self.name_get(cr, uid, project_ids, context=context)
1362 return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit)
1365 class project_project(osv.osv):
1366 _inherit = 'project.project'
1371 class project_task_history(osv.osv):
1373 Tasks History, used for cumulative flow charts (Lean/Agile)
1375 _name = 'project.task.history'
1376 _description = 'History of Tasks'
1377 _rec_name = 'task_id'
1380 def _get_date(self, cr, uid, ids, name, arg, context=None):
1382 for history in self.browse(cr, uid, ids, context=context):
1383 if history.state in ('done','cancelled'):
1384 result[history.id] = history.date
1386 cr.execute('''select
1389 project_task_history
1393 order by id limit 1''', (history.task_id.id, history.id))
1395 result[history.id] = res and res[0] or False
1398 def _get_related_date(self, cr, uid, ids, context=None):
1400 for history in self.browse(cr, uid, ids, context=context):
1401 cr.execute('''select
1404 project_task_history
1408 order by id desc limit 1''', (history.task_id.id, history.id))
1411 result.append(res[0])
1415 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1416 'type_id': fields.many2one('project.task.type', 'Stage'),
1417 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1418 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1419 'date': fields.date('Date', select=True),
1420 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1421 'project.task.history': (_get_related_date, None, 20)
1423 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1424 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1425 'user_id': fields.many2one('res.users', 'Responsible'),
1428 'date': fields.date.context_today,
1431 class project_task_history_cumulative(osv.osv):
1432 _name = 'project.task.history.cumulative'
1433 _table = 'project_task_history_cumulative'
1434 _inherit = 'project.task.history'
1438 'end_date': fields.date('End Date'),
1439 'project_id': fields.many2one('project.project', 'Project'),
1443 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1445 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1447 history.date::varchar||'-'||history.history_id::varchar AS id,
1448 history.date AS end_date,
1453 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1454 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1455 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1458 project_task_history AS h
1459 JOIN project_task AS t ON (h.task_id = t.id)
1465 class project_category(osv.osv):
1466 """ Category of project's task (or issue) """
1467 _name = "project.category"
1468 _description = "Category of project's task, issue, ..."
1470 'name': fields.char('Name', size=64, required=True, translate=True),
1472 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: