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(
292 _get_attached_docs, string="Number of documents attached", type='integer'
296 def _get_type_common(self, cr, uid, context):
297 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
300 _order = "sequence, id"
307 'type_ids': _get_type_common,
308 'alias_model': 'project.task',
309 'privacy_visibility': 'employees',
310 'alias_domain': False, # always hide alias during creation
313 # TODO: Why not using a SQL contraints ?
314 def _check_dates(self, cr, uid, ids, context=None):
315 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
316 if leave['date_start'] and leave['date']:
317 if leave['date_start'] > leave['date']:
322 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
325 def set_template(self, cr, uid, ids, context=None):
326 res = self.setActive(cr, uid, ids, value=False, context=context)
329 def set_done(self, cr, uid, ids, context=None):
330 task_obj = self.pool.get('project.task')
331 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
332 task_obj.case_close(cr, uid, task_ids, context=context)
333 return self.write(cr, uid, ids, {'state':'close'}, context=context)
335 def set_cancel(self, cr, uid, ids, context=None):
336 task_obj = self.pool.get('project.task')
337 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
338 task_obj.case_cancel(cr, uid, task_ids, context=context)
339 return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
341 def set_pending(self, cr, uid, ids, context=None):
342 return self.write(cr, uid, ids, {'state':'pending'}, context=context)
344 def set_open(self, cr, uid, ids, context=None):
345 return self.write(cr, uid, ids, {'state':'open'}, context=context)
347 def reset_project(self, cr, uid, ids, context=None):
348 return self.setActive(cr, uid, ids, value=True, context=context)
350 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
351 """ copy and map tasks from old to new project """
355 task_obj = self.pool.get('project.task')
356 proj = self.browse(cr, uid, old_project_id, context=context)
357 for task in proj.tasks:
358 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
359 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
360 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
363 def copy(self, cr, uid, id, default=None, context=None):
369 context['active_test'] = False
370 default['state'] = 'open'
371 default['line_ids'] = []
372 default['tasks'] = []
373 default.pop('alias_name', None)
374 default.pop('alias_id', None)
375 proj = self.browse(cr, uid, id, context=context)
376 if not default.get('name', False):
377 default.update(name=_("%s (copy)") % (proj.name))
378 res = super(project, self).copy(cr, uid, id, default, context)
379 self.map_tasks(cr,uid,id,res,context)
382 def duplicate_template(self, cr, uid, ids, context=None):
385 data_obj = self.pool.get('ir.model.data')
387 for proj in self.browse(cr, uid, ids, context=context):
388 parent_id = context.get('parent_id', False)
389 context.update({'analytic_project_copy': True})
390 new_date_start = time.strftime('%Y-%m-%d')
392 if proj.date_start and proj.date:
393 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
394 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
395 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
396 context.update({'copy':True})
397 new_id = self.copy(cr, uid, proj.id, default = {
398 'name':_("%s (copy)") % (proj.name),
400 'date_start':new_date_start,
402 'parent_id':parent_id}, context=context)
403 result.append(new_id)
405 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
406 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
408 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
410 if result and len(result):
412 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
413 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
414 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
415 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
416 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
417 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
419 'name': _('Projects'),
421 'view_mode': 'form,tree',
422 'res_model': 'project.project',
425 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
426 'type': 'ir.actions.act_window',
427 'search_view_id': search_view['res_id'],
431 # set active value for a project, its sub projects and its tasks
432 def setActive(self, cr, uid, ids, value=True, context=None):
433 task_obj = self.pool.get('project.task')
434 for proj in self.browse(cr, uid, ids, context=None):
435 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
436 cr.execute('select id from project_task where project_id=%s', (proj.id,))
437 tasks_id = [x[0] for x in cr.fetchall()]
439 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
440 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
442 self.setActive(cr, uid, child_ids, value, context=None)
445 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
446 context = context or {}
447 if type(ids) in (long, int,):
449 projects = self.browse(cr, uid, ids, context=context)
451 for project in projects:
452 if (not project.members) and force_members:
453 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
455 resource_pool = self.pool.get('resource.resource')
457 result = "from openerp.addons.resource.faces import *\n"
458 result += "import datetime\n"
459 for project in self.browse(cr, uid, ids, context=context):
460 u_ids = [i.id for i in project.members]
461 if project.user_id and (project.user_id.id not in u_ids):
462 u_ids.append(project.user_id.id)
463 for task in project.tasks:
464 if task.state in ('done','cancelled'):
466 if task.user_id and (task.user_id.id not in u_ids):
467 u_ids.append(task.user_id.id)
468 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
469 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
470 for key, vals in resource_objs.items():
472 class User_%s(Resource):
474 ''' % (key, vals.get('efficiency', False))
481 def _schedule_project(self, cr, uid, project, context=None):
482 resource_pool = self.pool.get('resource.resource')
483 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
484 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
485 # TODO: check if we need working_..., default values are ok.
486 puids = [x.id for x in project.members]
488 puids.append(project.user_id.id)
496 project.date_start or time.strftime('%Y-%m-%d'), working_days,
497 '|'.join(['User_'+str(x) for x in puids])
499 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
506 #TODO: DO Resource allocation and compute availability
507 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
513 def schedule_tasks(self, cr, uid, ids, context=None):
514 context = context or {}
515 if type(ids) in (long, int,):
517 projects = self.browse(cr, uid, ids, context=context)
518 result = self._schedule_header(cr, uid, ids, False, context=context)
519 for project in projects:
520 result += self._schedule_project(cr, uid, project, context=context)
521 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
524 exec result in local_dict
525 projects_gantt = Task.BalancedProject(local_dict['Project'])
527 for project in projects:
528 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
529 for task in project.tasks:
530 if task.state in ('done','cancelled'):
533 p = getattr(project_gantt, 'Task_%d' % (task.id,))
535 self.pool.get('project.task').write(cr, uid, [task.id], {
536 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
537 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
539 if (not task.user_id) and (p.booked_resource):
540 self.pool.get('project.task').write(cr, uid, [task.id], {
541 'user_id': int(p.booked_resource[0].name[5:]),
545 # ------------------------------------------------
546 # OpenChatter methods and notifications
547 # ------------------------------------------------
549 def create(self, cr, uid, vals, context=None):
550 if context is None: context = {}
551 # Prevent double project creation when 'use_tasks' is checked!
552 context = dict(context, project_creation_in_progress=True)
553 mail_alias = self.pool.get('mail.alias')
554 if not vals.get('alias_id') and vals.get('name', False):
555 vals.pop('alias_name', None) # prevent errors during copy()
556 alias_id = mail_alias.create_unique_alias(cr, uid,
557 # Using '+' allows using subaddressing for those who don't
558 # have a catchall domain setup.
559 {'alias_name': "project+"+short_name(vals['name'])},
560 model_name=vals.get('alias_model', 'project.task'),
562 vals['alias_id'] = alias_id
563 if vals.get('type', False) not in ('template','contract'):
564 vals['type'] = 'contract'
565 project_id = super(project, self).create(cr, uid, vals, context)
566 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
569 def write(self, cr, uid, ids, vals, context=None):
570 # if alias_model has been changed, update alias_model_id accordingly
571 if vals.get('alias_model'):
572 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
573 vals.update(alias_model_id=model_ids[0])
574 return super(project, self).write(cr, uid, ids, vals, context=context)
576 class task(base_stage, osv.osv):
577 _name = "project.task"
578 _description = "Task"
579 _date_name = "date_start"
580 _inherit = ['mail.thread', 'ir.needaction_mixin']
584 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
585 'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
586 'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
589 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'done', 'open'],
591 'kanban_state': { # kanban state: tracked, but only block subtype
592 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
596 def _get_default_partner(self, cr, uid, context=None):
597 """ Override of base_stage to add project specific behavior """
598 project_id = self._get_default_project_id(cr, uid, context)
600 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
601 if project and project.partner_id:
602 return project.partner_id.id
603 return super(task, self)._get_default_partner(cr, uid, context=context)
605 def _get_default_project_id(self, cr, uid, context=None):
606 """ Gives default section by checking if present in the context """
607 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
609 def _get_default_stage_id(self, cr, uid, context=None):
610 """ Gives default stage_id """
611 project_id = self._get_default_project_id(cr, uid, context=context)
612 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
614 def _resolve_project_id_from_context(self, cr, uid, context=None):
615 """ Returns ID of project based on the value of 'default_project_id'
616 context key, or None if it cannot be resolved to a single
621 if type(context.get('default_project_id')) in (int, long):
622 return context['default_project_id']
623 if isinstance(context.get('default_project_id'), basestring):
624 project_name = context['default_project_id']
625 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
626 if len(project_ids) == 1:
627 return project_ids[0][0]
630 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
631 stage_obj = self.pool.get('project.task.type')
632 order = stage_obj._order
633 access_rights_uid = access_rights_uid or uid
634 if read_group_order == 'stage_id desc':
635 order = '%s desc' % order
637 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
639 search_domain += ['|', ('project_ids', '=', project_id)]
640 search_domain += [('id', 'in', ids)]
641 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
642 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
643 # restore order of the search
644 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
647 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
648 fold[stage.id] = stage.fold or False
651 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
652 res_users = self.pool.get('res.users')
653 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
654 access_rights_uid = access_rights_uid or uid
656 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
657 order = res_users._order
658 # lame way to allow reverting search, should just work in the trivial case
659 if read_group_order == 'user_id desc':
660 order = '%s desc' % order
661 # de-duplicate and apply search order
662 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
663 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
664 # restore order of the search
665 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
669 'stage_id': _read_group_stage_ids,
670 'user_id': _read_group_user_id,
673 def _str_get(self, task, level=0, border='***', context=None):
674 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'+ \
675 border[0]+' '+(task.name or '')+'\n'+ \
676 (task.description or '')+'\n\n'
678 # Compute: effective_hours, total_hours, progress
679 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
681 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
682 hours = dict(cr.fetchall())
683 for task in self.browse(cr, uid, ids, context=context):
684 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)}
685 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
686 res[task.id]['progress'] = 0.0
687 if (task.remaining_hours + hours.get(task.id, 0.0)):
688 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
689 if task.state in ('done','cancelled'):
690 res[task.id]['progress'] = 100.0
693 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
694 if remaining and not planned:
695 return {'value':{'planned_hours': remaining}}
698 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
699 return {'value':{'remaining_hours': planned - effective}}
701 def onchange_project(self, cr, uid, id, project_id, context=None):
703 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
704 if project and project.partner_id:
705 return {'value': {'partner_id': project.partner_id.id}}
708 def duplicate_task(self, cr, uid, map_ids, context=None):
709 for new in map_ids.values():
710 task = self.browse(cr, uid, new, context)
711 child_ids = [ ch.id for ch in task.child_ids]
713 for child in task.child_ids:
714 if child.id in map_ids.keys():
715 child_ids.remove(child.id)
716 child_ids.append(map_ids[child.id])
718 parent_ids = [ ch.id for ch in task.parent_ids]
720 for parent in task.parent_ids:
721 if parent.id in map_ids.keys():
722 parent_ids.remove(parent.id)
723 parent_ids.append(map_ids[parent.id])
724 #FIXME why there is already the copy and the old one
725 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
727 def copy_data(self, cr, uid, id, default=None, context=None):
730 default = default or {}
731 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
732 if not default.get('remaining_hours', False):
733 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
734 default['active'] = True
735 if not default.get('name', False):
736 default['name'] = self.browse(cr, uid, id, context=context).name or ''
737 if not context.get('copy',False):
738 new_name = _("%s (copy)") % (default.get('name', ''))
739 default.update({'name':new_name})
740 return super(task, self).copy_data(cr, uid, id, default, context)
742 def copy(self, cr, uid, id, default=None, context=None):
747 stage = self._get_default_stage_id(cr, uid, context=context)
749 default['stage_id'] = stage
750 return super(task, self).copy(cr, uid, id, default, context)
752 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
754 for task in self.browse(cr, uid, ids, context=context):
757 if task.project_id.active == False or task.project_id.state == 'template':
761 def _get_task(self, cr, uid, ids, context=None):
763 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
764 if work.task_id: result[work.task_id.id] = True
768 '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."),
769 'name': fields.char('Task Summary', size=128, required=True, select=True),
770 'description': fields.text('Description'),
771 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
772 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
773 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange',
774 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
775 'state': fields.related('stage_id', 'state', type="selection", store=True,
776 selection=_TASK_STATE, string="Status", readonly=True,
777 help='The status is set to \'Draft\', when a case is created.\
778 If the case is in progress the status is set to \'Open\'.\
779 When the case is over, the status is set to \'Done\'.\
780 If the case needs to be reviewed then the status is \
781 set to \'Pending\'.'),
782 'categ_ids': fields.many2many('project.category', string='Tags'),
783 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
784 track_visibility='onchange',
785 help="A task's kanban state indicates special situations affecting it:\n"
786 " * Normal is the default situation\n"
787 " * Blocked indicates something is preventing the progress of this task\n"
788 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
789 readonly=True, required=False),
790 'create_date': fields.datetime('Create Date', readonly=True, select=True),
791 '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)
792 'date_start': fields.datetime('Starting Date',select=True),
793 'date_end': fields.datetime('Ending Date',select=True),
794 'date_deadline': fields.date('Deadline',select=True),
795 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", track_visibility='onchange'),
796 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
797 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
798 'notes': fields.text('Notes'),
799 '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.'),
800 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
802 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
803 'project.task.work': (_get_task, ['hours'], 10),
805 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
806 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
808 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
809 'project.task.work': (_get_task, ['hours'], 10),
811 '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",
813 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
814 'project.task.work': (_get_task, ['hours'], 10),
816 '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.",
818 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
819 'project.task.work': (_get_task, ['hours'], 10),
821 'user_id': fields.many2one('res.users', 'Assigned to', track_visibility='onchange'),
822 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
823 'partner_id': fields.many2one('res.partner', 'Customer'),
824 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
825 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
826 'company_id': fields.many2one('res.company', 'Company'),
827 'id': fields.integer('ID', readonly=True),
828 'color': fields.integer('Color Index'),
829 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
832 'stage_id': _get_default_stage_id,
833 'project_id': _get_default_project_id,
834 'kanban_state': 'normal',
839 'user_id': lambda obj, cr, uid, ctx=None: uid,
840 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
841 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
843 _order = "priority, sequence, date_start, name, id"
845 def set_high_priority(self, cr, uid, ids, *args):
846 """Set task priority to high
848 return self.write(cr, uid, ids, {'priority' : '0'})
850 def set_normal_priority(self, cr, uid, ids, *args):
851 """Set task priority to normal
853 return self.write(cr, uid, ids, {'priority' : '2'})
855 def _check_recursion(self, cr, uid, ids, context=None):
857 visited_branch = set()
859 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
865 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
866 if id in visited_branch: #Cycle
869 if id in visited_node: #Already tested don't work one more time for nothing
872 visited_branch.add(id)
875 #visit child using DFS
876 task = self.browse(cr, uid, id, context=context)
877 for child in task.child_ids:
878 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
882 visited_branch.remove(id)
885 def _check_dates(self, cr, uid, ids, context=None):
888 obj_task = self.browse(cr, uid, ids[0], context=context)
889 start = obj_task.date_start or False
890 end = obj_task.date_end or False
897 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
898 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
901 # Override view according to the company definition
902 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
903 users_obj = self.pool.get('res.users')
904 if context is None: context = {}
905 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
906 # this should be safe (no context passed to avoid side-effects)
907 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
908 tm = obj_tm and obj_tm.name or 'Hours'
910 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
912 if tm in ['Hours','Hour']:
915 eview = etree.fromstring(res['arch'])
917 def _check_rec(eview):
918 if eview.attrib.get('widget','') == 'float_time':
919 eview.set('widget','float')
926 res['arch'] = etree.tostring(eview)
928 for f in res['fields']:
929 if 'Hours' in res['fields'][f]['string']:
930 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
933 # ----------------------------------------
935 # ----------------------------------------
937 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
938 """ Override of the base.stage method
939 Parameter of the stage search taken from the lead:
940 - section_id: if set, stages must belong to this section or
941 be a default stage; if not set, stages must be default
944 if isinstance(cases, (int, long)):
945 cases = self.browse(cr, uid, cases, context=context)
946 # collect all section_ids
949 section_ids.append(section_id)
952 section_ids.append(task.project_id.id)
955 search_domain = [('|')] * (len(section_ids)-1)
956 for section_id in section_ids:
957 search_domain.append(('project_ids', '=', section_id))
958 search_domain += list(domain)
959 # perform search, return the first found
960 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
965 def _check_child_task(self, cr, uid, ids, context=None):
968 tasks = self.browse(cr, uid, ids, context=context)
971 for child in task.child_ids:
972 if child.state in ['draft', 'open', 'pending']:
973 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
976 def action_close(self, cr, uid, ids, context=None):
977 """ This action closes the task
979 task_id = len(ids) and ids[0] or False
980 self._check_child_task(cr, uid, ids, context=context)
981 if not task_id: return False
982 return self.do_close(cr, uid, [task_id], context=context)
984 def do_close(self, cr, uid, ids, context=None):
985 """ Compatibility when changing to case_close. """
986 return self.case_close(cr, uid, ids, context=context)
988 def case_close(self, cr, uid, ids, context=None):
990 if not isinstance(ids, list): ids = [ids]
991 for task in self.browse(cr, uid, ids, context=context):
993 project = task.project_id
994 for parent_id in task.parent_ids:
995 if parent_id.state in ('pending','draft'):
997 for child in parent_id.child_ids:
998 if child.id != task.id and child.state not in ('done','cancelled'):
1001 self.do_reopen(cr, uid, [parent_id.id], context=context)
1003 vals['remaining_hours'] = 0.0
1004 if not task.date_end:
1005 vals['date_end'] = fields.datetime.now()
1006 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
1009 def do_reopen(self, cr, uid, ids, context=None):
1010 for task in self.browse(cr, uid, ids, context=context):
1011 project = task.project_id
1012 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1015 def do_cancel(self, cr, uid, ids, context=None):
1016 """ Compatibility when changing to case_cancel. """
1017 return self.case_cancel(cr, uid, ids, context=context)
1019 def case_cancel(self, cr, uid, ids, context=None):
1020 tasks = self.browse(cr, uid, ids, context=context)
1021 self._check_child_task(cr, uid, ids, context=context)
1023 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1026 def do_open(self, cr, uid, ids, context=None):
1027 """ Compatibility when changing to case_open. """
1028 return self.case_open(cr, uid, ids, context=context)
1030 def case_open(self, cr, uid, ids, context=None):
1031 if not isinstance(ids,list): ids = [ids]
1032 return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1034 def do_draft(self, cr, uid, ids, context=None):
1035 """ Compatibility when changing to case_draft. """
1036 return self.case_draft(cr, uid, ids, context=context)
1038 def case_draft(self, cr, uid, ids, context=None):
1039 return self.case_set(cr, uid, ids, 'draft', {}, context=context)
1041 def do_pending(self, cr, uid, ids, context=None):
1042 """ Compatibility when changing to case_pending. """
1043 return self.case_pending(cr, uid, ids, context=context)
1045 def case_pending(self, cr, uid, ids, context=None):
1046 return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1048 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1049 attachment = self.pool.get('ir.attachment')
1050 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1051 new_attachment_ids = []
1052 for attachment_id in attachment_ids:
1053 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1054 return new_attachment_ids
1056 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1058 Delegate Task to another users.
1060 if delegate_data is None:
1062 assert delegate_data['user_id'], _("Delegated User should be specified")
1063 delegated_tasks = {}
1064 for task in self.browse(cr, uid, ids, context=context):
1065 delegated_task_id = self.copy(cr, uid, task.id, {
1066 'name': delegate_data['name'],
1067 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1068 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1069 'planned_hours': delegate_data['planned_hours'] or 0.0,
1070 'parent_ids': [(6, 0, [task.id])],
1071 'description': delegate_data['new_task_description'] or '',
1075 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1076 newname = delegate_data['prefix'] or ''
1078 'remaining_hours': delegate_data['planned_hours_me'],
1079 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1082 if delegate_data['state'] == 'pending':
1083 self.do_pending(cr, uid, [task.id], context=context)
1084 elif delegate_data['state'] == 'done':
1085 self.do_close(cr, uid, [task.id], context=context)
1086 delegated_tasks[task.id] = delegated_task_id
1087 return delegated_tasks
1089 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1090 for task in self.browse(cr, uid, ids, context=context):
1091 if (task.state=='draft') or (task.planned_hours==0.0):
1092 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1093 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1096 def set_remaining_time_1(self, cr, uid, ids, context=None):
1097 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1099 def set_remaining_time_2(self, cr, uid, ids, context=None):
1100 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1102 def set_remaining_time_5(self, cr, uid, ids, context=None):
1103 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1105 def set_remaining_time_10(self, cr, uid, ids, context=None):
1106 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1108 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1109 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1111 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1112 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1114 def set_kanban_state_done(self, cr, uid, ids, context=None):
1115 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1118 def _store_history(self, cr, uid, ids, context=None):
1119 for task in self.browse(cr, uid, ids, context=context):
1120 self.pool.get('project.task.history').create(cr, uid, {
1122 'remaining_hours': task.remaining_hours,
1123 'planned_hours': task.planned_hours,
1124 'kanban_state': task.kanban_state,
1125 'type_id': task.stage_id.id,
1126 'state': task.state,
1127 'user_id': task.user_id.id
1132 def create(self, cr, uid, vals, context=None):
1135 if vals.get('project_id') and not context.get('default_project_id'):
1136 context['default_project_id'] = vals.get('project_id')
1138 # context: no_log, because subtype already handle this
1139 create_context = dict(context, mail_create_nolog=True)
1140 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1141 self._store_history(cr, uid, [task_id], context=context)
1144 # Overridden to reset the kanban_state to normal whenever
1145 # the stage (stage_id) of the task changes.
1146 def write(self, cr, uid, ids, vals, context=None):
1147 if isinstance(ids, (int, long)):
1149 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1150 new_stage = vals.get('stage_id')
1151 vals_reset_kstate = dict(vals, kanban_state='normal')
1152 for t in self.browse(cr, uid, ids, context=context):
1153 #TO FIX:Kanban view doesn't raise warning
1154 #stages = [stage.id for stage in t.project_id.type_ids]
1155 #if new_stage not in stages:
1156 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1157 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1158 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1161 result = super(task, self).write(cr, uid, ids, vals, context=context)
1162 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1163 self._store_history(cr, uid, ids, context=context)
1166 def unlink(self, cr, uid, ids, context=None):
1169 self._check_child_task(cr, uid, ids, context=context)
1170 res = super(task, self).unlink(cr, uid, ids, context)
1173 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1174 context = context or {}
1178 if task.state in ('done','cancelled'):
1183 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1185 for t2 in task.parent_ids:
1186 start.append("up.Task_%s.end" % (t2.id,))
1190 ''' % (ident,','.join(start))
1195 ''' % (ident, 'User_'+str(task.user_id.id))
1200 # ---------------------------------------------------
1202 # ---------------------------------------------------
1204 def message_get_reply_to(self, cr, uid, ids, context=None):
1205 """ Override to get the reply_to of the parent project. """
1206 return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1207 for task in self.browse(cr, uid, ids, context=context)]
1209 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1210 """ Override to updates the document according to the email. """
1211 if custom_values is None:
1214 'name': msg.get('subject'),
1215 'planned_hours': 0.0,
1217 defaults.update(custom_values)
1218 return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1220 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1221 """ Override to update the task according to the email. """
1222 if update_vals is None:
1225 'cost': 'planned_hours',
1227 for line in msg['body'].split('\n'):
1229 res = tools.command_re.match(line)
1231 match = res.group(1).lower()
1232 field = maps.get(match)
1235 update_vals[field] = float(res.group(2).lower())
1236 except (ValueError, TypeError):
1238 return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1240 def project_task_reevaluate(self, cr, uid, ids, context=None):
1241 if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1243 'view_type': 'form',
1244 "view_mode": 'form',
1245 'res_model': 'project.task.reevaluate',
1246 'type': 'ir.actions.act_window',
1249 return self.do_reopen(cr, uid, ids, context=context)
1251 class project_work(osv.osv):
1252 _name = "project.task.work"
1253 _description = "Project Task Work"
1255 'name': fields.char('Work summary', size=128),
1256 'date': fields.datetime('Date', select="1"),
1257 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1258 'hours': fields.float('Time Spent'),
1259 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1260 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1264 'user_id': lambda obj, cr, uid, context: uid,
1265 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1268 _order = "date desc"
1269 def create(self, cr, uid, vals, *args, **kwargs):
1270 if 'hours' in vals and (not vals['hours']):
1271 vals['hours'] = 0.00
1272 if 'task_id' in vals:
1273 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1274 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1276 def write(self, cr, uid, ids, vals, context=None):
1277 if 'hours' in vals and (not vals['hours']):
1278 vals['hours'] = 0.00
1280 for work in self.browse(cr, uid, ids, context=context):
1281 cr.execute('update project_task set remaining_hours=remaining_hours - %s + (%s) where id=%s', (vals.get('hours',0.0), work.hours, work.task_id.id))
1282 return super(project_work,self).write(cr, uid, ids, vals, context)
1284 def unlink(self, cr, uid, ids, *args, **kwargs):
1285 for work in self.browse(cr, uid, ids):
1286 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1287 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1290 class account_analytic_account(osv.osv):
1291 _inherit = 'account.analytic.account'
1292 _description = 'Analytic Account'
1294 'use_tasks': fields.boolean('Tasks',help="If checked, this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1295 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1298 def on_change_template(self, cr, uid, ids, template_id, context=None):
1299 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1300 if template_id and 'value' in res:
1301 template = self.browse(cr, uid, template_id, context=context)
1302 res['value']['use_tasks'] = template.use_tasks
1305 def _trigger_project_creation(self, cr, uid, vals, context=None):
1307 This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise.
1309 if context is None: context = {}
1310 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1312 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1314 This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet.
1316 project_pool = self.pool.get('project.project')
1317 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1318 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1320 'name': vals.get('name'),
1321 'analytic_account_id': analytic_account_id,
1322 'type': vals.get('type','contract'),
1324 return project_pool.create(cr, uid, project_values, context=context)
1327 def create(self, cr, uid, vals, context=None):
1330 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1331 vals['child_ids'] = []
1332 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1333 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1334 return analytic_account_id
1336 def write(self, cr, uid, ids, vals, context=None):
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: