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 _
30 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
32 class project_task_type(osv.osv):
33 _name = 'project.task.type'
34 _description = 'Task Stage'
37 'name': fields.char('Stage Name', required=True, size=64, translate=True),
38 'description': fields.text('Description'),
39 'sequence': fields.integer('Sequence'),
40 'case_default': fields.boolean('Common to All Projects',
41 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."),
42 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
43 'state': fields.selection(_TASK_STATE, 'State', required=True,
44 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."),
45 'fold': fields.boolean('Hide in views if empty',
46 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
55 class project(osv.osv):
56 _name = "project.project"
57 _description = "Project"
58 _inherits = {'account.analytic.account': "analytic_account_id"}
59 _inherit = ['ir.needaction_mixin', 'mail.thread']
61 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
63 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
64 if context and context.get('user_preference'):
65 cr.execute("""SELECT project.id FROM project_project project
66 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
67 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
68 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
69 return [(r[0]) for r in cr.fetchall()]
70 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
71 context=context, count=count)
73 def _complete_name(self, cr, uid, ids, name, args, context=None):
75 for m in self.browse(cr, uid, ids, context=context):
76 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
79 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
80 partner_obj = self.pool.get('res.partner')
84 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
85 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
86 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
87 val['pricelist_id'] = pricelist_id
90 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
91 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
92 project_ids = [task.project_id.id for task in tasks if task.project_id]
93 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
95 def _get_project_and_parents(self, cr, uid, ids, context=None):
96 """ return the project ids and all their parent projects """
100 SELECT DISTINCT parent.id
101 FROM project_project project, project_project parent, account_analytic_account account
102 WHERE project.analytic_account_id = account.id
103 AND parent.analytic_account_id = account.parent_id
106 ids = [t[0] for t in cr.fetchall()]
110 def _get_project_and_children(self, cr, uid, ids, context=None):
111 """ retrieve all children projects of project ids;
112 return a dictionary mapping each project to its parent project (or None)
114 res = dict.fromkeys(ids, None)
117 SELECT project.id, parent.id
118 FROM project_project project, project_project parent, account_analytic_account account
119 WHERE project.analytic_account_id = account.id
120 AND parent.analytic_account_id = account.parent_id
123 dic = dict(cr.fetchall())
128 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
129 child_parent = self._get_project_and_children(cr, uid, ids, context)
130 # compute planned_hours, total_hours, effective_hours specific to each project
132 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
133 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
134 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
136 """, (tuple(child_parent.keys()),))
137 # aggregate results into res
138 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
139 for id, planned, total, effective in cr.fetchall():
140 # add the values specific to id to all parent projects of id in the result
143 res[id]['planned_hours'] += planned
144 res[id]['total_hours'] += total
145 res[id]['effective_hours'] += effective
146 id = child_parent[id]
147 # compute progress rates
149 if res[id]['total_hours']:
150 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
152 res[id]['progress_rate'] = 0.0
155 def unlink(self, cr, uid, ids, *args, **kwargs):
156 for proj in self.browse(cr, uid, ids):
158 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
159 return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
161 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
162 res = dict.fromkeys(ids, 0)
163 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
164 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
165 res[task.project_id.id] += 1
169 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
170 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
171 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
172 'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account', 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),
173 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
174 'warn_manager': fields.boolean('Notify Manager', help="If you check this field, the project manager will receive an email each time a task is completed by his team.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
176 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
177 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)]}),
178 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
179 '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.",
181 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
182 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
184 '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.",
186 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
187 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
189 '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.",
191 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
192 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
194 '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.",
196 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
197 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
199 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
200 'warn_customer': fields.boolean('Warn Partner', help="If you check this, the user will have a popup when closing a task that propose a message to send by email to the customer.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
201 'warn_header': fields.text('Mail Header', help="Header added at the beginning of the email for the warning message sent to the customer when a task is closed.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
202 'warn_footer': fields.text('Mail Footer', help="Footer added at the beginning of the email for the warning message sent to the customer when a task is closed.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
203 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
204 'use_tasks': fields.boolean('Use Tasks', help="Check this field if this project is aimed at managing tasks"),
205 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
206 'color': fields.integer('Color Index'),
207 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
210 def dummy(self, cr, uid, ids, context):
213 def _get_type_common(self, cr, uid, context):
214 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
222 'type_ids': _get_type_common,
226 # TODO: Why not using a SQL contraints ?
227 def _check_dates(self, cr, uid, ids, context=None):
228 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
229 if leave['date_start'] and leave['date']:
230 if leave['date_start'] > leave['date']:
235 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
238 def set_template(self, cr, uid, ids, context=None):
239 res = self.setActive(cr, uid, ids, value=False, context=context)
242 def set_done(self, cr, uid, ids, context=None):
243 task_obj = self.pool.get('project.task')
244 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
245 task_obj.case_close(cr, uid, task_ids, context=context)
246 self.write(cr, uid, ids, {'state':'close'}, context=context)
247 self.set_close_send_note(cr, uid, ids, context=context)
250 def set_cancel(self, cr, uid, ids, context=None):
251 task_obj = self.pool.get('project.task')
252 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
253 task_obj.case_cancel(cr, uid, task_ids, context=context)
254 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
255 self.set_cancel_send_note(cr, uid, ids, context=context)
258 def set_pending(self, cr, uid, ids, context=None):
259 self.write(cr, uid, ids, {'state':'pending'}, context=context)
260 self.set_pending_send_note(cr, uid, ids, context=context)
263 def set_open(self, cr, uid, ids, context=None):
264 self.write(cr, uid, ids, {'state':'open'}, context=context)
265 self.set_open_send_note(cr, uid, ids, context=context)
268 def reset_project(self, cr, uid, ids, context=None):
269 res = self.setActive(cr, uid, ids, value=True, context=context)
270 self.set_open_send_note(cr, uid, ids, context=context)
273 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
274 """ copy and map tasks from old to new project """
278 task_obj = self.pool.get('project.task')
279 proj = self.browse(cr, uid, old_project_id, context=context)
280 for task in proj.tasks:
281 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
282 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
283 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
286 def copy(self, cr, uid, id, default={}, context=None):
290 default = default or {}
291 context['active_test'] = False
292 default['state'] = 'open'
293 default['tasks'] = []
294 proj = self.browse(cr, uid, id, context=context)
295 if not default.get('name', False):
296 default['name'] = proj.name + _(' (copy)')
298 res = super(project, self).copy(cr, uid, id, default, context)
299 self.map_tasks(cr,uid,id,res,context)
302 def duplicate_template(self, cr, uid, ids, context=None):
305 data_obj = self.pool.get('ir.model.data')
307 for proj in self.browse(cr, uid, ids, context=context):
308 parent_id = context.get('parent_id', False)
309 context.update({'analytic_project_copy': True})
310 new_date_start = time.strftime('%Y-%m-%d')
312 if proj.date_start and proj.date:
313 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
314 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
315 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
316 context.update({'copy':True})
317 new_id = self.copy(cr, uid, proj.id, default = {
318 'name': proj.name +_(' (copy)'),
320 'date_start':new_date_start,
322 'parent_id':parent_id}, context=context)
323 result.append(new_id)
325 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
326 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
328 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
330 if result and len(result):
332 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
333 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
334 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
335 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
336 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
337 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
339 'name': _('Projects'),
341 'view_mode': 'form,tree',
342 'res_model': 'project.project',
345 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
346 'type': 'ir.actions.act_window',
347 'search_view_id': search_view['res_id'],
351 # set active value for a project, its sub projects and its tasks
352 def setActive(self, cr, uid, ids, value=True, context=None):
353 task_obj = self.pool.get('project.task')
354 for proj in self.browse(cr, uid, ids, context=None):
355 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
356 cr.execute('select id from project_task where project_id=%s', (proj.id,))
357 tasks_id = [x[0] for x in cr.fetchall()]
359 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
360 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
362 self.setActive(cr, uid, child_ids, value, context=None)
365 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
366 context = context or {}
367 if type(ids) in (long, int,):
369 projects = self.browse(cr, uid, ids, context=context)
371 for project in projects:
372 if (not project.members) and force_members:
373 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
375 resource_pool = self.pool.get('resource.resource')
377 result = "from openerp.addons.resource.faces import *\n"
378 result += "import datetime\n"
379 for project in self.browse(cr, uid, ids, context=context):
380 u_ids = [i.id for i in project.members]
381 if project.user_id and (project.user_id.id not in u_ids):
382 u_ids.append(project.user_id.id)
383 for task in project.tasks:
384 if task.state in ('done','cancelled'):
386 if task.user_id and (task.user_id.id not in u_ids):
387 u_ids.append(task.user_id.id)
388 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
389 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
390 for key, vals in resource_objs.items():
392 class User_%s(Resource):
394 ''' % (key, vals.get('efficiency', False))
401 def _schedule_project(self, cr, uid, project, context=None):
402 resource_pool = self.pool.get('resource.resource')
403 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
404 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
405 # TODO: check if we need working_..., default values are ok.
406 puids = [x.id for x in project.members]
408 puids.append(project.user_id.id)
416 project.date_start, working_days,
417 '|'.join(['User_'+str(x) for x in puids])
419 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
426 #TODO: DO Resource allocation and compute availability
427 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
433 def schedule_tasks(self, cr, uid, ids, context=None):
434 context = context or {}
435 if type(ids) in (long, int,):
437 projects = self.browse(cr, uid, ids, context=context)
438 result = self._schedule_header(cr, uid, ids, False, context=context)
439 for project in projects:
440 result += self._schedule_project(cr, uid, project, context=context)
441 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
444 exec result in local_dict
445 projects_gantt = Task.BalancedProject(local_dict['Project'])
447 for project in projects:
448 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
449 for task in project.tasks:
450 if task.state in ('done','cancelled'):
453 p = getattr(project_gantt, 'Task_%d' % (task.id,))
455 self.pool.get('project.task').write(cr, uid, [task.id], {
456 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
457 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
459 if (not task.user_id) and (p.booked_resource):
460 self.pool.get('project.task').write(cr, uid, [task.id], {
461 'user_id': int(p.booked_resource[0].name[5:]),
465 # ------------------------------------------------
466 # OpenChatter methods and notifications
467 # ------------------------------------------------
469 def get_needaction_user_ids(self, cr, uid, ids, context=None):
470 result = dict.fromkeys(ids)
471 for obj in self.browse(cr, uid, ids, context=context):
473 if obj.state == 'draft' and obj.user_id:
474 result[obj.id] = [obj.user_id.id]
477 def message_get_subscribers(self, cr, uid, ids, context=None):
478 sub_ids = self.message_get_subscribers_ids(cr, uid, ids, context=context);
479 for obj in self.browse(cr, uid, ids, context=context):
481 sub_ids.append(obj.user_id.id)
482 return self.pool.get('res.users').read(cr, uid, sub_ids, context=context)
484 def create(self, cr, uid, vals, context=None):
485 obj_id = super(project, self).create(cr, uid, vals, context=context)
486 self.create_send_note(cr, uid, [obj_id], context=context)
489 def create_send_note(self, cr, uid, ids, context=None):
490 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
492 def set_open_send_note(self, cr, uid, ids, context=None):
493 message = _("Project has been <b>opened</b>.")
494 return self.message_append_note(cr, uid, ids, body=message, context=context)
496 def set_pending_send_note(self, cr, uid, ids, context=None):
497 message = _("Project is now <b>pending</b>.")
498 return self.message_append_note(cr, uid, ids, body=message, context=context)
500 def set_cancel_send_note(self, cr, uid, ids, context=None):
501 message = _("Project has been <b>cancelled</b>.")
502 return self.message_append_note(cr, uid, ids, body=message, context=context)
504 def set_close_send_note(self, cr, uid, ids, context=None):
505 message = _("Project has been <b>closed</b>.")
506 return self.message_append_note(cr, uid, ids, body=message, context=context)
509 class task(base_stage, osv.osv):
510 _name = "project.task"
511 _description = "Task"
512 _date_name = "date_start"
513 _inherit = ['ir.needaction_mixin', 'mail.thread']
515 def _get_default_project_id(self, cr, uid, context=None):
516 """ Gives default section by checking if present in the context """
517 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
519 def _get_default_stage_id(self, cr, uid, context=None):
520 """ Gives default stage_id """
521 project_id = self._get_default_project_id(cr, uid, context=context)
522 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
524 def _resolve_project_id_from_context(self, cr, uid, context=None):
525 """ Returns ID of project based on the value of 'default_project_id'
526 context key, or None if it cannot be resolved to a single
529 if context is None: context = {}
530 if type(context.get('default_project_id')) in (int, long):
531 return context['default_project_id']
532 if isinstance(context.get('default_project_id'), basestring):
533 project_name = context['default_project_id']
534 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
535 if len(project_ids) == 1:
536 return project_ids[0][0]
539 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
540 stage_obj = self.pool.get('project.task.type')
541 order = stage_obj._order
542 access_rights_uid = access_rights_uid or uid
543 # lame way to allow reverting search, should just work in the trivial case
544 if read_group_order == 'stage_id desc':
545 order = '%s desc' % order
546 # retrieve section_id from the context and write the domain
547 # - ('id', 'in', 'ids'): add columns that should be present
548 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
549 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
551 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
553 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
554 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
555 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
556 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
557 # restore order of the search
558 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
561 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
562 res_users = self.pool.get('res.users')
563 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
564 access_rights_uid = access_rights_uid or uid
566 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
567 order = res_users._order
568 # lame way to allow reverting search, should just work in the trivial case
569 if read_group_order == 'user_id desc':
570 order = '%s desc' % order
571 # de-duplicate and apply search order
572 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
573 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
574 # restore order of the search
575 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
579 'stage_id': _read_group_stage_ids,
580 'user_id': _read_group_user_id,
583 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
584 obj_project = self.pool.get('project.project')
586 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
587 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
588 if id and isinstance(id, (long, int)):
589 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
590 args.append(('active', '=', False))
591 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
593 def _str_get(self, task, level=0, border='***', context=None):
594 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'+ \
595 border[0]+' '+(task.name or '')+'\n'+ \
596 (task.description or '')+'\n\n'
598 # Compute: effective_hours, total_hours, progress
599 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
601 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
602 hours = dict(cr.fetchall())
603 for task in self.browse(cr, uid, ids, context=context):
604 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)}
605 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
606 res[task.id]['progress'] = 0.0
607 if (task.remaining_hours + hours.get(task.id, 0.0)):
608 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
609 if task.state in ('done','cancelled'):
610 res[task.id]['progress'] = 100.0
613 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
614 if remaining and not planned:
615 return {'value':{'planned_hours': remaining}}
618 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
619 return {'value':{'remaining_hours': planned - effective}}
621 def onchange_project(self, cr, uid, id, project_id):
624 data = self.pool.get('project.project').browse(cr, uid, [project_id])
625 partner_id=data and data[0].partner_id
627 return {'value':{'partner_id':partner_id.id}}
630 def duplicate_task(self, cr, uid, map_ids, context=None):
631 for new in map_ids.values():
632 task = self.browse(cr, uid, new, context)
633 child_ids = [ ch.id for ch in task.child_ids]
635 for child in task.child_ids:
636 if child.id in map_ids.keys():
637 child_ids.remove(child.id)
638 child_ids.append(map_ids[child.id])
640 parent_ids = [ ch.id for ch in task.parent_ids]
642 for parent in task.parent_ids:
643 if parent.id in map_ids.keys():
644 parent_ids.remove(parent.id)
645 parent_ids.append(map_ids[parent.id])
646 #FIXME why there is already the copy and the old one
647 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
649 def copy_data(self, cr, uid, id, default={}, context=None):
650 default = default or {}
651 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
652 if not default.get('remaining_hours', False):
653 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
654 default['active'] = True
655 default['stage_id'] = False
656 if not default.get('name', False):
657 default['name'] = self.browse(cr, uid, id, context=context).name or ''
658 if not context.get('copy',False):
659 new_name = _("%s (copy)")%default.get('name','')
660 default.update({'name':new_name})
661 return super(task, self).copy_data(cr, uid, id, default, context)
664 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
666 for task in self.browse(cr, uid, ids, context=context):
669 if task.project_id.active == False or task.project_id.state == 'template':
673 def _get_task(self, cr, uid, ids, context=None):
675 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
676 if work.task_id: result[work.task_id.id] = True
680 '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."),
681 'name': fields.char('Task Summary', size=128, required=True, select=True),
682 'description': fields.text('Description'),
683 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
684 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
685 'stage_id': fields.many2one('project.task.type', 'Stage',
686 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
687 'state': fields.related('stage_id', 'state', type="selection", store=True,
688 selection=_TASK_STATE, string="State", readonly=True,
689 help='The state is set to \'Draft\', when a case is created.\
690 If the case is in progress the state is set to \'Open\'.\
691 When the case is over, the state is set to \'Done\'.\
692 If the case needs to be reviewed then the state is \
693 set to \'Pending\'.'),
694 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
695 help="A task's kanban state indicates special situations affecting it:\n"
696 " * Normal is the default situation\n"
697 " * Blocked indicates something is preventing the progress of this task\n"
698 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
699 readonly=True, required=False),
700 'create_date': fields.datetime('Create Date', readonly=True,select=True),
701 'date_start': fields.datetime('Starting Date',select=True),
702 'date_end': fields.datetime('Ending Date',select=True),
703 'date_deadline': fields.date('Deadline',select=True),
704 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
705 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
706 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
707 'notes': fields.text('Notes'),
708 'planned_hours': fields.float('Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
709 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
711 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
712 'project.task.work': (_get_task, ['hours'], 10),
714 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
715 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
717 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
718 'project.task.work': (_get_task, ['hours'], 10),
720 '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",
722 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
723 'project.task.work': (_get_task, ['hours'], 10),
725 '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.",
727 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
728 'project.task.work': (_get_task, ['hours'], 10),
730 'user_id': fields.many2one('res.users', 'Assigned to'),
731 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
732 'partner_id': fields.many2one('res.partner', 'Partner'),
733 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
734 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
735 'company_id': fields.many2one('res.company', 'Company'),
736 'id': fields.integer('ID', readonly=True),
737 'color': fields.integer('Color Index'),
738 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
742 'stage_id': _get_default_stage_id,
743 'project_id': _get_default_project_id,
745 'kanban_state': 'normal',
750 'user_id': lambda obj, cr, uid, context: uid,
751 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
754 _order = "priority, sequence, date_start, name, id"
756 def set_priority(self, cr, uid, ids, priority):
759 return self.write(cr, uid, ids, {'priority' : priority})
761 def set_high_priority(self, cr, uid, ids, *args):
762 """Set task priority to high
764 return self.set_priority(cr, uid, ids, '1')
766 def set_normal_priority(self, cr, uid, ids, *args):
767 """Set task priority to normal
769 return self.set_priority(cr, uid, ids, '2')
771 def _check_recursion(self, cr, uid, ids, context=None):
773 visited_branch = set()
775 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
781 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
782 if id in visited_branch: #Cycle
785 if id in visited_node: #Already tested don't work one more time for nothing
788 visited_branch.add(id)
791 #visit child using DFS
792 task = self.browse(cr, uid, id, context=context)
793 for child in task.child_ids:
794 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
798 visited_branch.remove(id)
801 def _check_dates(self, cr, uid, ids, context=None):
804 obj_task = self.browse(cr, uid, ids[0], context=context)
805 start = obj_task.date_start or False
806 end = obj_task.date_end or False
813 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
814 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
817 # Override view according to the company definition
819 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
820 users_obj = self.pool.get('res.users')
822 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
823 # this should be safe (no context passed to avoid side-effects)
824 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
825 tm = obj_tm and obj_tm.name or 'Hours'
827 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
829 if tm in ['Hours','Hour']:
832 eview = etree.fromstring(res['arch'])
834 def _check_rec(eview):
835 if eview.attrib.get('widget','') == 'float_time':
836 eview.set('widget','float')
843 res['arch'] = etree.tostring(eview)
845 for f in res['fields']:
846 if 'Hours' in res['fields'][f]['string']:
847 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
850 # ****************************************
852 # ****************************************
854 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
855 """ Override of the base.stage method
856 Parameter of the stage search taken from the lead:
857 - section_id: if set, stages must belong to this section or
858 be a default stage; if not set, stages must be default
861 if isinstance(cases, (int, long)):
862 cases = self.browse(cr, uid, cases, context=context)
863 # collect all section_ids
866 section_ids.append(section_id)
869 section_ids.append(task.project_id.id)
870 # OR all section_ids and OR with case_default
873 search_domain += [('|')] * len(section_ids)
874 for section_id in section_ids:
875 search_domain.append(('project_ids', '=', section_id))
876 search_domain.append(('case_default', '=', True))
877 # AND with the domain in parameter
878 search_domain += list(domain)
879 # perform search, return the first found
880 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
885 def _check_child_task(self, cr, uid, ids, context=None):
888 tasks = self.browse(cr, uid, ids, context=context)
891 for child in task.child_ids:
892 if child.state in ['draft', 'open', 'pending']:
893 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
896 def action_close(self, cr, uid, ids, context=None):
897 """ This action closes the task, then opens the wizard to send an
898 email to the partner or the project manager.
900 task_id = len(ids) and ids[0] or False
901 self._check_child_task(cr, uid, ids, context=context)
902 if not task_id: return False
903 task = self.browse(cr, uid, task_id, context=context)
904 project = task.project_id
905 res = self.do_close(cr, uid, [task_id], context=context)
906 if project.warn_manager or project.warn_customer:
908 'name': _('Send Email after close task'),
911 'res_model': 'mail.compose.message',
912 'type': 'ir.actions.act_window',
915 'context': {'active_id': task.id,
916 'active_model': 'project.task'}
920 def do_close(self, cr, uid, ids, context=None):
921 """ Compatibility when changing to case_close. """
922 return self.case_close(cr, uid, ids, context=context)
924 def case_close(self, cr, uid, ids, context=None):
926 request = self.pool.get('res.request')
927 if not isinstance(ids, list): ids = [ids]
928 for task in self.browse(cr, uid, ids, context=context):
930 project = task.project_id
931 for parent_id in task.parent_ids:
932 if parent_id.state in ('pending','draft'):
934 for child in parent_id.child_ids:
935 if child.id != task.id and child.state not in ('done','cancelled'):
938 self.do_reopen(cr, uid, [parent_id.id], context=context)
940 vals['remaining_hours'] = 0.0
941 if not task.date_end:
942 vals['date_end'] = fields.datetime.now()
943 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
944 self.case_close_send_note(cr, uid, [task.id], context=context)
947 def do_reopen(self, cr, uid, ids, context=None):
948 for task in self.browse(cr, uid, ids, context=context):
949 project = task.project_id
950 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
951 self.case_open_send_note(cr, uid, [task.id], context)
954 def do_cancel(self, cr, uid, ids, context=None):
955 """ Compatibility when changing to case_cancel. """
956 return self.case_cancel(cr, uid, ids, context=context)
958 def case_cancel(self, cr, uid, ids, context=None):
959 request = self.pool.get('res.request')
960 tasks = self.browse(cr, uid, ids, context=context)
961 self._check_child_task(cr, uid, ids, context=context)
963 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
964 self.case_cancel_send_note(cr, uid, [task.id], context=context)
967 def do_open(self, cr, uid, ids, context=None):
968 """ Compatibility when changing to case_open. """
969 return self.case_open(cr, uid, ids, context=context)
971 def case_open(self, cr, uid, ids, context=None):
972 if not isinstance(ids,list): ids = [ids]
973 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
974 self.case_open_send_note(cr, uid, ids, context)
977 def do_draft(self, cr, uid, ids, context=None):
978 """ Compatibility when changing to case_draft. """
979 return self.case_draft(cr, uid, ids, context=context)
981 def case_draft(self, cr, uid, ids, context=None):
982 self.case_set(cr, uid, ids, 'draft', {}, context=context)
983 self.case_draft_send_note(cr, uid, ids, context=context)
986 def do_pending(self, cr, uid, ids, context=None):
987 """ Compatibility when changing to case_pending. """
988 return self.case_pending(cr, uid, ids, context=context)
990 def case_pending(self, cr, uid, ids, context=None):
991 self.case_set(cr, uid, ids, 'pending', {}, context=context)
992 return self.case_pending_send_note(cr, uid, ids, context=context)
994 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
995 attachment = self.pool.get('ir.attachment')
996 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
997 new_attachment_ids = []
998 for attachment_id in attachment_ids:
999 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1000 return new_attachment_ids
1002 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1004 Delegate Task to another users.
1006 assert delegate_data['user_id'], _("Delegated User should be specified")
1007 delegated_tasks = {}
1008 for task in self.browse(cr, uid, ids, context=context):
1009 delegated_task_id = self.copy(cr, uid, task.id, {
1010 'name': delegate_data['name'],
1011 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1012 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1013 'planned_hours': delegate_data['planned_hours'] or 0.0,
1014 'parent_ids': [(6, 0, [task.id])],
1016 'description': delegate_data['new_task_description'] or '',
1020 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1021 newname = delegate_data['prefix'] or ''
1023 'remaining_hours': delegate_data['planned_hours_me'],
1024 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1027 if delegate_data['state'] == 'pending':
1028 self.do_pending(cr, uid, [task.id], context=context)
1029 elif delegate_data['state'] == 'done':
1030 self.do_close(cr, uid, [task.id], context=context)
1031 self.do_delegation_send_note(cr, uid, [task.id], context)
1032 delegated_tasks[task.id] = delegated_task_id
1033 return delegated_tasks
1035 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1036 for task in self.browse(cr, uid, ids, context=context):
1037 if (task.state=='draft') or (task.planned_hours==0.0):
1038 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1039 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1042 def set_remaining_time_1(self, cr, uid, ids, context=None):
1043 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1045 def set_remaining_time_2(self, cr, uid, ids, context=None):
1046 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1048 def set_remaining_time_5(self, cr, uid, ids, context=None):
1049 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1051 def set_remaining_time_10(self, cr, uid, ids, context=None):
1052 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1054 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1055 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1057 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1058 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1060 def set_kanban_state_done(self, cr, uid, ids, context=None):
1061 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1063 def _store_history(self, cr, uid, ids, context=None):
1064 for task in self.browse(cr, uid, ids, context=context):
1065 self.pool.get('project.task.history').create(cr, uid, {
1067 'remaining_hours': task.remaining_hours,
1068 'planned_hours': task.planned_hours,
1069 'kanban_state': task.kanban_state,
1070 'type_id': task.stage_id.id,
1071 'state': task.state,
1072 'user_id': task.user_id.id
1077 def create(self, cr, uid, vals, context=None):
1078 task_id = super(task, self).create(cr, uid, vals, context=context)
1079 self._store_history(cr, uid, [task_id], context=context)
1080 self.create_send_note(cr, uid, [task_id], context=context)
1083 # Overridden to reset the kanban_state to normal whenever
1084 # the stage (stage_id) of the task changes.
1085 def write(self, cr, uid, ids, vals, context=None):
1086 if isinstance(ids, (int, long)):
1088 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1089 new_stage = vals.get('stage_id')
1090 vals_reset_kstate = dict(vals, kanban_state='normal')
1091 for t in self.browse(cr, uid, ids, context=context):
1092 #TO FIX:Kanban view doesn't raise warning
1093 #stages = [stage.id for stage in t.project_id.type_ids]
1094 #if new_stage not in stages:
1095 #raise osv.except_osv(_('Warning !'), _('Stage is not defined in the project.'))
1096 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1097 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1100 result = super(task,self).write(cr, uid, ids, vals, context=context)
1101 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1102 self._store_history(cr, uid, ids, context=context)
1105 def unlink(self, cr, uid, ids, context=None):
1108 self._check_child_task(cr, uid, ids, context=context)
1109 res = super(task, self).unlink(cr, uid, ids, context)
1112 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1113 context = context or {}
1117 if task.state in ('done','cancelled'):
1122 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1124 for t2 in task.parent_ids:
1125 start.append("up.Task_%s.end" % (t2.id,))
1129 ''' % (ident,','.join(start))
1134 ''' % (ident, 'User_'+str(task.user_id.id))
1139 # ---------------------------------------------------
1140 # OpenChatter methods and notifications
1141 # ---------------------------------------------------
1143 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1144 """ Override of default prefix for notifications. """
1147 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1148 result = dict.fromkeys(ids, [])
1149 for obj in self.browse(cr, uid, ids, context=context):
1150 if obj.state == 'draft' and obj.user_id:
1151 result[obj.id] = [obj.user_id.id]
1154 def message_get_subscribers(self, cr, uid, ids, context=None):
1155 sub_ids = self.message_get_subscribers_ids(cr, uid, ids, context=context);
1156 for obj in self.browse(cr, uid, ids, context=context):
1158 sub_ids.append(obj.user_id.id)
1160 sub_ids.append(obj.manager_id.id)
1161 return self.pool.get('res.users').read(cr, uid, sub_ids, context=context)
1163 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1164 """ Override of the (void) default notification method. """
1165 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1166 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1168 def create_send_note(self, cr, uid, ids, context=None):
1169 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1171 def case_draft_send_note(self, cr, uid, ids, context=None):
1172 msg = _('Task has been set as <b>draft</b>.')
1173 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1175 def do_delegation_send_note(self, cr, uid, ids, context=None):
1176 for task in self.browse(cr, uid, ids, context=context):
1177 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1178 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1182 class project_work(osv.osv):
1183 _name = "project.task.work"
1184 _description = "Project Task Work"
1186 'name': fields.char('Work summary', size=128),
1187 'date': fields.datetime('Date', select="1"),
1188 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1189 'hours': fields.float('Time Spent'),
1190 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1191 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1195 'user_id': lambda obj, cr, uid, context: uid,
1196 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1199 _order = "date desc"
1200 def create(self, cr, uid, vals, *args, **kwargs):
1201 if 'hours' in vals and (not vals['hours']):
1202 vals['hours'] = 0.00
1203 if 'task_id' in vals:
1204 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1205 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1207 def write(self, cr, uid, ids, vals, context=None):
1208 if 'hours' in vals and (not vals['hours']):
1209 vals['hours'] = 0.00
1211 for work in self.browse(cr, uid, ids, context=context):
1212 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))
1213 return super(project_work,self).write(cr, uid, ids, vals, context)
1215 def unlink(self, cr, uid, ids, *args, **kwargs):
1216 for work in self.browse(cr, uid, ids):
1217 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1218 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1221 class account_analytic_account(osv.osv):
1223 _inherit = 'account.analytic.account'
1224 _description = 'Analytic Account'
1226 def create(self, cr, uid, vals, context=None):
1229 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1230 vals['child_ids'] = []
1231 return super(account_analytic_account, self).create(cr, uid, vals, context=context)
1233 def unlink(self, cr, uid, ids, *args, **kwargs):
1234 project_obj = self.pool.get('project.project')
1235 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1237 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1238 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1242 # Tasks History, used for cumulative flow charts (Lean/Agile)
1245 class project_task_history(osv.osv):
1246 _name = 'project.task.history'
1247 _description = 'History of Tasks'
1248 _rec_name = 'task_id'
1250 def _get_date(self, cr, uid, ids, name, arg, context=None):
1252 for history in self.browse(cr, uid, ids, context=context):
1253 if history.state in ('done','cancelled'):
1254 result[history.id] = history.date
1256 cr.execute('''select
1259 project_task_history
1263 order by id limit 1''', (history.task_id.id, history.id))
1265 result[history.id] = res and res[0] or False
1268 def _get_related_date(self, cr, uid, ids, context=None):
1270 for history in self.browse(cr, uid, ids, context=context):
1271 cr.execute('''select
1274 project_task_history
1278 order by id desc limit 1''', (history.task_id.id, history.id))
1281 result.append(res[0])
1285 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1286 'type_id': fields.many2one('project.task.type', 'Stage'),
1287 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1288 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1289 'date': fields.date('Date', select=True),
1290 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1291 'project.task.history': (_get_related_date, None, 20)
1293 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1294 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1295 'user_id': fields.many2one('res.users', 'Responsible'),
1298 'date': fields.date.context_today,
1302 class project_task_history_cumulative(osv.osv):
1303 _name = 'project.task.history.cumulative'
1304 _table = 'project_task_history_cumulative'
1305 _inherit = 'project.task.history'
1308 'end_date': fields.date('End Date'),
1309 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1312 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1314 history.date::varchar||'-'||history.history_id::varchar as id,
1315 history.date as end_date,
1320 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1321 task_id, type_id, user_id, kanban_state, state,
1322 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1324 project_task_history