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
168 def _get_followers(self, cr, uid, ids, name, arg, context=None):
170 Functional field that computes the users that are 'following' a thread.
173 for project in self.browse(cr, uid, ids, context=context):
175 for message in project.message_ids:
176 l.add(message.user_id and message.user_id.id or False)
177 res[project.id] = list(filter(None, l))
180 def _search_followers(self, cr, uid, obj, name, args, context=None):
181 project_obj = self.pool.get('project.project')
182 project_ids = project_obj.search(cr, uid, [('message_ids.user_id.id', 'in', args[0][2])], context=context)
183 return [('id', 'in', project_ids)]
186 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
187 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
188 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
189 '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),
190 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
191 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
192 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)]}),
193 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
194 '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.",
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 '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.",
201 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
202 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
204 '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.",
206 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
207 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
209 '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.",
211 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
212 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
214 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
215 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
216 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
217 'color': fields.integer('Color Index'),
218 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility'),
219 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
220 'followers': fields.function(_get_followers, method=True, fnct_search=_search_followers,
221 type='many2many', relation='res.users', string='Followers'),
224 def dummy(self, cr, uid, ids, context):
227 def _get_type_common(self, cr, uid, context):
228 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
238 'type_ids': _get_type_common,
241 # TODO: Why not using a SQL contraints ?
242 def _check_dates(self, cr, uid, ids, context=None):
243 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
244 if leave['date_start'] and leave['date']:
245 if leave['date_start'] > leave['date']:
250 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
253 def set_template(self, cr, uid, ids, context=None):
254 res = self.setActive(cr, uid, ids, value=False, context=context)
257 def set_done(self, cr, uid, ids, context=None):
258 task_obj = self.pool.get('project.task')
259 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
260 task_obj.case_close(cr, uid, task_ids, context=context)
261 self.write(cr, uid, ids, {'state':'close'}, context=context)
262 self.set_close_send_note(cr, uid, ids, context=context)
265 def set_cancel(self, cr, uid, ids, context=None):
266 task_obj = self.pool.get('project.task')
267 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
268 task_obj.case_cancel(cr, uid, task_ids, context=context)
269 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
270 self.set_cancel_send_note(cr, uid, ids, context=context)
273 def set_pending(self, cr, uid, ids, context=None):
274 self.write(cr, uid, ids, {'state':'pending'}, context=context)
275 self.set_pending_send_note(cr, uid, ids, context=context)
278 def set_open(self, cr, uid, ids, context=None):
279 self.write(cr, uid, ids, {'state':'open'}, context=context)
280 self.set_open_send_note(cr, uid, ids, context=context)
283 def reset_project(self, cr, uid, ids, context=None):
284 res = self.setActive(cr, uid, ids, value=True, context=context)
285 self.set_open_send_note(cr, uid, ids, context=context)
288 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
289 """ copy and map tasks from old to new project """
293 task_obj = self.pool.get('project.task')
294 proj = self.browse(cr, uid, old_project_id, context=context)
295 for task in proj.tasks:
296 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
297 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
298 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
301 def copy(self, cr, uid, id, default={}, context=None):
305 default = default or {}
306 context['active_test'] = False
307 default['state'] = 'open'
308 default['tasks'] = []
309 proj = self.browse(cr, uid, id, context=context)
310 if not default.get('name', False):
311 default['name'] = proj.name + _(' (copy)')
313 res = super(project, self).copy(cr, uid, id, default, context)
314 self.map_tasks(cr,uid,id,res,context)
317 def duplicate_template(self, cr, uid, ids, context=None):
320 data_obj = self.pool.get('ir.model.data')
322 for proj in self.browse(cr, uid, ids, context=context):
323 parent_id = context.get('parent_id', False)
324 context.update({'analytic_project_copy': True})
325 new_date_start = time.strftime('%Y-%m-%d')
327 if proj.date_start and proj.date:
328 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
329 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
330 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
331 context.update({'copy':True})
332 new_id = self.copy(cr, uid, proj.id, default = {
333 'name': proj.name +_(' (copy)'),
335 'date_start':new_date_start,
337 'parent_id':parent_id}, context=context)
338 result.append(new_id)
340 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
341 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
343 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
345 if result and len(result):
347 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
348 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
349 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
350 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
351 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
352 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
354 'name': _('Projects'),
356 'view_mode': 'form,tree',
357 'res_model': 'project.project',
360 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
361 'type': 'ir.actions.act_window',
362 'search_view_id': search_view['res_id'],
366 # set active value for a project, its sub projects and its tasks
367 def setActive(self, cr, uid, ids, value=True, context=None):
368 task_obj = self.pool.get('project.task')
369 for proj in self.browse(cr, uid, ids, context=None):
370 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
371 cr.execute('select id from project_task where project_id=%s', (proj.id,))
372 tasks_id = [x[0] for x in cr.fetchall()]
374 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
375 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
377 self.setActive(cr, uid, child_ids, value, context=None)
380 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
381 context = context or {}
382 if type(ids) in (long, int,):
384 projects = self.browse(cr, uid, ids, context=context)
386 for project in projects:
387 if (not project.members) and force_members:
388 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
390 resource_pool = self.pool.get('resource.resource')
392 result = "from openerp.addons.resource.faces import *\n"
393 result += "import datetime\n"
394 for project in self.browse(cr, uid, ids, context=context):
395 u_ids = [i.id for i in project.members]
396 if project.user_id and (project.user_id.id not in u_ids):
397 u_ids.append(project.user_id.id)
398 for task in project.tasks:
399 if task.state in ('done','cancelled'):
401 if task.user_id and (task.user_id.id not in u_ids):
402 u_ids.append(task.user_id.id)
403 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
404 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
405 for key, vals in resource_objs.items():
407 class User_%s(Resource):
409 ''' % (key, vals.get('efficiency', False))
416 def _schedule_project(self, cr, uid, project, context=None):
417 resource_pool = self.pool.get('resource.resource')
418 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
419 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
420 # TODO: check if we need working_..., default values are ok.
421 puids = [x.id for x in project.members]
423 puids.append(project.user_id.id)
431 project.date_start, working_days,
432 '|'.join(['User_'+str(x) for x in puids])
434 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
441 #TODO: DO Resource allocation and compute availability
442 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
448 def schedule_tasks(self, cr, uid, ids, context=None):
449 context = context or {}
450 if type(ids) in (long, int,):
452 projects = self.browse(cr, uid, ids, context=context)
453 result = self._schedule_header(cr, uid, ids, False, context=context)
454 for project in projects:
455 result += self._schedule_project(cr, uid, project, context=context)
456 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
459 exec result in local_dict
460 projects_gantt = Task.BalancedProject(local_dict['Project'])
462 for project in projects:
463 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
464 for task in project.tasks:
465 if task.state in ('done','cancelled'):
468 p = getattr(project_gantt, 'Task_%d' % (task.id,))
470 self.pool.get('project.task').write(cr, uid, [task.id], {
471 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
472 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
474 if (not task.user_id) and (p.booked_resource):
475 self.pool.get('project.task').write(cr, uid, [task.id], {
476 'user_id': int(p.booked_resource[0].name[5:]),
480 # ------------------------------------------------
481 # OpenChatter methods and notifications
482 # ------------------------------------------------
484 def message_get_subscribers(self, cr, uid, ids, context=None):
485 """ Override to add responsible user. """
486 user_ids = super(project, self).message_get_subscribers(cr, uid, ids, context=context)
487 for obj in self.browse(cr, uid, ids, context=context):
488 if obj.user_id and not obj.user_id.id in user_ids:
489 user_ids.append(obj.user_id.id)
492 def create(self, cr, uid, vals, context=None):
493 obj_id = super(project, self).create(cr, uid, vals, context=context)
494 self.create_send_note(cr, uid, [obj_id], context=context)
497 def create_send_note(self, cr, uid, ids, context=None):
498 return self.message_append_note(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
500 def set_open_send_note(self, cr, uid, ids, context=None):
501 message = _("Project has been <b>opened</b>.")
502 return self.message_append_note(cr, uid, ids, body=message, context=context)
504 def set_pending_send_note(self, cr, uid, ids, context=None):
505 message = _("Project is now <b>pending</b>.")
506 return self.message_append_note(cr, uid, ids, body=message, context=context)
508 def set_cancel_send_note(self, cr, uid, ids, context=None):
509 message = _("Project has been <b>cancelled</b>.")
510 return self.message_append_note(cr, uid, ids, body=message, context=context)
512 def set_close_send_note(self, cr, uid, ids, context=None):
513 message = _("Project has been <b>closed</b>.")
514 return self.message_append_note(cr, uid, ids, body=message, context=context)
517 class task(base_stage, osv.osv):
518 _name = "project.task"
519 _description = "Task"
520 _date_name = "date_start"
521 _inherit = ['ir.needaction_mixin', 'mail.thread']
523 def _get_default_project_id(self, cr, uid, context=None):
524 """ Gives default section by checking if present in the context """
525 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
527 def _get_default_stage_id(self, cr, uid, context=None):
528 """ Gives default stage_id """
529 project_id = self._get_default_project_id(cr, uid, context=context)
530 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
532 def _resolve_project_id_from_context(self, cr, uid, context=None):
533 """ Returns ID of project based on the value of 'default_project_id'
534 context key, or None if it cannot be resolved to a single
537 if context is None: context = {}
538 if type(context.get('default_project_id')) in (int, long):
539 return context['default_project_id']
540 if isinstance(context.get('default_project_id'), basestring):
541 project_name = context['default_project_id']
542 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
543 if len(project_ids) == 1:
544 return project_ids[0][0]
547 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
548 stage_obj = self.pool.get('project.task.type')
549 order = stage_obj._order
550 access_rights_uid = access_rights_uid or uid
551 # lame way to allow reverting search, should just work in the trivial case
552 if read_group_order == 'stage_id desc':
553 order = '%s desc' % order
554 # retrieve section_id from the context and write the domain
555 # - ('id', 'in', 'ids'): add columns that should be present
556 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
557 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
559 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
561 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
562 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
563 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
564 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
565 # restore order of the search
566 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
569 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
570 res_users = self.pool.get('res.users')
571 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
572 access_rights_uid = access_rights_uid or uid
574 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
575 order = res_users._order
576 # lame way to allow reverting search, should just work in the trivial case
577 if read_group_order == 'user_id desc':
578 order = '%s desc' % order
579 # de-duplicate and apply search order
580 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
581 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
582 # restore order of the search
583 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
587 'stage_id': _read_group_stage_ids,
588 'user_id': _read_group_user_id,
591 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
592 obj_project = self.pool.get('project.project')
594 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
595 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
596 if id and isinstance(id, (long, int)):
597 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
598 args.append(('active', '=', False))
599 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
601 def _str_get(self, task, level=0, border='***', context=None):
602 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'+ \
603 border[0]+' '+(task.name or '')+'\n'+ \
604 (task.description or '')+'\n\n'
606 # Compute: effective_hours, total_hours, progress
607 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
609 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
610 hours = dict(cr.fetchall())
611 for task in self.browse(cr, uid, ids, context=context):
612 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)}
613 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
614 res[task.id]['progress'] = 0.0
615 if (task.remaining_hours + hours.get(task.id, 0.0)):
616 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
617 if task.state in ('done','cancelled'):
618 res[task.id]['progress'] = 100.0
621 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
622 if remaining and not planned:
623 return {'value':{'planned_hours': remaining}}
626 def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
627 return {'value':{'remaining_hours': planned - effective}}
629 def onchange_project(self, cr, uid, id, project_id):
632 data = self.pool.get('project.project').browse(cr, uid, [project_id])
633 partner_id=data and data[0].partner_id
635 return {'value':{'partner_id':partner_id.id}}
638 def duplicate_task(self, cr, uid, map_ids, context=None):
639 for new in map_ids.values():
640 task = self.browse(cr, uid, new, context)
641 child_ids = [ ch.id for ch in task.child_ids]
643 for child in task.child_ids:
644 if child.id in map_ids.keys():
645 child_ids.remove(child.id)
646 child_ids.append(map_ids[child.id])
648 parent_ids = [ ch.id for ch in task.parent_ids]
650 for parent in task.parent_ids:
651 if parent.id in map_ids.keys():
652 parent_ids.remove(parent.id)
653 parent_ids.append(map_ids[parent.id])
654 #FIXME why there is already the copy and the old one
655 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
657 def copy_data(self, cr, uid, id, default={}, context=None):
658 default = default or {}
659 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
660 if not default.get('remaining_hours', False):
661 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
662 default['active'] = True
663 default['stage_id'] = False
664 if not default.get('name', False):
665 default['name'] = self.browse(cr, uid, id, context=context).name or ''
666 if not context.get('copy',False):
667 new_name = _("%s (copy)")%default.get('name','')
668 default.update({'name':new_name})
669 return super(task, self).copy_data(cr, uid, id, default, context)
672 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
674 for task in self.browse(cr, uid, ids, context=context):
677 if task.project_id.active == False or task.project_id.state == 'template':
681 def _get_task(self, cr, uid, ids, context=None):
683 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
684 if work.task_id: result[work.task_id.id] = True
688 '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."),
689 'name': fields.char('Task Summary', size=128, required=True, select=True),
690 'description': fields.text('Description'),
691 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
692 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
693 'stage_id': fields.many2one('project.task.type', 'Stage',
694 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
695 'state': fields.related('stage_id', 'state', type="selection", store=True,
696 selection=_TASK_STATE, string="State", readonly=True,
697 help='The state is set to \'Draft\', when a case is created.\
698 If the case is in progress the state is set to \'Open\'.\
699 When the case is over, the state is set to \'Done\'.\
700 If the case needs to be reviewed then the state is \
701 set to \'Pending\'.'),
702 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
703 help="A task's kanban state indicates special situations affecting it:\n"
704 " * Normal is the default situation\n"
705 " * Blocked indicates something is preventing the progress of this task\n"
706 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
707 readonly=True, required=False),
708 'create_date': fields.datetime('Create Date', readonly=True,select=True),
709 'date_start': fields.datetime('Starting Date',select=True),
710 'date_end': fields.datetime('Ending Date',select=True),
711 'date_deadline': fields.date('Deadline',select=True),
712 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
713 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
714 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
715 'notes': fields.text('Notes'),
716 '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.'),
717 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
719 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
720 'project.task.work': (_get_task, ['hours'], 10),
722 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
723 'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
725 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
726 'project.task.work': (_get_task, ['hours'], 10),
728 '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",
730 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
731 'project.task.work': (_get_task, ['hours'], 10),
733 '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.",
735 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
736 'project.task.work': (_get_task, ['hours'], 10),
738 'user_id': fields.many2one('res.users', 'Assigned to'),
739 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
740 'partner_id': fields.many2one('res.partner', 'Partner'),
741 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
742 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
743 'company_id': fields.many2one('res.company', 'Company'),
744 'id': fields.integer('ID', readonly=True),
745 'color': fields.integer('Color Index'),
746 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
750 'stage_id': _get_default_stage_id,
751 'project_id': _get_default_project_id,
753 'kanban_state': 'normal',
758 'user_id': lambda obj, cr, uid, context: uid,
759 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
762 _order = "priority, sequence, date_start, name, id"
764 def set_priority(self, cr, uid, ids, priority, *args):
767 return self.write(cr, uid, ids, {'priority' : priority})
769 def set_high_priority(self, cr, uid, ids, *args):
770 """Set task priority to high
772 return self.set_priority(cr, uid, ids, '1')
774 def set_normal_priority(self, cr, uid, ids, *args):
775 """Set task priority to normal
777 return self.set_priority(cr, uid, ids, '2')
779 def _check_recursion(self, cr, uid, ids, context=None):
781 visited_branch = set()
783 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
789 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
790 if id in visited_branch: #Cycle
793 if id in visited_node: #Already tested don't work one more time for nothing
796 visited_branch.add(id)
799 #visit child using DFS
800 task = self.browse(cr, uid, id, context=context)
801 for child in task.child_ids:
802 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
806 visited_branch.remove(id)
809 def _check_dates(self, cr, uid, ids, context=None):
812 obj_task = self.browse(cr, uid, ids[0], context=context)
813 start = obj_task.date_start or False
814 end = obj_task.date_end or False
821 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
822 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
825 # Override view according to the company definition
827 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
828 users_obj = self.pool.get('res.users')
829 if context is None: context = {}
830 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
831 # this should be safe (no context passed to avoid side-effects)
832 obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
833 tm = obj_tm and obj_tm.name or 'Hours'
835 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
837 if tm in ['Hours','Hour']:
840 eview = etree.fromstring(res['arch'])
842 def _check_rec(eview):
843 if eview.attrib.get('widget','') == 'float_time':
844 eview.set('widget','float')
851 res['arch'] = etree.tostring(eview)
853 for f in res['fields']:
854 if 'Hours' in res['fields'][f]['string']:
855 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
858 # ****************************************
860 # ****************************************
862 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
863 """ Override of the base.stage method
864 Parameter of the stage search taken from the lead:
865 - section_id: if set, stages must belong to this section or
866 be a default stage; if not set, stages must be default
869 if isinstance(cases, (int, long)):
870 cases = self.browse(cr, uid, cases, context=context)
871 # collect all section_ids
874 section_ids.append(section_id)
877 section_ids.append(task.project_id.id)
878 # OR all section_ids and OR with case_default
881 search_domain += [('|')] * len(section_ids)
882 for section_id in section_ids:
883 search_domain.append(('project_ids', '=', section_id))
884 search_domain.append(('case_default', '=', True))
885 # AND with the domain in parameter
886 search_domain += list(domain)
887 # perform search, return the first found
888 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
893 def _check_child_task(self, cr, uid, ids, context=None):
896 tasks = self.browse(cr, uid, ids, context=context)
899 for child in task.child_ids:
900 if child.state in ['draft', 'open', 'pending']:
901 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
904 def action_close(self, cr, uid, ids, context=None):
905 """ This action closes the task
907 task_id = len(ids) and ids[0] or False
908 self._check_child_task(cr, uid, ids, context=context)
909 if not task_id: return False
910 return self.do_close(cr, uid, [task_id], context=context)
912 def do_close(self, cr, uid, ids, context=None):
913 """ Compatibility when changing to case_close. """
914 return self.case_close(cr, uid, ids, context=context)
916 def case_close(self, cr, uid, ids, context=None):
918 if not isinstance(ids, list): ids = [ids]
919 for task in self.browse(cr, uid, ids, context=context):
921 project = task.project_id
922 for parent_id in task.parent_ids:
923 if parent_id.state in ('pending','draft'):
925 for child in parent_id.child_ids:
926 if child.id != task.id and child.state not in ('done','cancelled'):
929 self.do_reopen(cr, uid, [parent_id.id], context=context)
931 vals['remaining_hours'] = 0.0
932 if not task.date_end:
933 vals['date_end'] = fields.datetime.now()
934 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
935 self.case_close_send_note(cr, uid, [task.id], context=context)
938 def do_reopen(self, cr, uid, ids, context=None):
939 for task in self.browse(cr, uid, ids, context=context):
940 project = task.project_id
941 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
942 self.case_open_send_note(cr, uid, [task.id], context)
945 def do_cancel(self, cr, uid, ids, context=None):
946 """ Compatibility when changing to case_cancel. """
947 return self.case_cancel(cr, uid, ids, context=context)
949 def case_cancel(self, cr, uid, ids, context=None):
950 tasks = self.browse(cr, uid, ids, context=context)
951 self._check_child_task(cr, uid, ids, context=context)
953 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
954 self.case_cancel_send_note(cr, uid, [task.id], context=context)
957 def do_open(self, cr, uid, ids, context=None):
958 """ Compatibility when changing to case_open. """
959 return self.case_open(cr, uid, ids, context=context)
961 def case_open(self, cr, uid, ids, context=None):
962 if not isinstance(ids,list): ids = [ids]
963 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
964 self.case_open_send_note(cr, uid, ids, context)
967 def do_draft(self, cr, uid, ids, context=None):
968 """ Compatibility when changing to case_draft. """
969 return self.case_draft(cr, uid, ids, context=context)
971 def case_draft(self, cr, uid, ids, context=None):
972 self.case_set(cr, uid, ids, 'draft', {}, context=context)
973 self.case_draft_send_note(cr, uid, ids, context=context)
976 def do_pending(self, cr, uid, ids, context=None):
977 """ Compatibility when changing to case_pending. """
978 return self.case_pending(cr, uid, ids, context=context)
980 def case_pending(self, cr, uid, ids, context=None):
981 self.case_set(cr, uid, ids, 'pending', {}, context=context)
982 return self.case_pending_send_note(cr, uid, ids, context=context)
984 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
985 attachment = self.pool.get('ir.attachment')
986 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
987 new_attachment_ids = []
988 for attachment_id in attachment_ids:
989 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
990 return new_attachment_ids
992 def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
994 Delegate Task to another users.
996 assert delegate_data['user_id'], _("Delegated User should be specified")
998 for task in self.browse(cr, uid, ids, context=context):
999 delegated_task_id = self.copy(cr, uid, task.id, {
1000 'name': delegate_data['name'],
1001 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1002 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1003 'planned_hours': delegate_data['planned_hours'] or 0.0,
1004 'parent_ids': [(6, 0, [task.id])],
1006 'description': delegate_data['new_task_description'] or '',
1010 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1011 newname = delegate_data['prefix'] or ''
1013 'remaining_hours': delegate_data['planned_hours_me'],
1014 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1017 if delegate_data['state'] == 'pending':
1018 self.do_pending(cr, uid, [task.id], context=context)
1019 elif delegate_data['state'] == 'done':
1020 self.do_close(cr, uid, [task.id], context=context)
1021 self.do_delegation_send_note(cr, uid, [task.id], context)
1022 delegated_tasks[task.id] = delegated_task_id
1023 return delegated_tasks
1025 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1026 for task in self.browse(cr, uid, ids, context=context):
1027 if (task.state=='draft') or (task.planned_hours==0.0):
1028 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1029 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1032 def set_remaining_time_1(self, cr, uid, ids, context=None):
1033 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1035 def set_remaining_time_2(self, cr, uid, ids, context=None):
1036 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1038 def set_remaining_time_5(self, cr, uid, ids, context=None):
1039 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1041 def set_remaining_time_10(self, cr, uid, ids, context=None):
1042 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1044 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1045 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1048 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1049 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1052 def set_kanban_state_done(self, cr, uid, ids, context=None):
1053 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1056 def _store_history(self, cr, uid, ids, context=None):
1057 for task in self.browse(cr, uid, ids, context=context):
1058 self.pool.get('project.task.history').create(cr, uid, {
1060 'remaining_hours': task.remaining_hours,
1061 'planned_hours': task.planned_hours,
1062 'kanban_state': task.kanban_state,
1063 'type_id': task.stage_id.id,
1064 'state': task.state,
1065 'user_id': task.user_id.id
1070 def create(self, cr, uid, vals, context=None):
1071 task_id = super(task, self).create(cr, uid, vals, context=context)
1072 self._store_history(cr, uid, [task_id], context=context)
1073 self.create_send_note(cr, uid, [task_id], context=context)
1076 # Overridden to reset the kanban_state to normal whenever
1077 # the stage (stage_id) of the task changes.
1078 def write(self, cr, uid, ids, vals, context=None):
1079 if isinstance(ids, (int, long)):
1081 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1082 new_stage = vals.get('stage_id')
1083 vals_reset_kstate = dict(vals, kanban_state='normal')
1084 for t in self.browse(cr, uid, ids, context=context):
1085 #TO FIX:Kanban view doesn't raise warning
1086 #stages = [stage.id for stage in t.project_id.type_ids]
1087 #if new_stage not in stages:
1088 #raise osv.except_osv(_('Warning !'), _('Stage is not defined in the project.'))
1089 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1090 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1093 result = super(task,self).write(cr, uid, ids, vals, context=context)
1094 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1095 self._store_history(cr, uid, ids, context=context)
1098 def unlink(self, cr, uid, ids, context=None):
1101 self._check_child_task(cr, uid, ids, context=context)
1102 res = super(task, self).unlink(cr, uid, ids, context)
1105 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1106 context = context or {}
1110 if task.state in ('done','cancelled'):
1115 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1117 for t2 in task.parent_ids:
1118 start.append("up.Task_%s.end" % (t2.id,))
1122 ''' % (ident,','.join(start))
1127 ''' % (ident, 'User_'+str(task.user_id.id))
1132 # ---------------------------------------------------
1133 # OpenChatter methods and notifications
1134 # ---------------------------------------------------
1136 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1137 """ Override of default prefix for notifications. """
1140 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1141 """ Returns the user_ids that have to perform an action.
1142 Add to the previous results given by super the document responsible
1144 :return: dict { record_id: [user_ids], }
1146 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1147 for obj in self.browse(cr, uid, ids, context=context):
1148 if obj.state == 'draft' and obj.user_id:
1149 result[obj.id].append(obj.user_id.id)
1152 def message_get_subscribers(self, cr, uid, ids, context=None):
1153 """ Override to add responsible user and project manager. """
1154 user_ids = super(task, self).message_get_subscribers(cr, uid, ids, context=context)
1155 for obj in self.browse(cr, uid, ids, context=context):
1156 if obj.user_id and not obj.user_id.id in user_ids:
1157 user_ids.append(obj.user_id.id)
1158 if obj.manager_id and not obj.manager_id.id in user_ids:
1159 user_ids.append(obj.manager_id.id)
1162 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1163 """ Override of the (void) default notification method. """
1164 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1165 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1167 def create_send_note(self, cr, uid, ids, context=None):
1168 return self.message_append_note(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1170 def case_draft_send_note(self, cr, uid, ids, context=None):
1171 msg = _('Task has been set as <b>draft</b>.')
1172 return self.message_append_note(cr, uid, ids, body=msg, context=context)
1174 def do_delegation_send_note(self, cr, uid, ids, context=None):
1175 for task in self.browse(cr, uid, ids, context=context):
1176 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1177 self.message_append_note(cr, uid, [task.id], body=msg, context=context)
1181 class project_work(osv.osv):
1182 _name = "project.task.work"
1183 _description = "Project Task Work"
1185 'name': fields.char('Work summary', size=128),
1186 'date': fields.datetime('Date', select="1"),
1187 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1188 'hours': fields.float('Time Spent'),
1189 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1190 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1194 'user_id': lambda obj, cr, uid, context: uid,
1195 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1198 _order = "date desc"
1199 def create(self, cr, uid, vals, *args, **kwargs):
1200 if 'hours' in vals and (not vals['hours']):
1201 vals['hours'] = 0.00
1202 if 'task_id' in vals:
1203 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1204 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1206 def write(self, cr, uid, ids, vals, context=None):
1207 if 'hours' in vals and (not vals['hours']):
1208 vals['hours'] = 0.00
1210 for work in self.browse(cr, uid, ids, context=context):
1211 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))
1212 return super(project_work,self).write(cr, uid, ids, vals, context)
1214 def unlink(self, cr, uid, ids, *args, **kwargs):
1215 for work in self.browse(cr, uid, ids):
1216 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1217 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1220 class account_analytic_account(osv.osv):
1221 _inherit = 'account.analytic.account'
1222 _description = 'Analytic Account'
1224 'use_tasks': fields.boolean('Tasks Management',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1225 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1228 def _trigger_project_creation(self, cr, uid, vals, context=None):
1230 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.
1232 return vals.get('use_tasks')
1234 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1236 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.
1238 project_pool = self.pool.get('project.project')
1239 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1240 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1242 'name': vals.get('name'),
1243 'analytic_account_id': analytic_account_id,
1245 return project_pool.create(cr, uid, project_values, context=context)
1248 def create(self, cr, uid, vals, context=None):
1251 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1252 vals['child_ids'] = []
1253 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1254 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1255 return analytic_account_id
1257 def write(self, cr, uid, ids, vals, context=None):
1258 name = vals.get('name')
1259 for account in self.browse(cr, uid, ids, context=context):
1261 vals['name'] = account.name
1262 self.project_create(cr, uid, account.id, vals, context=context)
1263 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1265 def unlink(self, cr, uid, ids, *args, **kwargs):
1266 project_obj = self.pool.get('project.project')
1267 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1269 raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
1270 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1274 # Tasks History, used for cumulative flow charts (Lean/Agile)
1277 class project_task_history(osv.osv):
1278 _name = 'project.task.history'
1279 _description = 'History of Tasks'
1280 _rec_name = 'task_id'
1282 def _get_date(self, cr, uid, ids, name, arg, context=None):
1284 for history in self.browse(cr, uid, ids, context=context):
1285 if history.state in ('done','cancelled'):
1286 result[history.id] = history.date
1288 cr.execute('''select
1291 project_task_history
1295 order by id limit 1''', (history.task_id.id, history.id))
1297 result[history.id] = res and res[0] or False
1300 def _get_related_date(self, cr, uid, ids, context=None):
1302 for history in self.browse(cr, uid, ids, context=context):
1303 cr.execute('''select
1306 project_task_history
1310 order by id desc limit 1''', (history.task_id.id, history.id))
1313 result.append(res[0])
1317 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1318 'type_id': fields.many2one('project.task.type', 'Stage'),
1319 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1320 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1321 'date': fields.date('Date', select=True),
1322 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1323 'project.task.history': (_get_related_date, None, 20)
1325 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1326 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1327 'user_id': fields.many2one('res.users', 'Responsible'),
1330 'date': fields.date.context_today,
1334 class project_task_history_cumulative(osv.osv):
1335 _name = 'project.task.history.cumulative'
1336 _table = 'project_task_history_cumulative'
1337 _inherit = 'project.task.history'
1340 'end_date': fields.date('End Date'),
1341 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1344 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1346 history.date::varchar||'-'||history.history_id::varchar as id,
1347 history.date as end_date,
1352 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1353 task_id, type_id, user_id, kanban_state, state,
1354 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1356 project_task_history