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 base_status.base_stage import base_stage
23 from datetime import datetime, date
24 from lxml import etree
25 from osv import fields, osv
26 from openerp.addons.resource.faces import task as Task
28 from tools.translate import _
29 from openerp import SUPERUSER_ID
31 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
33 class project_task_type(osv.osv):
34 _name = 'project.task.type'
35 _description = 'Task Stage'
38 'name': fields.char('Stage Name', required=True, size=64, translate=True),
39 'description': fields.text('Description'),
40 'sequence': fields.integer('Sequence'),
41 'case_default': fields.boolean('Common to All Projects',
42 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."),
43 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
44 'state': fields.selection(_TASK_STATE, 'State', required=True,
45 help="The related state for the stage. The state of your document will automatically change regarding the selected stage. Example, a stage is related to the state 'Close', when your document reach this stage, it will be automatically closed."),
46 'fold': fields.boolean('Hide in views if empty',
47 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
58 """Keep first word(s) of name to make it small enough
60 if not name: return name
61 # keep 7 chars + end of the last word
62 keep_words = name[:7].strip().split()
63 return ' '.join(name.split()[:len(keep_words)])
65 class project(osv.osv):
66 _name = "project.project"
67 _description = "Project"
68 _inherits = {'account.analytic.account': "analytic_account_id",
69 "mail.alias": "alias_id"}
70 _inherit = ['mail.thread', 'ir.needaction_mixin']
72 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
74 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
75 if context and context.get('user_preference'):
76 cr.execute("""SELECT project.id FROM project_project project
77 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
78 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
79 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
80 return [(r[0]) for r in cr.fetchall()]
81 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
82 context=context, count=count)
84 def _complete_name(self, cr, uid, ids, name, args, context=None):
86 for m in self.browse(cr, uid, ids, context=context):
87 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
90 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
91 partner_obj = self.pool.get('res.partner')
95 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
96 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
97 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
98 val['pricelist_id'] = pricelist_id
101 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
102 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
103 project_ids = [task.project_id.id for task in tasks if task.project_id]
104 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
106 def _get_project_and_parents(self, cr, uid, ids, context=None):
107 """ return the project ids and all their parent projects """
111 SELECT DISTINCT parent.id
112 FROM project_project project, project_project parent, account_analytic_account account
113 WHERE project.analytic_account_id = account.id
114 AND parent.analytic_account_id = account.parent_id
117 ids = [t[0] for t in cr.fetchall()]
121 def _get_project_and_children(self, cr, uid, ids, context=None):
122 """ retrieve all children projects of project ids;
123 return a dictionary mapping each project to its parent project (or None)
125 res = dict.fromkeys(ids, None)
128 SELECT project.id, 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 dic = dict(cr.fetchall())
139 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
140 child_parent = self._get_project_and_children(cr, uid, ids, context)
141 # compute planned_hours, total_hours, effective_hours specific to each project
143 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
144 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
145 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
147 """, (tuple(child_parent.keys()),))
148 # aggregate results into res
149 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
150 for id, planned, total, effective in cr.fetchall():
151 # add the values specific to id to all parent projects of id in the result
154 res[id]['planned_hours'] += planned
155 res[id]['total_hours'] += total
156 res[id]['effective_hours'] += effective
157 id = child_parent[id]
158 # compute progress rates
160 if res[id]['total_hours']:
161 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
163 res[id]['progress_rate'] = 0.0
166 def unlink(self, cr, uid, ids, *args, **kwargs):
168 mail_alias = self.pool.get('mail.alias')
169 for proj in self.browse(cr, uid, ids):
171 raise osv.except_osv(_('Invalid Action!'),
172 _('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.'))
174 alias_ids.append(proj.alias_id.id)
175 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
176 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
179 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
180 res = dict.fromkeys(ids, 0)
181 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
182 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
183 res[task.project_id.id] += 1
186 def _get_alias_models(self, cr, uid, context=None):
187 """Overriden in project_issue to offer more options"""
188 return [('project.task', "Tasks")]
190 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
191 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
194 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
195 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
196 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
197 '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),
198 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
199 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
200 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)]}),
201 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
202 '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.",
204 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
205 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
207 '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.",
209 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
210 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
212 '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.",
214 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
215 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
217 '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.",
219 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
220 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
222 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
223 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
224 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
225 'color': fields.integer('Color Index'),
226 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
227 help="Internal email associated with this project. Incoming emails are automatically synchronized"
228 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
229 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
230 help="The kind of document created when an email is received on this project's email alias"),
231 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
232 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
235 def _get_type_common(self, cr, uid, context):
236 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
246 'type_ids': _get_type_common,
247 'alias_model': 'project.task',
248 'privacy_visibility': 'public',
249 'alias_domain': False, # always hide alias during creation
252 # TODO: Why not using a SQL contraints ?
253 def _check_dates(self, cr, uid, ids, context=None):
254 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
255 if leave['date_start'] and leave['date']:
256 if leave['date_start'] > leave['date']:
261 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
264 def set_template(self, cr, uid, ids, context=None):
265 res = self.setActive(cr, uid, ids, value=False, context=context)
268 def set_done(self, cr, uid, ids, context=None):
269 task_obj = self.pool.get('project.task')
270 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
271 task_obj.case_close(cr, uid, task_ids, context=context)
272 self.write(cr, uid, ids, {'state':'close'}, context=context)
273 self.set_close_send_note(cr, uid, ids, context=context)
276 def set_cancel(self, cr, uid, ids, context=None):
277 task_obj = self.pool.get('project.task')
278 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
279 task_obj.case_cancel(cr, uid, task_ids, context=context)
280 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
281 self.set_cancel_send_note(cr, uid, ids, context=context)
284 def set_pending(self, cr, uid, ids, context=None):
285 self.write(cr, uid, ids, {'state':'pending'}, context=context)
286 self.set_pending_send_note(cr, uid, ids, context=context)
289 def set_open(self, cr, uid, ids, context=None):
290 self.write(cr, uid, ids, {'state':'open'}, context=context)
291 self.set_open_send_note(cr, uid, ids, context=context)
294 def reset_project(self, cr, uid, ids, context=None):
295 res = self.setActive(cr, uid, ids, value=True, context=context)
296 self.set_open_send_note(cr, uid, ids, context=context)
299 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
300 """ copy and map tasks from old to new project """
304 task_obj = self.pool.get('project.task')
305 proj = self.browse(cr, uid, old_project_id, context=context)
306 for task in proj.tasks:
307 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
308 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
309 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
312 def copy(self, cr, uid, id, default={}, context=None):
316 default = default or {}
317 context['active_test'] = False
318 default['state'] = 'open'
319 default['tasks'] = []
320 default.pop('alias_name', None)
321 default.pop('alias_id', None)
322 proj = self.browse(cr, uid, id, context=context)
323 if not default.get('name', False):
324 default['name'] = proj.name + _(' (copy)')
325 res = super(project, self).copy(cr, uid, id, default, context)
326 self.map_tasks(cr,uid,id,res,context)
329 def duplicate_template(self, cr, uid, ids, context=None):
332 data_obj = self.pool.get('ir.model.data')
334 for proj in self.browse(cr, uid, ids, context=context):
335 parent_id = context.get('parent_id', False)
336 context.update({'analytic_project_copy': True})
337 new_date_start = time.strftime('%Y-%m-%d')
339 if proj.date_start and proj.date:
340 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
341 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
342 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
343 context.update({'copy':True})
344 new_id = self.copy(cr, uid, proj.id, default = {
345 'name': proj.name +_(' (copy)'),
347 'date_start':new_date_start,
349 'parent_id':parent_id}, context=context)
350 result.append(new_id)
352 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
353 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
355 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
357 if result and len(result):
359 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
360 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
361 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
362 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
363 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
364 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
366 'name': _('Projects'),
368 'view_mode': 'form,tree',
369 'res_model': 'project.project',
372 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
373 'type': 'ir.actions.act_window',
374 'search_view_id': search_view['res_id'],
378 # set active value for a project, its sub projects and its tasks
379 def setActive(self, cr, uid, ids, value=True, context=None):
380 task_obj = self.pool.get('project.task')
381 for proj in self.browse(cr, uid, ids, context=None):
382 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
383 cr.execute('select id from project_task where project_id=%s', (proj.id,))
384 tasks_id = [x[0] for x in cr.fetchall()]
386 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
387 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
389 self.setActive(cr, uid, child_ids, value, context=None)
392 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
393 context = context or {}
394 if type(ids) in (long, int,):
396 projects = self.browse(cr, uid, ids, context=context)
398 for project in projects:
399 if (not project.members) and force_members:
400 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
402 resource_pool = self.pool.get('resource.resource')
404 result = "from openerp.addons.resource.faces import *\n"
405 result += "import datetime\n"
406 for project in self.browse(cr, uid, ids, context=context):
407 u_ids = [i.id for i in project.members]
408 if project.user_id and (project.user_id.id not in u_ids):
409 u_ids.append(project.user_id.id)
410 for task in project.tasks:
411 if task.state in ('done','cancelled'):
413 if task.user_id and (task.user_id.id not in u_ids):
414 u_ids.append(task.user_id.id)
415 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
416 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
417 for key, vals in resource_objs.items():
419 class User_%s(Resource):
421 ''' % (key, vals.get('efficiency', False))
428 def _schedule_project(self, cr, uid, project, context=None):
429 resource_pool = self.pool.get('resource.resource')
430 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
431 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
432 # TODO: check if we need working_..., default values are ok.
433 puids = [x.id for x in project.members]
435 puids.append(project.user_id.id)
443 project.date_start, working_days,
444 '|'.join(['User_'+str(x) for x in puids])
446 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
453 #TODO: DO Resource allocation and compute availability
454 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
460 def schedule_tasks(self, cr, uid, ids, context=None):
461 context = context or {}
462 if type(ids) in (long, int,):
464 projects = self.browse(cr, uid, ids, context=context)
465 result = self._schedule_header(cr, uid, ids, False, context=context)
466 for project in projects:
467 result += self._schedule_project(cr, uid, project, context=context)
468 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
471 exec result in local_dict
472 projects_gantt = Task.BalancedProject(local_dict['Project'])
474 for project in projects:
475 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
476 for task in project.tasks:
477 if task.state in ('done','cancelled'):
480 p = getattr(project_gantt, 'Task_%d' % (task.id,))
482 self.pool.get('project.task').write(cr, uid, [task.id], {
483 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
484 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
486 if (not task.user_id) and (p.booked_resource):
487 self.pool.get('project.task').write(cr, uid, [task.id], {
488 'user_id': int(p.booked_resource[0].name[5:]),
492 # ------------------------------------------------
493 # OpenChatter methods and notifications
494 # ------------------------------------------------
496 def create(self, cr, uid, vals, context=None):
497 if context is None: context = {}
498 # Prevent double project creation when 'use_tasks' is checked!
499 context = dict(context, project_creation_in_progress=True)
500 mail_alias = self.pool.get('mail.alias')
501 if not vals.get('alias_id'):
502 vals.pop('alias_name', None) # prevent errors during copy()
503 alias_id = mail_alias.create_unique_alias(cr, uid,
504 # Using '+' allows using subaddressing for those who don't
505 # have a catchall domain setup.
506 {'alias_name': "project+"+short_name(vals['name'])},
507 model_name=vals.get('alias_model', 'project.task'),
509 vals['alias_id'] = alias_id
510 project_id = super(project, self).create(cr, uid, vals, context)
511 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
512 self.create_send_note(cr, uid, [project_id], context=context)
515 def create_send_note(self, cr, uid, ids, context=None):
516 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
518 def set_open_send_note(self, cr, uid, ids, context=None):
519 message = _("Project has been <b>opened</b>.")
520 return self.message_post(cr, uid, ids, body=message, context=context)
522 def set_pending_send_note(self, cr, uid, ids, context=None):
523 message = _("Project is now <b>pending</b>.")
524 return self.message_post(cr, uid, ids, body=message, context=context)
526 def set_cancel_send_note(self, cr, uid, ids, context=None):
527 message = _("Project has been <b>cancelled</b>.")
528 return self.message_post(cr, uid, ids, body=message, context=context)
530 def set_close_send_note(self, cr, uid, ids, context=None):
531 message = _("Project has been <b>closed</b>.")
532 return self.message_post(cr, uid, ids, body=message, context=context)
534 def write(self, cr, uid, ids, vals, context=None):
535 # if alias_model has been changed, update alias_model_id accordingly
536 if vals.get('alias_model'):
537 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
538 vals.update(alias_model_id=model_ids[0])
539 return super(project, self).write(cr, uid, ids, vals, context=context)
541 class task(base_stage, osv.osv):
542 _name = "project.task"
543 _description = "Task"
544 _date_name = "date_start"
545 _inherit = ['mail.thread', 'ir.needaction_mixin']
547 def _get_default_project_id(self, cr, uid, context=None):
548 """ Gives default section by checking if present in the context """
549 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
551 def _get_default_stage_id(self, cr, uid, context=None):
552 """ Gives default stage_id """
553 project_id = self._get_default_project_id(cr, uid, context=context)
554 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
556 def _resolve_project_id_from_context(self, cr, uid, context=None):
557 """ Returns ID of project based on the value of 'default_project_id'
558 context key, or None if it cannot be resolved to a single
561 if context is None: context = {}
562 if type(context.get('default_project_id')) in (int, long):
563 return context['default_project_id']
564 if isinstance(context.get('default_project_id'), basestring):
565 project_name = context['default_project_id']
566 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
567 if len(project_ids) == 1:
568 return project_ids[0][0]
571 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
572 stage_obj = self.pool.get('project.task.type')
573 order = stage_obj._order
574 access_rights_uid = access_rights_uid or uid
575 # lame way to allow reverting search, should just work in the trivial case
576 if read_group_order == 'stage_id desc':
577 order = '%s desc' % order
578 # retrieve section_id from the context and write the domain
579 # - ('id', 'in', 'ids'): add columns that should be present
580 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
581 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
583 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
585 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
586 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
587 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
588 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
589 # restore order of the search
590 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
593 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
594 res_users = self.pool.get('res.users')
595 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
596 access_rights_uid = access_rights_uid or uid
598 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
599 order = res_users._order
600 # lame way to allow reverting search, should just work in the trivial case
601 if read_group_order == 'user_id desc':
602 order = '%s desc' % order
603 # de-duplicate and apply search order
604 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
605 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
606 # restore order of the search
607 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
611 'stage_id': _read_group_stage_ids,
612 'user_id': _read_group_user_id,
615 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
616 obj_project = self.pool.get('project.project')
618 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
619 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
620 if id and isinstance(id, (long, int)):
621 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
622 args.append(('active', '=', False))
623 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
625 def _str_get(self, task, level=0, border='***', context=None):
626 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'+ \
627 border[0]+' '+(task.name or '')+'\n'+ \
628 (task.description or '')+'\n\n'
630 # Compute: effective_hours, total_hours, progress
631 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
633 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
634 hours = dict(cr.fetchall())
635 for task in self.browse(cr, uid, ids, context=context):
636 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)}
637 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
638 res[task.id]['progress'] = 0.0
639 if (task.remaining_hours + hours.get(task.id, 0.0)):
640 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
641 if task.state in ('done','cancelled'):
642 res[task.id]['progress'] = 100.0
645 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
646 if remaining and not planned:
647 return {'value':{'planned_hours': remaining}}
650 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
651 return {'value':{'remaining_hours': planned - effective}}
653 def onchange_project(self, cr, uid, id, project_id):
656 data = self.pool.get('project.project').browse(cr, uid, [project_id])
657 partner_id=data and data[0].partner_id
659 return {'value':{'partner_id':partner_id.id}}
662 def duplicate_task(self, cr, uid, map_ids, context=None):
663 for new in map_ids.values():
664 task = self.browse(cr, uid, new, context)
665 child_ids = [ ch.id for ch in task.child_ids]
667 for child in task.child_ids:
668 if child.id in map_ids.keys():
669 child_ids.remove(child.id)
670 child_ids.append(map_ids[child.id])
672 parent_ids = [ ch.id for ch in task.parent_ids]
674 for parent in task.parent_ids:
675 if parent.id in map_ids.keys():
676 parent_ids.remove(parent.id)
677 parent_ids.append(map_ids[parent.id])
678 #FIXME why there is already the copy and the old one
679 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
681 def copy_data(self, cr, uid, id, default={}, context=None):
682 default = default or {}
683 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
684 if not default.get('remaining_hours', False):
685 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
686 default['active'] = True
687 default['stage_id'] = False
688 if not default.get('name', False):
689 default['name'] = self.browse(cr, uid, id, context=context).name or ''
690 if not context.get('copy',False):
691 new_name = _("%s (copy)")%default.get('name','')
692 default.update({'name':new_name})
693 return super(task, self).copy_data(cr, uid, id, default, context)
696 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
698 for task in self.browse(cr, uid, ids, context=context):
701 if task.project_id.active == False or task.project_id.state == 'template':
705 def _get_task(self, cr, uid, ids, context=None):
707 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
708 if work.task_id: result[work.task_id.id] = True
712 '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."),
713 'name': fields.char('Task Summary', size=128, required=True, select=True),
714 'description': fields.text('Description'),
715 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
716 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
717 'stage_id': fields.many2one('project.task.type', 'Stage',
718 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
719 'state': fields.related('stage_id', 'state', type="selection", store=True,
720 selection=_TASK_STATE, string="State", readonly=True,
721 help='The state is set to \'Draft\', when a case is created.\
722 If the case is in progress the state is set to \'Open\'.\
723 When the case is over, the state is set to \'Done\'.\
724 If the case needs to be reviewed then the state is \
725 set to \'Pending\'.'),
726 'categ_ids': fields.many2many('project.category', string='Categories'),
727 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
728 help="A task's kanban state indicates special situations affecting it:\n"
729 " * Normal is the default situation\n"
730 " * Blocked indicates something is preventing the progress of this task\n"
731 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
732 readonly=True, required=False),
733 'create_date': fields.datetime('Create Date', readonly=True,select=True),
734 'date_start': fields.datetime('Starting Date',select=True),
735 'date_end': fields.datetime('Ending Date',select=True),
736 'date_deadline': fields.date('Deadline',select=True),
737 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
738 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
739 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
740 'notes': fields.text('Notes'),
741 '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.'),
742 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
744 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
745 'project.task.work': (_get_task, ['hours'], 10),
747 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
748 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
750 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
751 'project.task.work': (_get_task, ['hours'], 10),
753 '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",
755 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
756 'project.task.work': (_get_task, ['hours'], 10),
758 '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.",
760 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
761 'project.task.work': (_get_task, ['hours'], 10),
763 'user_id': fields.many2one('res.users', 'Assigned to'),
764 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
765 'partner_id': fields.many2one('res.partner', 'Contact'),
766 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
767 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
768 'company_id': fields.many2one('res.company', 'Company'),
769 'id': fields.integer('ID', readonly=True),
770 'color': fields.integer('Color Index'),
771 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
775 'stage_id': _get_default_stage_id,
776 'project_id': _get_default_project_id,
778 'kanban_state': 'normal',
783 'user_id': lambda obj, cr, uid, context: uid,
784 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
787 _order = "priority, sequence, date_start, name, id"
789 def set_priority(self, cr, uid, ids, priority, *args):
792 return self.write(cr, uid, ids, {'priority' : priority})
794 def set_high_priority(self, cr, uid, ids, *args):
795 """Set task priority to high
797 return self.set_priority(cr, uid, ids, '1')
799 def set_normal_priority(self, cr, uid, ids, *args):
800 """Set task priority to normal
802 return self.set_priority(cr, uid, ids, '2')
804 def _check_recursion(self, cr, uid, ids, context=None):
806 visited_branch = set()
808 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
814 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
815 if id in visited_branch: #Cycle
818 if id in visited_node: #Already tested don't work one more time for nothing
821 visited_branch.add(id)
824 #visit child using DFS
825 task = self.browse(cr, uid, id, context=context)
826 for child in task.child_ids:
827 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
831 visited_branch.remove(id)
834 def _check_dates(self, cr, uid, ids, context=None):
837 obj_task = self.browse(cr, uid, ids[0], context=context)
838 start = obj_task.date_start or False
839 end = obj_task.date_end or False
846 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
847 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
850 # Override view according to the company definition
852 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
853 users_obj = self.pool.get('res.users')
854 if context is None: context = {}
855 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
856 # this should be safe (no context passed to avoid side-effects)
857 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
858 tm = obj_tm and obj_tm.name or 'Hours'
860 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
862 if tm in ['Hours','Hour']:
865 eview = etree.fromstring(res['arch'])
867 def _check_rec(eview):
868 if eview.attrib.get('widget','') == 'float_time':
869 eview.set('widget','float')
876 res['arch'] = etree.tostring(eview)
878 for f in res['fields']:
879 if 'Hours' in res['fields'][f]['string']:
880 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
883 # ****************************************
885 # ****************************************
887 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
888 """ Override of the base.stage method
889 Parameter of the stage search taken from the lead:
890 - section_id: if set, stages must belong to this section or
891 be a default stage; if not set, stages must be default
894 if isinstance(cases, (int, long)):
895 cases = self.browse(cr, uid, cases, context=context)
896 # collect all section_ids
899 section_ids.append(section_id)
902 section_ids.append(task.project_id.id)
903 # OR all section_ids and OR with case_default
906 search_domain += [('|')] * len(section_ids)
907 for section_id in section_ids:
908 search_domain.append(('project_ids', '=', section_id))
909 search_domain.append(('case_default', '=', True))
910 # AND with the domain in parameter
911 search_domain += list(domain)
912 # perform search, return the first found
913 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
918 def _check_child_task(self, cr, uid, ids, context=None):
921 tasks = self.browse(cr, uid, ids, context=context)
924 for child in task.child_ids:
925 if child.state in ['draft', 'open', 'pending']:
926 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
929 def action_close(self, cr, uid, ids, context=None):
930 """ This action closes the task
932 task_id = len(ids) and ids[0] or False
933 self._check_child_task(cr, uid, ids, context=context)
934 if not task_id: return False
935 return self.do_close(cr, uid, [task_id], context=context)
937 def do_close(self, cr, uid, ids, context=None):
938 """ Compatibility when changing to case_close. """
939 return self.case_close(cr, uid, ids, context=context)
941 def case_close(self, cr, uid, ids, context=None):
943 if not isinstance(ids, list): ids = [ids]
944 for task in self.browse(cr, uid, ids, context=context):
946 project = task.project_id
947 for parent_id in task.parent_ids:
948 if parent_id.state in ('pending','draft'):
950 for child in parent_id.child_ids:
951 if child.id != task.id and child.state not in ('done','cancelled'):
954 self.do_reopen(cr, uid, [parent_id.id], context=context)
956 vals['remaining_hours'] = 0.0
957 if not task.date_end:
958 vals['date_end'] = fields.datetime.now()
959 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
960 self.case_close_send_note(cr, uid, [task.id], context=context)
963 def do_reopen(self, cr, uid, ids, context=None):
964 for task in self.browse(cr, uid, ids, context=context):
965 project = task.project_id
966 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
967 self.case_open_send_note(cr, uid, [task.id], context)
970 def do_cancel(self, cr, uid, ids, context=None):
971 """ Compatibility when changing to case_cancel. """
972 return self.case_cancel(cr, uid, ids, context=context)
974 def case_cancel(self, cr, uid, ids, context=None):
975 tasks = self.browse(cr, uid, ids, context=context)
976 self._check_child_task(cr, uid, ids, context=context)
978 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
979 self.case_cancel_send_note(cr, uid, [task.id], context=context)
982 def do_open(self, cr, uid, ids, context=None):
983 """ Compatibility when changing to case_open. """
984 return self.case_open(cr, uid, ids, context=context)
986 def case_open(self, cr, uid, ids, context=None):
987 if not isinstance(ids,list): ids = [ids]
988 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
989 self.case_open_send_note(cr, uid, ids, context)
992 def do_draft(self, cr, uid, ids, context=None):
993 """ Compatibility when changing to case_draft. """
994 return self.case_draft(cr, uid, ids, context=context)
996 def case_draft(self, cr, uid, ids, context=None):
997 self.case_set(cr, uid, ids, 'draft', {}, context=context)
998 self.case_draft_send_note(cr, uid, ids, context=context)
1001 def do_pending(self, cr, uid, ids, context=None):
1002 """ Compatibility when changing to case_pending. """
1003 return self.case_pending(cr, uid, ids, context=context)
1005 def case_pending(self, cr, uid, ids, context=None):
1006 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1007 return self.case_pending_send_note(cr, uid, ids, context=context)
1009 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1010 attachment = self.pool.get('ir.attachment')
1011 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1012 new_attachment_ids = []
1013 for attachment_id in attachment_ids:
1014 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1015 return new_attachment_ids
1017 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1019 Delegate Task to another users.
1021 assert delegate_data['user_id'], _("Delegated User should be specified")
1022 delegated_tasks = {}
1023 for task in self.browse(cr, uid, ids, context=context):
1024 delegated_task_id = self.copy(cr, uid, task.id, {
1025 'name': delegate_data['name'],
1026 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1027 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1028 'planned_hours': delegate_data['planned_hours'] or 0.0,
1029 'parent_ids': [(6, 0, [task.id])],
1031 'description': delegate_data['new_task_description'] or '',
1035 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1036 newname = delegate_data['prefix'] or ''
1038 'remaining_hours': delegate_data['planned_hours_me'],
1039 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1042 if delegate_data['state'] == 'pending':
1043 self.do_pending(cr, uid, [task.id], context=context)
1044 elif delegate_data['state'] == 'done':
1045 self.do_close(cr, uid, [task.id], context=context)
1046 self.do_delegation_send_note(cr, uid, [task.id], context)
1047 delegated_tasks[task.id] = delegated_task_id
1048 return delegated_tasks
1050 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1051 for task in self.browse(cr, uid, ids, context=context):
1052 if (task.state=='draft') or (task.planned_hours==0.0):
1053 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1054 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1057 def set_remaining_time_1(self, cr, uid, ids, context=None):
1058 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1060 def set_remaining_time_2(self, cr, uid, ids, context=None):
1061 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1063 def set_remaining_time_5(self, cr, uid, ids, context=None):
1064 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1066 def set_remaining_time_10(self, cr, uid, ids, context=None):
1067 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1069 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1070 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1073 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1074 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1077 def set_kanban_state_done(self, cr, uid, ids, context=None):
1078 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1081 def _store_history(self, cr, uid, ids, context=None):
1082 for task in self.browse(cr, uid, ids, context=context):
1083 self.pool.get('project.task.history').create(cr, uid, {
1085 'remaining_hours': task.remaining_hours,
1086 'planned_hours': task.planned_hours,
1087 'kanban_state': task.kanban_state,
1088 'type_id': task.stage_id.id,
1089 'state': task.state,
1090 'user_id': task.user_id.id
1095 def create(self, cr, uid, vals, context=None):
1096 task_id = super(task, self).create(cr, uid, vals, context=context)
1097 self._store_history(cr, uid, [task_id], context=context)
1098 self.create_send_note(cr, uid, [task_id], context=context)
1101 # Overridden to reset the kanban_state to normal whenever
1102 # the stage (stage_id) of the task changes.
1103 def write(self, cr, uid, ids, vals, context=None):
1104 if isinstance(ids, (int, long)):
1106 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1107 new_stage = vals.get('stage_id')
1108 vals_reset_kstate = dict(vals, kanban_state='normal')
1109 for t in self.browse(cr, uid, ids, context=context):
1110 #TO FIX:Kanban view doesn't raise warning
1111 #stages = [stage.id for stage in t.project_id.type_ids]
1112 #if new_stage not in stages:
1113 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1114 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1115 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1116 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1119 result = super(task,self).write(cr, uid, ids, vals, context=context)
1120 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1121 self._store_history(cr, uid, ids, context=context)
1124 def unlink(self, cr, uid, ids, context=None):
1127 self._check_child_task(cr, uid, ids, context=context)
1128 res = super(task, self).unlink(cr, uid, ids, context)
1131 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1132 context = context or {}
1136 if task.state in ('done','cancelled'):
1141 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1143 for t2 in task.parent_ids:
1144 start.append("up.Task_%s.end" % (t2.id,))
1148 ''' % (ident,','.join(start))
1153 ''' % (ident, 'User_'+str(task.user_id.id))
1158 # ---------------------------------------------------
1160 # ---------------------------------------------------
1162 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1163 """ Override to updates the document according to the email. """
1164 if custom_values is None: custom_values = {}
1165 custom_values.update({
1167 'planned_hours': 0.0,
1168 'subject': msg.get('subject'),
1170 return super(project_tasks,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1172 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1173 """ Override to update the task according to the email. """
1174 if update_vals is None: update_vals = {}
1177 'cost':'planned_hours',
1179 for line in msg['body'].split('\n'):
1181 res = tools.misc.command_re.match(line)
1183 match = res.group(1).lower()
1184 field = maps.get(match)
1187 update_vals[field] = float(res.group(2).lower())
1188 except (ValueError, TypeError):
1190 elif match.lower() == 'state' \
1191 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1192 act = 'do_%s' % res.group(2).lower()
1194 getattr(self,act)(cr, uid, ids, context=context)
1195 return super(project_tasks,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1197 # ---------------------------------------------------
1198 # OpenChatter methods and notifications
1199 # ---------------------------------------------------
1201 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1202 """ Override of default prefix for notifications. """
1205 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1206 """ Returns the user_ids that have to perform an action.
1207 Add to the previous results given by super the document responsible
1209 :return: dict { record_id: [user_ids], }
1211 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1212 for obj in self.browse(cr, uid, ids, context=context):
1213 if obj.state == 'draft' and obj.user_id:
1214 result[obj.id].append(obj.user_id.id)
1217 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1218 """ Add 'user_id' and 'manager_id' to the monitored fields """
1219 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1220 return res + ['user_id', 'manager_id']
1222 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1223 """ Override of the (void) default notification method. """
1224 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1225 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1227 def create_send_note(self, cr, uid, ids, context=None):
1228 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1230 def case_draft_send_note(self, cr, uid, ids, context=None):
1231 msg = _('Task has been set as <b>draft</b>.')
1232 return self.message_post(cr, uid, ids, body=msg, context=context)
1234 def do_delegation_send_note(self, cr, uid, ids, context=None):
1235 for task in self.browse(cr, uid, ids, context=context):
1236 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1237 self.message_post(cr, uid, [task.id], body=msg, context=context)
1241 class project_work(osv.osv):
1242 _name = "project.task.work"
1243 _description = "Project Task Work"
1245 'name': fields.char('Work summary', size=128),
1246 'date': fields.datetime('Date', select="1"),
1247 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1248 'hours': fields.float('Time Spent'),
1249 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1250 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1254 'user_id': lambda obj, cr, uid, context: uid,
1255 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1258 _order = "date desc"
1259 def create(self, cr, uid, vals, *args, **kwargs):
1260 if 'hours' in vals and (not vals['hours']):
1261 vals['hours'] = 0.00
1262 if 'task_id' in vals:
1263 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1264 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1266 def write(self, cr, uid, ids, vals, context=None):
1267 if 'hours' in vals and (not vals['hours']):
1268 vals['hours'] = 0.00
1270 for work in self.browse(cr, uid, ids, context=context):
1271 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))
1272 return super(project_work,self).write(cr, uid, ids, vals, context)
1274 def unlink(self, cr, uid, ids, *args, **kwargs):
1275 for work in self.browse(cr, uid, ids):
1276 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1277 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1280 class account_analytic_account(osv.osv):
1281 _inherit = 'account.analytic.account'
1282 _description = 'Analytic Account'
1284 'use_tasks': fields.boolean('Tasks',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1285 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1288 def on_change_template(self, cr, uid, ids, template_id, context=None):
1289 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1290 if template_id and 'value' in res:
1291 template = self.browse(cr, uid, template_id, context=context)
1292 res['value']['use_tasks'] = template.use_tasks
1295 def _trigger_project_creation(self, cr, uid, vals, context=None):
1297 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.
1299 if context is None: context = {}
1300 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1302 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1304 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.
1306 project_pool = self.pool.get('project.project')
1307 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1308 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1310 'name': vals.get('name'),
1311 'analytic_account_id': analytic_account_id,
1313 return project_pool.create(cr, uid, project_values, context=context)
1316 def create(self, cr, uid, vals, context=None):
1319 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1320 vals['child_ids'] = []
1321 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1322 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1323 return analytic_account_id
1325 def write(self, cr, uid, ids, vals, context=None):
1326 name = vals.get('name')
1327 for account in self.browse(cr, uid, ids, context=context):
1329 vals['name'] = account.name
1330 self.project_create(cr, uid, account.id, vals, context=context)
1331 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1333 def unlink(self, cr, uid, ids, *args, **kwargs):
1334 project_obj = self.pool.get('project.project')
1335 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1337 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1338 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1340 class project_project(osv.osv):
1341 _inherit = 'project.project'
1348 # Tasks History, used for cumulative flow charts (Lean/Agile)
1351 class project_task_history(osv.osv):
1352 _name = 'project.task.history'
1353 _description = 'History of Tasks'
1354 _rec_name = 'task_id'
1356 def _get_date(self, cr, uid, ids, name, arg, context=None):
1358 for history in self.browse(cr, uid, ids, context=context):
1359 if history.state in ('done','cancelled'):
1360 result[history.id] = history.date
1362 cr.execute('''select
1365 project_task_history
1369 order by id limit 1''', (history.task_id.id, history.id))
1371 result[history.id] = res and res[0] or False
1374 def _get_related_date(self, cr, uid, ids, context=None):
1376 for history in self.browse(cr, uid, ids, context=context):
1377 cr.execute('''select
1380 project_task_history
1384 order by id desc limit 1''', (history.task_id.id, history.id))
1387 result.append(res[0])
1391 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1392 'type_id': fields.many2one('project.task.type', 'Stage'),
1393 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1394 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1395 'date': fields.date('Date', select=True),
1396 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1397 'project.task.history': (_get_related_date, None, 20)
1399 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1400 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1401 'user_id': fields.many2one('res.users', 'Responsible'),
1404 'date': fields.date.context_today,
1408 class project_task_history_cumulative(osv.osv):
1409 _name = 'project.task.history.cumulative'
1410 _table = 'project_task_history_cumulative'
1411 _inherit = 'project.task.history'
1414 'end_date': fields.date('End Date'),
1415 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1418 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1420 history.date::varchar||'-'||history.history_id::varchar as id,
1421 history.date as end_date,
1426 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1427 task_id, type_id, user_id, kanban_state, state,
1428 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1430 project_task_history
1436 class project_category(osv.osv):
1437 """ Category of project's task (or issue) """
1438 _name = "project.category"
1439 _description = "Category of project's task, issue, ..."
1441 'name': fields.char('Name', size=64, required=True, translate=True),