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 ##############################################################################
23 from lxml import etree
24 from datetime import datetime, date
27 from base_status.base_stage import base_stage
28 from osv import fields, osv
29 from openerp.addons.resource.faces import task as Task
30 from tools.translate import _
31 from openerp import SUPERUSER_ID
33 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
35 class project_task_type(osv.osv):
36 _name = 'project.task.type'
37 _description = 'Task Stage'
40 'name': fields.char('Stage Name', required=True, size=64, translate=True),
41 'description': fields.text('Description'),
42 'sequence': fields.integer('Sequence'),
43 'case_default': fields.boolean('Common to All Projects',
44 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."),
45 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
46 'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
47 help="The status of your document is automatically changed regarding the selected stage. " \
48 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
49 'fold': fields.boolean('Hide in views if empty',
50 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
61 """Keep first word(s) of name to make it small enough
63 if not name: return name
64 # keep 7 chars + end of the last word
65 keep_words = name[:7].strip().split()
66 return ' '.join(name.split()[:len(keep_words)])
68 class project(osv.osv):
69 _name = "project.project"
70 _description = "Project"
71 _inherits = {'account.analytic.account': "analytic_account_id",
72 "mail.alias": "alias_id"}
73 _inherit = ['mail.thread', 'ir.needaction_mixin']
75 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
77 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
78 if context and context.get('user_preference'):
79 cr.execute("""SELECT project.id FROM project_project project
80 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
81 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
82 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
83 return [(r[0]) for r in cr.fetchall()]
84 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
85 context=context, count=count)
87 def _complete_name(self, cr, uid, ids, name, args, context=None):
89 for m in self.browse(cr, uid, ids, context=context):
90 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
93 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
94 partner_obj = self.pool.get('res.partner')
98 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
99 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
100 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
101 val['pricelist_id'] = pricelist_id
102 return {'value': val}
104 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
105 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
106 project_ids = [task.project_id.id for task in tasks if task.project_id]
107 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
109 def _get_project_and_parents(self, cr, uid, ids, context=None):
110 """ return the project ids and all their parent projects """
114 SELECT DISTINCT parent.id
115 FROM project_project project, project_project parent, account_analytic_account account
116 WHERE project.analytic_account_id = account.id
117 AND parent.analytic_account_id = account.parent_id
120 ids = [t[0] for t in cr.fetchall()]
124 def _get_project_and_children(self, cr, uid, ids, context=None):
125 """ retrieve all children projects of project ids;
126 return a dictionary mapping each project to its parent project (or None)
128 res = dict.fromkeys(ids, None)
131 SELECT project.id, parent.id
132 FROM project_project project, project_project parent, account_analytic_account account
133 WHERE project.analytic_account_id = account.id
134 AND parent.analytic_account_id = account.parent_id
137 dic = dict(cr.fetchall())
142 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
143 child_parent = self._get_project_and_children(cr, uid, ids, context)
144 # compute planned_hours, total_hours, effective_hours specific to each project
146 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
147 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
148 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
150 """, (tuple(child_parent.keys()),))
151 # aggregate results into res
152 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
153 for id, planned, total, effective in cr.fetchall():
154 # add the values specific to id to all parent projects of id in the result
157 res[id]['planned_hours'] += planned
158 res[id]['total_hours'] += total
159 res[id]['effective_hours'] += effective
160 id = child_parent[id]
161 # compute progress rates
163 if res[id]['total_hours']:
164 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
166 res[id]['progress_rate'] = 0.0
169 def unlink(self, cr, uid, ids, *args, **kwargs):
171 mail_alias = self.pool.get('mail.alias')
172 for proj in self.browse(cr, uid, ids):
174 raise osv.except_osv(_('Invalid Action!'),
175 _('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.'))
177 alias_ids.append(proj.alias_id.id)
178 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
179 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
182 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
184 attachment = self.pool.get('ir.attachment')
185 task = self.pool.get('project.task')
187 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', 'in', [id])], context=context)
188 task_ids = task.search(cr, uid, [('project_id', 'in', [id])])
189 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context)
190 res[id] = len(project_attachments + task_attachments)
193 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
194 res = dict.fromkeys(ids, 0)
195 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
196 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
197 res[task.project_id.id] += 1
200 def _get_alias_models(self, cr, uid, context=None):
201 """Overriden in project_issue to offer more options"""
202 return [('project.task', "Tasks")]
204 def attachment_tree_view(self, cr, uid, ids, context):
205 attachment = self.pool.get('ir.attachment')
206 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', 'in', ids)], context=context)
207 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
208 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context)
209 all_attachment = project_attachments + task_attachments
210 res_id = ids and ids[0] or False
212 'name': _('Attachments'),
213 'domain': [('id','in', all_attachment)],
214 'res_model': 'ir.attachment',
215 'type': 'ir.actions.act_window',
217 'view_mode': 'tree,form',
220 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
222 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
223 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
225 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
226 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
227 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
228 'analytic_account_id': fields.many2one('account.analytic.account', 'Contract/Analytic', help="Link this project to an analytic account if you need financial management on projects. It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True),
229 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
230 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
231 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)]}),
232 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
233 '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.",
235 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
236 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
238 '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.",
240 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
241 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
243 '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.",
245 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
246 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
248 '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.",
250 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
251 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
253 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
254 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
255 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
256 'color': fields.integer('Color Index'),
257 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
258 help="Internal email associated with this project. Incoming emails are automatically synchronized"
259 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
260 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
261 help="The kind of document created when an email is received on this project's email alias"),
262 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
263 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
264 'doc_count':fields.function(_get_attached_docs, string="Number of documents attached", type='int')
267 def _get_type_common(self, cr, uid, context):
268 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
278 'type_ids': _get_type_common,
279 'alias_model': 'project.task',
280 'privacy_visibility': 'public',
281 'alias_domain': False, # always hide alias during creation
284 # TODO: Why not using a SQL contraints ?
285 def _check_dates(self, cr, uid, ids, context=None):
286 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
287 if leave['date_start'] and leave['date']:
288 if leave['date_start'] > leave['date']:
293 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
296 def set_template(self, cr, uid, ids, context=None):
297 res = self.setActive(cr, uid, ids, value=False, context=context)
300 def set_done(self, cr, uid, ids, context=None):
301 task_obj = self.pool.get('project.task')
302 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
303 task_obj.case_close(cr, uid, task_ids, context=context)
304 self.write(cr, uid, ids, {'state':'close'}, context=context)
305 self.set_close_send_note(cr, uid, ids, context=context)
308 def set_cancel(self, cr, uid, ids, context=None):
309 task_obj = self.pool.get('project.task')
310 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
311 task_obj.case_cancel(cr, uid, task_ids, context=context)
312 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
313 self.set_cancel_send_note(cr, uid, ids, context=context)
316 def set_pending(self, cr, uid, ids, context=None):
317 self.write(cr, uid, ids, {'state':'pending'}, context=context)
318 self.set_pending_send_note(cr, uid, ids, context=context)
321 def set_open(self, cr, uid, ids, context=None):
322 self.write(cr, uid, ids, {'state':'open'}, context=context)
323 self.set_open_send_note(cr, uid, ids, context=context)
326 def reset_project(self, cr, uid, ids, context=None):
327 res = self.setActive(cr, uid, ids, value=True, context=context)
328 self.set_open_send_note(cr, uid, ids, context=context)
331 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
332 """ copy and map tasks from old to new project """
336 task_obj = self.pool.get('project.task')
337 proj = self.browse(cr, uid, old_project_id, context=context)
338 for task in proj.tasks:
339 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
340 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
341 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
344 def copy(self, cr, uid, id, default=None, context=None):
350 context['active_test'] = False
351 default['state'] = 'open'
352 default['tasks'] = []
353 default.pop('alias_name', None)
354 default.pop('alias_id', None)
355 proj = self.browse(cr, uid, id, context=context)
356 if not default.get('name', False):
357 default.update(name=_("%s (copy)") % (proj.name))
358 res = super(project, self).copy(cr, uid, id, default, context)
359 self.map_tasks(cr,uid,id,res,context)
362 def duplicate_template(self, cr, uid, ids, context=None):
365 data_obj = self.pool.get('ir.model.data')
367 for proj in self.browse(cr, uid, ids, context=context):
368 parent_id = context.get('parent_id', False)
369 context.update({'analytic_project_copy': True})
370 new_date_start = time.strftime('%Y-%m-%d')
372 if proj.date_start and proj.date:
373 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
374 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
375 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
376 context.update({'copy':True})
377 new_id = self.copy(cr, uid, proj.id, default = {
378 'name':_("%s (copy)") % (proj.name),
380 'date_start':new_date_start,
382 'parent_id':parent_id}, context=context)
383 result.append(new_id)
385 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
386 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
388 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
390 if result and len(result):
392 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
393 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
394 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
395 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
396 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
397 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
399 'name': _('Projects'),
401 'view_mode': 'form,tree',
402 'res_model': 'project.project',
405 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
406 'type': 'ir.actions.act_window',
407 'search_view_id': search_view['res_id'],
411 # set active value for a project, its sub projects and its tasks
412 def setActive(self, cr, uid, ids, value=True, context=None):
413 task_obj = self.pool.get('project.task')
414 for proj in self.browse(cr, uid, ids, context=None):
415 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
416 cr.execute('select id from project_task where project_id=%s', (proj.id,))
417 tasks_id = [x[0] for x in cr.fetchall()]
419 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
420 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
422 self.setActive(cr, uid, child_ids, value, context=None)
425 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
426 context = context or {}
427 if type(ids) in (long, int,):
429 projects = self.browse(cr, uid, ids, context=context)
431 for project in projects:
432 if (not project.members) and force_members:
433 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
435 resource_pool = self.pool.get('resource.resource')
437 result = "from openerp.addons.resource.faces import *\n"
438 result += "import datetime\n"
439 for project in self.browse(cr, uid, ids, context=context):
440 u_ids = [i.id for i in project.members]
441 if project.user_id and (project.user_id.id not in u_ids):
442 u_ids.append(project.user_id.id)
443 for task in project.tasks:
444 if task.state in ('done','cancelled'):
446 if task.user_id and (task.user_id.id not in u_ids):
447 u_ids.append(task.user_id.id)
448 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
449 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
450 for key, vals in resource_objs.items():
452 class User_%s(Resource):
454 ''' % (key, vals.get('efficiency', False))
461 def _schedule_project(self, cr, uid, project, context=None):
462 resource_pool = self.pool.get('resource.resource')
463 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
464 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
465 # TODO: check if we need working_..., default values are ok.
466 puids = [x.id for x in project.members]
468 puids.append(project.user_id.id)
476 project.date_start, working_days,
477 '|'.join(['User_'+str(x) for x in puids])
479 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
486 #TODO: DO Resource allocation and compute availability
487 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
493 def schedule_tasks(self, cr, uid, ids, context=None):
494 context = context or {}
495 if type(ids) in (long, int,):
497 projects = self.browse(cr, uid, ids, context=context)
498 result = self._schedule_header(cr, uid, ids, False, context=context)
499 for project in projects:
500 result += self._schedule_project(cr, uid, project, context=context)
501 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
504 exec result in local_dict
505 projects_gantt = Task.BalancedProject(local_dict['Project'])
507 for project in projects:
508 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
509 for task in project.tasks:
510 if task.state in ('done','cancelled'):
513 p = getattr(project_gantt, 'Task_%d' % (task.id,))
515 self.pool.get('project.task').write(cr, uid, [task.id], {
516 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
517 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
519 if (not task.user_id) and (p.booked_resource):
520 self.pool.get('project.task').write(cr, uid, [task.id], {
521 'user_id': int(p.booked_resource[0].name[5:]),
525 # ------------------------------------------------
526 # OpenChatter methods and notifications
527 # ------------------------------------------------
529 def create(self, cr, uid, vals, context=None):
530 if context is None: context = {}
531 # Prevent double project creation when 'use_tasks' is checked!
532 context = dict(context, project_creation_in_progress=True)
533 mail_alias = self.pool.get('mail.alias')
534 if not vals.get('alias_id'):
535 vals.pop('alias_name', None) # prevent errors during copy()
536 alias_id = mail_alias.create_unique_alias(cr, uid,
537 # Using '+' allows using subaddressing for those who don't
538 # have a catchall domain setup.
539 {'alias_name': "project+"+short_name(vals['name'])},
540 model_name=vals.get('alias_model', 'project.task'),
542 vals['alias_id'] = alias_id
543 vals['type'] = 'contract'
544 project_id = super(project, self).create(cr, uid, vals, context)
545 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
546 self.create_send_note(cr, uid, [project_id], context=context)
549 def create_send_note(self, cr, uid, ids, context=None):
550 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
552 def set_open_send_note(self, cr, uid, ids, context=None):
553 return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
555 def set_pending_send_note(self, cr, uid, ids, context=None):
556 return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
558 def set_cancel_send_note(self, cr, uid, ids, context=None):
559 return self.message_post(cr, uid, ids, body=_("Project has been <b>canceled</b>."), context=context)
561 def set_close_send_note(self, cr, uid, ids, context=None):
562 return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), context=context)
564 def write(self, cr, uid, ids, vals, context=None):
565 # if alias_model has been changed, update alias_model_id accordingly
566 if vals.get('alias_model'):
567 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
568 vals.update(alias_model_id=model_ids[0])
569 return super(project, self).write(cr, uid, ids, vals, context=context)
571 class task(base_stage, osv.osv):
572 _name = "project.task"
573 _description = "Task"
574 _date_name = "date_start"
575 _inherit = ['mail.thread', 'ir.needaction_mixin']
577 def _get_default_project_id(self, cr, uid, context=None):
578 """ Gives default section by checking if present in the context """
579 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
581 def _get_default_stage_id(self, cr, uid, context=None):
582 """ Gives default stage_id """
583 project_id = self._get_default_project_id(cr, uid, context=context)
584 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
586 def _resolve_project_id_from_context(self, cr, uid, context=None):
587 """ Returns ID of project based on the value of 'default_project_id'
588 context key, or None if it cannot be resolved to a single
591 if context is None: context = {}
592 if type(context.get('default_project_id')) in (int, long):
593 return context['default_project_id']
594 if isinstance(context.get('default_project_id'), basestring):
595 project_name = context['default_project_id']
596 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
597 if len(project_ids) == 1:
598 return project_ids[0][0]
601 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
602 stage_obj = self.pool.get('project.task.type')
603 order = stage_obj._order
604 access_rights_uid = access_rights_uid or uid
605 # lame way to allow reverting search, should just work in the trivial case
606 if read_group_order == 'stage_id desc':
607 order = '%s desc' % order
608 # retrieve section_id from the context and write the domain
609 # - ('id', 'in', 'ids'): add columns that should be present
610 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
611 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
613 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
615 search_domain += ['|', ('project_ids', '=', project_id)]
616 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
617 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
618 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
619 # restore order of the search
620 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
623 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
624 fold[stage.id] = stage.fold or False
627 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
628 res_users = self.pool.get('res.users')
629 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
630 access_rights_uid = access_rights_uid or uid
632 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
633 order = res_users._order
634 # lame way to allow reverting search, should just work in the trivial case
635 if read_group_order == 'user_id desc':
636 order = '%s desc' % order
637 # de-duplicate and apply search order
638 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
639 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
640 # restore order of the search
641 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
645 'stage_id': _read_group_stage_ids,
646 'user_id': _read_group_user_id,
649 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
650 obj_project = self.pool.get('project.project')
652 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
653 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
654 if id and isinstance(id, (long, int)):
655 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
656 args.append(('active', '=', False))
657 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
659 def _str_get(self, task, level=0, border='***', context=None):
660 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'+ \
661 border[0]+' '+(task.name or '')+'\n'+ \
662 (task.description or '')+'\n\n'
664 # Compute: effective_hours, total_hours, progress
665 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
667 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
668 hours = dict(cr.fetchall())
669 for task in self.browse(cr, uid, ids, context=context):
670 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)}
671 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
672 res[task.id]['progress'] = 0.0
673 if (task.remaining_hours + hours.get(task.id, 0.0)):
674 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
675 if task.state in ('done','cancelled'):
676 res[task.id]['progress'] = 100.0
679 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
680 if remaining and not planned:
681 return {'value':{'planned_hours': remaining}}
684 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
685 return {'value':{'remaining_hours': planned - effective}}
687 def onchange_project(self, cr, uid, id, project_id):
690 data = self.pool.get('project.project').browse(cr, uid, [project_id])
691 partner_id=data and data[0].partner_id
693 return {'value':{'partner_id':partner_id.id}}
696 def duplicate_task(self, cr, uid, map_ids, context=None):
697 for new in map_ids.values():
698 task = self.browse(cr, uid, new, context)
699 child_ids = [ ch.id for ch in task.child_ids]
701 for child in task.child_ids:
702 if child.id in map_ids.keys():
703 child_ids.remove(child.id)
704 child_ids.append(map_ids[child.id])
706 parent_ids = [ ch.id for ch in task.parent_ids]
708 for parent in task.parent_ids:
709 if parent.id in map_ids.keys():
710 parent_ids.remove(parent.id)
711 parent_ids.append(map_ids[parent.id])
712 #FIXME why there is already the copy and the old one
713 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
715 def copy_data(self, cr, uid, id, default=None, context=None):
718 default = default or {}
719 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
720 if not default.get('remaining_hours', False):
721 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
722 default['active'] = True
723 if not default.get('name', False):
724 default['name'] = self.browse(cr, uid, id, context=context).name or ''
725 if not context.get('copy',False):
726 new_name = _("%s (copy)") % (default.get('name', ''))
727 default.update({'name':new_name})
728 return super(task, self).copy_data(cr, uid, id, default, context)
730 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
732 for task in self.browse(cr, uid, ids, context=context):
735 if task.project_id.active == False or task.project_id.state == 'template':
739 def _get_task(self, cr, uid, ids, context=None):
741 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
742 if work.task_id: result[work.task_id.id] = True
746 '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."),
747 'name': fields.char('Task Summary', size=128, required=True, select=True),
748 'description': fields.text('Description'),
749 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
750 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
751 'stage_id': fields.many2one('project.task.type', 'Stage',
752 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
753 'state': fields.related('stage_id', 'state', type="selection", store=True,
754 selection=_TASK_STATE, string="Status", readonly=True,
755 help='The status is set to \'Draft\', when a case is created.\
756 If the case is in progress the status is set to \'Open\'.\
757 When the case is over, the status is set to \'Done\'.\
758 If the case needs to be reviewed then the status is \
759 set to \'Pending\'.'),
760 'categ_ids': fields.many2many('project.category', string='Tags'),
761 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
762 help="A task's kanban state indicates special situations affecting it:\n"
763 " * Normal is the default situation\n"
764 " * Blocked indicates something is preventing the progress of this task\n"
765 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
766 readonly=True, required=False),
767 'create_date': fields.datetime('Create Date', readonly=True,select=True),
768 'date_start': fields.datetime('Starting Date',select=True),
769 'date_end': fields.datetime('Ending Date',select=True),
770 'date_deadline': fields.date('Deadline',select=True),
771 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
772 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
773 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
774 'notes': fields.text('Notes'),
775 'planned_hours': fields.float('Initially Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
776 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
778 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
779 'project.task.work': (_get_task, ['hours'], 10),
781 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
782 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
784 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
785 'project.task.work': (_get_task, ['hours'], 10),
787 '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",
789 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
790 'project.task.work': (_get_task, ['hours'], 10),
792 '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.",
794 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
795 'project.task.work': (_get_task, ['hours'], 10),
797 'user_id': fields.many2one('res.users', 'Assigned to'),
798 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
799 'partner_id': fields.many2one('res.partner', 'Customer'),
800 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
801 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
802 'company_id': fields.many2one('res.company', 'Company'),
803 'id': fields.integer('ID', readonly=True),
804 'color': fields.integer('Color Index'),
805 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
808 'stage_id': _get_default_stage_id,
809 'project_id': _get_default_project_id,
810 'kanban_state': 'normal',
815 'user_id': lambda obj, cr, uid, context: uid,
816 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
818 _order = "priority, sequence, date_start, name, id"
820 def set_priority(self, cr, uid, ids, priority, *args):
823 return self.write(cr, uid, ids, {'priority' : priority})
825 def set_very_high_priority(self, cr, uid, ids, *args):
826 """Set task priority to very high
828 return self.set_priority(cr, uid, ids, '0')
830 def set_high_priority(self, cr, uid, ids, *args):
831 """Set task priority to high
833 return self.set_priority(cr, uid, ids, '1')
835 def set_normal_priority(self, cr, uid, ids, *args):
836 """Set task priority to normal
838 return self.set_priority(cr, uid, ids, '2')
840 def _check_recursion(self, cr, uid, ids, context=None):
842 visited_branch = set()
844 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
850 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
851 if id in visited_branch: #Cycle
854 if id in visited_node: #Already tested don't work one more time for nothing
857 visited_branch.add(id)
860 #visit child using DFS
861 task = self.browse(cr, uid, id, context=context)
862 for child in task.child_ids:
863 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
867 visited_branch.remove(id)
870 def _check_dates(self, cr, uid, ids, context=None):
873 obj_task = self.browse(cr, uid, ids[0], context=context)
874 start = obj_task.date_start or False
875 end = obj_task.date_end or False
882 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
883 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
886 # Override view according to the company definition
887 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
888 users_obj = self.pool.get('res.users')
889 if context is None: context = {}
890 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
891 # this should be safe (no context passed to avoid side-effects)
892 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
893 tm = obj_tm and obj_tm.name or 'Hours'
895 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
897 if tm in ['Hours','Hour']:
900 eview = etree.fromstring(res['arch'])
902 def _check_rec(eview):
903 if eview.attrib.get('widget','') == 'float_time':
904 eview.set('widget','float')
911 res['arch'] = etree.tostring(eview)
913 for f in res['fields']:
914 if 'Hours' in res['fields'][f]['string']:
915 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
918 # ----------------------------------------
920 # ----------------------------------------
922 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
923 """ Override of the base.stage method
924 Parameter of the stage search taken from the lead:
925 - section_id: if set, stages must belong to this section or
926 be a default stage; if not set, stages must be default
929 if isinstance(cases, (int, long)):
930 cases = self.browse(cr, uid, cases, context=context)
931 # collect all section_ids
934 section_ids.append(section_id)
937 section_ids.append(task.project_id.id)
938 # OR all section_ids and OR with case_default
941 search_domain += [('|')] * len(section_ids)
942 for section_id in section_ids:
943 search_domain.append(('project_ids', '=', section_id))
944 search_domain.append(('case_default', '=', True))
945 # AND with the domain in parameter
946 search_domain += list(domain)
947 # perform search, return the first found
948 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
953 def _check_child_task(self, cr, uid, ids, context=None):
956 tasks = self.browse(cr, uid, ids, context=context)
959 for child in task.child_ids:
960 if child.state in ['draft', 'open', 'pending']:
961 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
964 def action_close(self, cr, uid, ids, context=None):
965 """ This action closes the task
967 task_id = len(ids) and ids[0] or False
968 self._check_child_task(cr, uid, ids, context=context)
969 if not task_id: return False
970 return self.do_close(cr, uid, [task_id], context=context)
972 def do_close(self, cr, uid, ids, context=None):
973 """ Compatibility when changing to case_close. """
974 return self.case_close(cr, uid, ids, context=context)
976 def case_close(self, cr, uid, ids, context=None):
978 if not isinstance(ids, list): ids = [ids]
979 for task in self.browse(cr, uid, ids, context=context):
981 project = task.project_id
982 for parent_id in task.parent_ids:
983 if parent_id.state in ('pending','draft'):
985 for child in parent_id.child_ids:
986 if child.id != task.id and child.state not in ('done','cancelled'):
989 self.do_reopen(cr, uid, [parent_id.id], context=context)
991 vals['remaining_hours'] = 0.0
992 if not task.date_end:
993 vals['date_end'] = fields.datetime.now()
994 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
995 self.case_close_send_note(cr, uid, [task.id], context=context)
998 def do_reopen(self, cr, uid, ids, context=None):
999 for task in self.browse(cr, uid, ids, context=context):
1000 project = task.project_id
1001 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1002 self.case_open_send_note(cr, uid, [task.id], context)
1005 def do_cancel(self, cr, uid, ids, context=None):
1006 """ Compatibility when changing to case_cancel. """
1007 return self.case_cancel(cr, uid, ids, context=context)
1009 def case_cancel(self, cr, uid, ids, context=None):
1010 tasks = self.browse(cr, uid, ids, context=context)
1011 self._check_child_task(cr, uid, ids, context=context)
1013 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1014 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1017 def do_open(self, cr, uid, ids, context=None):
1018 """ Compatibility when changing to case_open. """
1019 return self.case_open(cr, uid, ids, context=context)
1021 def case_open(self, cr, uid, ids, context=None):
1022 if not isinstance(ids,list): ids = [ids]
1023 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1024 self.case_open_send_note(cr, uid, ids, context)
1027 def do_draft(self, cr, uid, ids, context=None):
1028 """ Compatibility when changing to case_draft. """
1029 return self.case_draft(cr, uid, ids, context=context)
1031 def case_draft(self, cr, uid, ids, context=None):
1032 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1033 self.case_draft_send_note(cr, uid, ids, context=context)
1036 def do_pending(self, cr, uid, ids, context=None):
1037 """ Compatibility when changing to case_pending. """
1038 return self.case_pending(cr, uid, ids, context=context)
1040 def case_pending(self, cr, uid, ids, context=None):
1041 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1042 return self.case_pending_send_note(cr, uid, ids, context=context)
1044 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1045 attachment = self.pool.get('ir.attachment')
1046 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1047 new_attachment_ids = []
1048 for attachment_id in attachment_ids:
1049 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1050 return new_attachment_ids
1052 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1054 Delegate Task to another users.
1056 if delegate_data is None:
1058 assert delegate_data['user_id'], _("Delegated User should be specified")
1059 delegated_tasks = {}
1060 for task in self.browse(cr, uid, ids, context=context):
1061 delegated_task_id = self.copy(cr, uid, task.id, {
1062 'name': delegate_data['name'],
1063 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1064 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1065 'planned_hours': delegate_data['planned_hours'] or 0.0,
1066 'parent_ids': [(6, 0, [task.id])],
1067 'description': delegate_data['new_task_description'] or '',
1071 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1072 newname = delegate_data['prefix'] or ''
1074 'remaining_hours': delegate_data['planned_hours_me'],
1075 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1078 if delegate_data['state'] == 'pending':
1079 self.do_pending(cr, uid, [task.id], context=context)
1080 elif delegate_data['state'] == 'done':
1081 self.do_close(cr, uid, [task.id], context=context)
1082 self.do_delegation_send_note(cr, uid, [task.id], context)
1083 delegated_tasks[task.id] = delegated_task_id
1084 return delegated_tasks
1086 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1087 for task in self.browse(cr, uid, ids, context=context):
1088 if (task.state=='draft') or (task.planned_hours==0.0):
1089 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1090 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1093 def set_remaining_time_1(self, cr, uid, ids, context=None):
1094 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1096 def set_remaining_time_2(self, cr, uid, ids, context=None):
1097 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1099 def set_remaining_time_5(self, cr, uid, ids, context=None):
1100 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1102 def set_remaining_time_10(self, cr, uid, ids, context=None):
1103 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1105 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1106 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1109 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1110 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1113 def set_kanban_state_done(self, cr, uid, ids, context=None):
1114 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1117 def _store_history(self, cr, uid, ids, context=None):
1118 for task in self.browse(cr, uid, ids, context=context):
1119 self.pool.get('project.task.history').create(cr, uid, {
1121 'remaining_hours': task.remaining_hours,
1122 'planned_hours': task.planned_hours,
1123 'kanban_state': task.kanban_state,
1124 'type_id': task.stage_id.id,
1125 'state': task.state,
1126 'user_id': task.user_id.id
1131 def create(self, cr, uid, vals, context=None):
1132 task_id = super(task, self).create(cr, uid, vals, context=context)
1133 task_record = self.browse(cr, uid, task_id, context=context)
1134 if task_record.project_id:
1135 project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1136 self.message_subscribe(cr, uid, [task_id], project_follower_ids,
1138 self._store_history(cr, uid, [task_id], context=context)
1139 self.create_send_note(cr, uid, [task_id], context=context)
1142 # Overridden to reset the kanban_state to normal whenever
1143 # the stage (stage_id) of the task changes.
1144 def write(self, cr, uid, ids, vals, context=None):
1145 if isinstance(ids, (int, long)):
1147 if vals.get('project_id'):
1148 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1149 vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1150 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1151 new_stage = vals.get('stage_id')
1152 vals_reset_kstate = dict(vals, kanban_state='normal')
1153 for t in self.browse(cr, uid, ids, context=context):
1154 #TO FIX:Kanban view doesn't raise warning
1155 #stages = [stage.id for stage in t.project_id.type_ids]
1156 #if new_stage not in stages:
1157 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1158 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1159 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1160 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1163 result = super(task,self).write(cr, uid, ids, vals, context=context)
1164 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1165 self._store_history(cr, uid, ids, context=context)
1168 def unlink(self, cr, uid, ids, context=None):
1171 self._check_child_task(cr, uid, ids, context=context)
1172 res = super(task, self).unlink(cr, uid, ids, context)
1175 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1176 context = context or {}
1180 if task.state in ('done','cancelled'):
1185 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1187 for t2 in task.parent_ids:
1188 start.append("up.Task_%s.end" % (t2.id,))
1192 ''' % (ident,','.join(start))
1197 ''' % (ident, 'User_'+str(task.user_id.id))
1202 # ---------------------------------------------------
1204 # ---------------------------------------------------
1206 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1207 """ Override to updates the document according to the email. """
1208 if custom_values is None: custom_values = {}
1209 custom_values.update({
1210 'name': msg.get('subject'),
1211 'planned_hours': 0.0,
1213 return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1215 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1216 """ Override to update the task according to the email. """
1217 if update_vals is None: update_vals = {}
1220 'cost':'planned_hours',
1222 for line in msg['body'].split('\n'):
1224 res = tools.misc.command_re.match(line)
1226 match = res.group(1).lower()
1227 field = maps.get(match)
1230 update_vals[field] = float(res.group(2).lower())
1231 except (ValueError, TypeError):
1233 elif match.lower() == 'state' \
1234 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1235 act = 'do_%s' % res.group(2).lower()
1237 getattr(self,act)(cr, uid, ids, context=context)
1238 return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1240 # ---------------------------------------------------
1241 # OpenChatter methods and notifications
1242 # ---------------------------------------------------
1244 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1245 """ Override of default prefix for notifications. """
1248 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1249 """ Returns the user_ids that have to perform an action.
1250 Add to the previous results given by super the document responsible
1252 :return: dict { record_id: [user_ids], }
1254 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1255 for obj in self.browse(cr, uid, ids, context=context):
1256 if obj.state == 'draft' and obj.user_id:
1257 result[obj.id].append(obj.user_id.id)
1260 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1261 """ Add 'user_id' and 'manager_id' to the monitored fields """
1262 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1263 return res + ['user_id', 'manager_id']
1265 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1266 """ Override of the (void) default notification method. """
1267 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1268 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1271 def create_send_note(self, cr, uid, ids, context=None):
1272 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1274 def case_draft_send_note(self, cr, uid, ids, context=None):
1275 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1277 def do_delegation_send_note(self, cr, uid, ids, context=None):
1278 for task in self.browse(cr, uid, ids, context=context):
1279 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1280 self.message_post(cr, uid, [task.id], body=msg, context=context)
1284 class project_work(osv.osv):
1285 _name = "project.task.work"
1286 _description = "Project Task Work"
1288 'name': fields.char('Work summary', size=128),
1289 'date': fields.datetime('Date', select="1"),
1290 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1291 'hours': fields.float('Time Spent'),
1292 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1293 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1297 'user_id': lambda obj, cr, uid, context: uid,
1298 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1301 _order = "date desc"
1302 def create(self, cr, uid, vals, *args, **kwargs):
1303 if 'hours' in vals and (not vals['hours']):
1304 vals['hours'] = 0.00
1305 if 'task_id' in vals:
1306 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1307 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1309 def write(self, cr, uid, ids, vals, context=None):
1310 if 'hours' in vals and (not vals['hours']):
1311 vals['hours'] = 0.00
1313 for work in self.browse(cr, uid, ids, context=context):
1314 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))
1315 return super(project_work,self).write(cr, uid, ids, vals, context)
1317 def unlink(self, cr, uid, ids, *args, **kwargs):
1318 for work in self.browse(cr, uid, ids):
1319 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1320 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1323 class account_analytic_account(osv.osv):
1324 _inherit = 'account.analytic.account'
1325 _description = 'Analytic Account'
1327 'use_tasks': fields.boolean('Tasks',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1328 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1331 def on_change_template(self, cr, uid, ids, template_id, context=None):
1332 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1333 if template_id and 'value' in res:
1334 template = self.browse(cr, uid, template_id, context=context)
1335 res['value']['use_tasks'] = template.use_tasks
1338 def _trigger_project_creation(self, cr, uid, vals, context=None):
1340 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.
1342 if context is None: context = {}
1343 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1345 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1347 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.
1349 project_pool = self.pool.get('project.project')
1350 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1351 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1353 'name': vals.get('name'),
1354 'analytic_account_id': analytic_account_id,
1356 return project_pool.create(cr, uid, project_values, context=context)
1359 def create(self, cr, uid, vals, context=None):
1362 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1363 vals['child_ids'] = []
1364 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1365 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1366 return analytic_account_id
1368 def write(self, cr, uid, ids, vals, context=None):
1369 name = vals.get('name')
1370 for account in self.browse(cr, uid, ids, context=context):
1372 vals['name'] = account.name
1373 self.project_create(cr, uid, account.id, vals, context=context)
1374 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1376 def unlink(self, cr, uid, ids, *args, **kwargs):
1377 project_obj = self.pool.get('project.project')
1378 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1380 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1381 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1383 class project_project(osv.osv):
1384 _inherit = 'project.project'
1389 class project_task_history(osv.osv):
1391 Tasks History, used for cumulative flow charts (Lean/Agile)
1393 _name = 'project.task.history'
1394 _description = 'History of Tasks'
1395 _rec_name = 'task_id'
1398 def _get_date(self, cr, uid, ids, name, arg, context=None):
1400 for history in self.browse(cr, uid, ids, context=context):
1401 if history.state in ('done','cancelled'):
1402 result[history.id] = history.date
1404 cr.execute('''select
1407 project_task_history
1411 order by id limit 1''', (history.task_id.id, history.id))
1413 result[history.id] = res and res[0] or False
1416 def _get_related_date(self, cr, uid, ids, context=None):
1418 for history in self.browse(cr, uid, ids, context=context):
1419 cr.execute('''select
1422 project_task_history
1426 order by id desc limit 1''', (history.task_id.id, history.id))
1429 result.append(res[0])
1433 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1434 'type_id': fields.many2one('project.task.type', 'Stage'),
1435 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1436 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1437 'date': fields.date('Date', select=True),
1438 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1439 'project.task.history': (_get_related_date, None, 20)
1441 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1442 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1443 'user_id': fields.many2one('res.users', 'Responsible'),
1446 'date': fields.date.context_today,
1449 class project_task_history_cumulative(osv.osv):
1450 _name = 'project.task.history.cumulative'
1451 _table = 'project_task_history_cumulative'
1452 _inherit = 'project.task.history'
1456 'end_date': fields.date('End Date'),
1457 'project_id': fields.many2one('project.project', 'Project'),
1461 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1463 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1465 history.date::varchar||'-'||history.history_id::varchar AS id,
1466 history.date AS end_date,
1471 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1472 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1473 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1476 project_task_history AS h
1477 JOIN project_task AS t ON (h.task_id = t.id)
1483 class project_category(osv.osv):
1484 """ Category of project's task (or issue) """
1485 _name = "project.category"
1486 _description = "Category of project's task, issue, ..."
1488 'name': fields.char('Name', size=64, required=True, translate=True),