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
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',
221 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
222 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
224 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
225 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
226 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
227 '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),
228 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
229 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
230 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)]}),
231 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
232 '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.",
234 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
235 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
237 '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.",
239 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
240 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
242 '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.",
244 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
245 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
247 '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.",
249 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
250 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
252 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
253 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
254 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
255 'color': fields.integer('Color Index'),
256 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
257 help="Internal email associated with this project. Incoming emails are automatically synchronized"
258 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
259 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
260 help="The kind of document created when an email is received on this project's email alias"),
261 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
262 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
263 'doc_count':fields.function(_get_attached_docs, string="Number of documents attached", type='int')
266 def _get_type_common(self, cr, uid, context):
267 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
277 'type_ids': _get_type_common,
278 'alias_model': 'project.task',
279 'privacy_visibility': 'public',
280 'alias_domain': False, # always hide alias during creation
283 # TODO: Why not using a SQL contraints ?
284 def _check_dates(self, cr, uid, ids, context=None):
285 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
286 if leave['date_start'] and leave['date']:
287 if leave['date_start'] > leave['date']:
292 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
295 def set_template(self, cr, uid, ids, context=None):
296 res = self.setActive(cr, uid, ids, value=False, context=context)
299 def set_done(self, cr, uid, ids, context=None):
300 task_obj = self.pool.get('project.task')
301 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
302 task_obj.case_close(cr, uid, task_ids, context=context)
303 self.write(cr, uid, ids, {'state':'close'}, context=context)
304 self.set_close_send_note(cr, uid, ids, context=context)
307 def set_cancel(self, cr, uid, ids, context=None):
308 task_obj = self.pool.get('project.task')
309 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
310 task_obj.case_cancel(cr, uid, task_ids, context=context)
311 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
312 self.set_cancel_send_note(cr, uid, ids, context=context)
315 def set_pending(self, cr, uid, ids, context=None):
316 self.write(cr, uid, ids, {'state':'pending'}, context=context)
317 self.set_pending_send_note(cr, uid, ids, context=context)
320 def set_open(self, cr, uid, ids, context=None):
321 self.write(cr, uid, ids, {'state':'open'}, context=context)
322 self.set_open_send_note(cr, uid, ids, context=context)
325 def reset_project(self, cr, uid, ids, context=None):
326 res = self.setActive(cr, uid, ids, value=True, context=context)
327 self.set_open_send_note(cr, uid, ids, context=context)
330 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
331 """ copy and map tasks from old to new project """
335 task_obj = self.pool.get('project.task')
336 proj = self.browse(cr, uid, old_project_id, context=context)
337 for task in proj.tasks:
338 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
339 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
340 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
343 def copy(self, cr, uid, id, default=None, context=None):
349 context['active_test'] = False
350 default['state'] = 'open'
351 default['tasks'] = []
352 default.pop('alias_name', None)
353 default.pop('alias_id', None)
354 proj = self.browse(cr, uid, id, context=context)
355 if not default.get('name', False):
356 default.update(name=_("%s (copy)") % (proj.name))
357 res = super(project, self).copy(cr, uid, id, default, context)
358 self.map_tasks(cr,uid,id,res,context)
361 def duplicate_template(self, cr, uid, ids, context=None):
364 data_obj = self.pool.get('ir.model.data')
366 for proj in self.browse(cr, uid, ids, context=context):
367 parent_id = context.get('parent_id', False)
368 context.update({'analytic_project_copy': True})
369 new_date_start = time.strftime('%Y-%m-%d')
371 if proj.date_start and proj.date:
372 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
373 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
374 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
375 context.update({'copy':True})
376 new_id = self.copy(cr, uid, proj.id, default = {
377 'name':_("%s (copy)") % (proj.name),
379 'date_start':new_date_start,
381 'parent_id':parent_id}, context=context)
382 result.append(new_id)
384 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
385 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
387 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
389 if result and len(result):
391 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
392 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
393 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
394 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
395 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
396 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
398 'name': _('Projects'),
400 'view_mode': 'form,tree',
401 'res_model': 'project.project',
404 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
405 'type': 'ir.actions.act_window',
406 'search_view_id': search_view['res_id'],
410 # set active value for a project, its sub projects and its tasks
411 def setActive(self, cr, uid, ids, value=True, context=None):
412 task_obj = self.pool.get('project.task')
413 for proj in self.browse(cr, uid, ids, context=None):
414 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
415 cr.execute('select id from project_task where project_id=%s', (proj.id,))
416 tasks_id = [x[0] for x in cr.fetchall()]
418 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
419 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
421 self.setActive(cr, uid, child_ids, value, context=None)
424 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
425 context = context or {}
426 if type(ids) in (long, int,):
428 projects = self.browse(cr, uid, ids, context=context)
430 for project in projects:
431 if (not project.members) and force_members:
432 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
434 resource_pool = self.pool.get('resource.resource')
436 result = "from openerp.addons.resource.faces import *\n"
437 result += "import datetime\n"
438 for project in self.browse(cr, uid, ids, context=context):
439 u_ids = [i.id for i in project.members]
440 if project.user_id and (project.user_id.id not in u_ids):
441 u_ids.append(project.user_id.id)
442 for task in project.tasks:
443 if task.state in ('done','cancelled'):
445 if task.user_id and (task.user_id.id not in u_ids):
446 u_ids.append(task.user_id.id)
447 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
448 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
449 for key, vals in resource_objs.items():
451 class User_%s(Resource):
453 ''' % (key, vals.get('efficiency', False))
460 def _schedule_project(self, cr, uid, project, context=None):
461 resource_pool = self.pool.get('resource.resource')
462 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
463 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
464 # TODO: check if we need working_..., default values are ok.
465 puids = [x.id for x in project.members]
467 puids.append(project.user_id.id)
475 project.date_start, working_days,
476 '|'.join(['User_'+str(x) for x in puids])
478 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
485 #TODO: DO Resource allocation and compute availability
486 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
492 def schedule_tasks(self, cr, uid, ids, context=None):
493 context = context or {}
494 if type(ids) in (long, int,):
496 projects = self.browse(cr, uid, ids, context=context)
497 result = self._schedule_header(cr, uid, ids, False, context=context)
498 for project in projects:
499 result += self._schedule_project(cr, uid, project, context=context)
500 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
503 exec result in local_dict
504 projects_gantt = Task.BalancedProject(local_dict['Project'])
506 for project in projects:
507 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
508 for task in project.tasks:
509 if task.state in ('done','cancelled'):
512 p = getattr(project_gantt, 'Task_%d' % (task.id,))
514 self.pool.get('project.task').write(cr, uid, [task.id], {
515 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
516 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
518 if (not task.user_id) and (p.booked_resource):
519 self.pool.get('project.task').write(cr, uid, [task.id], {
520 'user_id': int(p.booked_resource[0].name[5:]),
524 # ------------------------------------------------
525 # OpenChatter methods and notifications
526 # ------------------------------------------------
528 def create(self, cr, uid, vals, context=None):
529 if context is None: context = {}
530 # Prevent double project creation when 'use_tasks' is checked!
531 context = dict(context, project_creation_in_progress=True)
532 mail_alias = self.pool.get('mail.alias')
533 if not vals.get('alias_id'):
534 vals.pop('alias_name', None) # prevent errors during copy()
535 alias_id = mail_alias.create_unique_alias(cr, uid,
536 # Using '+' allows using subaddressing for those who don't
537 # have a catchall domain setup.
538 {'alias_name': "project+"+short_name(vals['name'])},
539 model_name=vals.get('alias_model', 'project.task'),
541 vals['alias_id'] = alias_id
542 vals['type'] = 'contract'
543 project_id = super(project, self).create(cr, uid, vals, context)
544 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
545 self.create_send_note(cr, uid, [project_id], context=context)
548 def create_send_note(self, cr, uid, ids, context=None):
549 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
551 def set_open_send_note(self, cr, uid, ids, context=None):
552 return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
554 def set_pending_send_note(self, cr, uid, ids, context=None):
555 return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
557 def set_cancel_send_note(self, cr, uid, ids, context=None):
558 return self.message_post(cr, uid, ids, body=_("Project has been <b>canceled</b>."), context=context)
560 def set_close_send_note(self, cr, uid, ids, context=None):
561 return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), context=context)
563 def write(self, cr, uid, ids, vals, context=None):
564 # if alias_model has been changed, update alias_model_id accordingly
565 if vals.get('alias_model'):
566 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
567 vals.update(alias_model_id=model_ids[0])
568 return super(project, self).write(cr, uid, ids, vals, context=context)
570 class task(base_stage, osv.osv):
571 _name = "project.task"
572 _description = "Task"
573 _date_name = "date_start"
574 _inherit = ['mail.thread', 'ir.needaction_mixin']
576 def _get_default_project_id(self, cr, uid, context=None):
577 """ Gives default section by checking if present in the context """
578 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
580 def _get_default_stage_id(self, cr, uid, context=None):
581 """ Gives default stage_id """
582 project_id = self._get_default_project_id(cr, uid, context=context)
583 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
585 def _resolve_project_id_from_context(self, cr, uid, context=None):
586 """ Returns ID of project based on the value of 'default_project_id'
587 context key, or None if it cannot be resolved to a single
590 if context is None: context = {}
591 if type(context.get('default_project_id')) in (int, long):
592 return context['default_project_id']
593 if isinstance(context.get('default_project_id'), basestring):
594 project_name = context['default_project_id']
595 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
596 if len(project_ids) == 1:
597 return project_ids[0][0]
600 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
601 stage_obj = self.pool.get('project.task.type')
602 order = stage_obj._order
603 access_rights_uid = access_rights_uid or uid
604 # lame way to allow reverting search, should just work in the trivial case
605 if read_group_order == 'stage_id desc':
606 order = '%s desc' % order
607 # retrieve section_id from the context and write the domain
608 # - ('id', 'in', 'ids'): add columns that should be present
609 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
610 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
612 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
614 search_domain += ['|', ('project_ids', '=', project_id)]
615 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
616 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
617 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
618 # restore order of the search
619 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
622 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
623 fold[stage.id] = stage.fold or False
626 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
627 res_users = self.pool.get('res.users')
628 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
629 access_rights_uid = access_rights_uid or uid
631 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
632 order = res_users._order
633 # lame way to allow reverting search, should just work in the trivial case
634 if read_group_order == 'user_id desc':
635 order = '%s desc' % order
636 # de-duplicate and apply search order
637 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
638 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
639 # restore order of the search
640 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
644 'stage_id': _read_group_stage_ids,
645 'user_id': _read_group_user_id,
648 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
649 obj_project = self.pool.get('project.project')
651 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
652 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
653 if id and isinstance(id, (long, int)):
654 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
655 args.append(('active', '=', False))
656 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
658 def _str_get(self, task, level=0, border='***', context=None):
659 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'+ \
660 border[0]+' '+(task.name or '')+'\n'+ \
661 (task.description or '')+'\n\n'
663 # Compute: effective_hours, total_hours, progress
664 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
666 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
667 hours = dict(cr.fetchall())
668 for task in self.browse(cr, uid, ids, context=context):
669 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)}
670 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
671 res[task.id]['progress'] = 0.0
672 if (task.remaining_hours + hours.get(task.id, 0.0)):
673 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
674 if task.state in ('done','cancelled'):
675 res[task.id]['progress'] = 100.0
678 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
679 if remaining and not planned:
680 return {'value':{'planned_hours': remaining}}
683 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
684 return {'value':{'remaining_hours': planned - effective}}
686 def onchange_project(self, cr, uid, id, project_id):
689 data = self.pool.get('project.project').browse(cr, uid, [project_id])
690 partner_id=data and data[0].partner_id
692 return {'value':{'partner_id':partner_id.id}}
695 def duplicate_task(self, cr, uid, map_ids, context=None):
696 for new in map_ids.values():
697 task = self.browse(cr, uid, new, context)
698 child_ids = [ ch.id for ch in task.child_ids]
700 for child in task.child_ids:
701 if child.id in map_ids.keys():
702 child_ids.remove(child.id)
703 child_ids.append(map_ids[child.id])
705 parent_ids = [ ch.id for ch in task.parent_ids]
707 for parent in task.parent_ids:
708 if parent.id in map_ids.keys():
709 parent_ids.remove(parent.id)
710 parent_ids.append(map_ids[parent.id])
711 #FIXME why there is already the copy and the old one
712 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
714 def copy_data(self, cr, uid, id, default=None, context=None):
717 default = default or {}
718 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
719 if not default.get('remaining_hours', False):
720 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
721 default['active'] = True
722 if not default.get('name', False):
723 default['name'] = self.browse(cr, uid, id, context=context).name or ''
724 if not context.get('copy',False):
725 new_name = _("%s (copy)") % (default.get('name', ''))
726 default.update({'name':new_name})
727 return super(task, self).copy_data(cr, uid, id, default, context)
729 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
731 for task in self.browse(cr, uid, ids, context=context):
734 if task.project_id.active == False or task.project_id.state == 'template':
738 def _get_task(self, cr, uid, ids, context=None):
740 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
741 if work.task_id: result[work.task_id.id] = True
745 '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."),
746 'name': fields.char('Task Summary', size=128, required=True, select=True),
747 'description': fields.text('Description'),
748 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
749 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
750 'stage_id': fields.many2one('project.task.type', 'Stage',
751 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
752 'state': fields.related('stage_id', 'state', type="selection", store=True,
753 selection=_TASK_STATE, string="Status", readonly=True,
754 help='The status is set to \'Draft\', when a case is created.\
755 If the case is in progress the status is set to \'Open\'.\
756 When the case is over, the status is set to \'Done\'.\
757 If the case needs to be reviewed then the status is \
758 set to \'Pending\'.'),
759 'categ_ids': fields.many2many('project.category', string='Tags'),
760 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
761 help="A task's kanban state indicates special situations affecting it:\n"
762 " * Normal is the default situation\n"
763 " * Blocked indicates something is preventing the progress of this task\n"
764 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
765 readonly=True, required=False),
766 'create_date': fields.datetime('Create Date', readonly=True,select=True),
767 'date_start': fields.datetime('Starting Date',select=True),
768 'date_end': fields.datetime('Ending Date',select=True),
769 'date_deadline': fields.date('Deadline',select=True),
770 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
771 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
772 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
773 'notes': fields.text('Notes'),
774 '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.'),
775 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
777 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
778 'project.task.work': (_get_task, ['hours'], 10),
780 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
781 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
783 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
784 'project.task.work': (_get_task, ['hours'], 10),
786 '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",
788 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
789 'project.task.work': (_get_task, ['hours'], 10),
791 '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.",
793 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
794 'project.task.work': (_get_task, ['hours'], 10),
796 'user_id': fields.many2one('res.users', 'Assigned to'),
797 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
798 'partner_id': fields.many2one('res.partner', 'Customer'),
799 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
800 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
801 'company_id': fields.many2one('res.company', 'Company'),
802 'id': fields.integer('ID', readonly=True),
803 'color': fields.integer('Color Index'),
804 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
807 'stage_id': _get_default_stage_id,
808 'project_id': _get_default_project_id,
809 'kanban_state': 'normal',
814 'user_id': lambda obj, cr, uid, context: uid,
815 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
817 _order = "priority, sequence, date_start, name, id"
819 def set_priority(self, cr, uid, ids, priority, *args):
822 return self.write(cr, uid, ids, {'priority' : priority})
824 def set_very_high_priority(self, cr, uid, ids, *args):
825 """Set task priority to very high
827 return self.set_priority(cr, uid, ids, '0')
829 def set_high_priority(self, cr, uid, ids, *args):
830 """Set task priority to high
832 return self.set_priority(cr, uid, ids, '1')
834 def set_normal_priority(self, cr, uid, ids, *args):
835 """Set task priority to normal
837 return self.set_priority(cr, uid, ids, '2')
839 def _check_recursion(self, cr, uid, ids, context=None):
841 visited_branch = set()
843 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
849 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
850 if id in visited_branch: #Cycle
853 if id in visited_node: #Already tested don't work one more time for nothing
856 visited_branch.add(id)
859 #visit child using DFS
860 task = self.browse(cr, uid, id, context=context)
861 for child in task.child_ids:
862 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
866 visited_branch.remove(id)
869 def _check_dates(self, cr, uid, ids, context=None):
872 obj_task = self.browse(cr, uid, ids[0], context=context)
873 start = obj_task.date_start or False
874 end = obj_task.date_end or False
881 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
882 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
885 # Override view according to the company definition
886 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
887 users_obj = self.pool.get('res.users')
888 if context is None: context = {}
889 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
890 # this should be safe (no context passed to avoid side-effects)
891 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
892 tm = obj_tm and obj_tm.name or 'Hours'
894 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
896 if tm in ['Hours','Hour']:
899 eview = etree.fromstring(res['arch'])
901 def _check_rec(eview):
902 if eview.attrib.get('widget','') == 'float_time':
903 eview.set('widget','float')
910 res['arch'] = etree.tostring(eview)
912 for f in res['fields']:
913 if 'Hours' in res['fields'][f]['string']:
914 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
917 # ----------------------------------------
919 # ----------------------------------------
921 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
922 """ Override of the base.stage method
923 Parameter of the stage search taken from the lead:
924 - section_id: if set, stages must belong to this section or
925 be a default stage; if not set, stages must be default
928 if isinstance(cases, (int, long)):
929 cases = self.browse(cr, uid, cases, context=context)
930 # collect all section_ids
933 section_ids.append(section_id)
936 section_ids.append(task.project_id.id)
937 # OR all section_ids and OR with case_default
940 search_domain += [('|')] * len(section_ids)
941 for section_id in section_ids:
942 search_domain.append(('project_ids', '=', section_id))
943 search_domain.append(('case_default', '=', True))
944 # AND with the domain in parameter
945 search_domain += list(domain)
946 # perform search, return the first found
947 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
952 def _check_child_task(self, cr, uid, ids, context=None):
955 tasks = self.browse(cr, uid, ids, context=context)
958 for child in task.child_ids:
959 if child.state in ['draft', 'open', 'pending']:
960 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
963 def action_close(self, cr, uid, ids, context=None):
964 """ This action closes the task
966 task_id = len(ids) and ids[0] or False
967 self._check_child_task(cr, uid, ids, context=context)
968 if not task_id: return False
969 return self.do_close(cr, uid, [task_id], context=context)
971 def do_close(self, cr, uid, ids, context=None):
972 """ Compatibility when changing to case_close. """
973 return self.case_close(cr, uid, ids, context=context)
975 def case_close(self, cr, uid, ids, context=None):
977 if not isinstance(ids, list): ids = [ids]
978 for task in self.browse(cr, uid, ids, context=context):
980 project = task.project_id
981 for parent_id in task.parent_ids:
982 if parent_id.state in ('pending','draft'):
984 for child in parent_id.child_ids:
985 if child.id != task.id and child.state not in ('done','cancelled'):
988 self.do_reopen(cr, uid, [parent_id.id], context=context)
990 vals['remaining_hours'] = 0.0
991 if not task.date_end:
992 vals['date_end'] = fields.datetime.now()
993 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
994 self.case_close_send_note(cr, uid, [task.id], context=context)
997 def do_reopen(self, cr, uid, ids, context=None):
998 for task in self.browse(cr, uid, ids, context=context):
999 project = task.project_id
1000 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1001 self.case_open_send_note(cr, uid, [task.id], context)
1004 def do_cancel(self, cr, uid, ids, context=None):
1005 """ Compatibility when changing to case_cancel. """
1006 return self.case_cancel(cr, uid, ids, context=context)
1008 def case_cancel(self, cr, uid, ids, context=None):
1009 tasks = self.browse(cr, uid, ids, context=context)
1010 self._check_child_task(cr, uid, ids, context=context)
1012 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1013 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1016 def do_open(self, cr, uid, ids, context=None):
1017 """ Compatibility when changing to case_open. """
1018 return self.case_open(cr, uid, ids, context=context)
1020 def case_open(self, cr, uid, ids, context=None):
1021 if not isinstance(ids,list): ids = [ids]
1022 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1023 self.case_open_send_note(cr, uid, ids, context)
1026 def do_draft(self, cr, uid, ids, context=None):
1027 """ Compatibility when changing to case_draft. """
1028 return self.case_draft(cr, uid, ids, context=context)
1030 def case_draft(self, cr, uid, ids, context=None):
1031 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1032 self.case_draft_send_note(cr, uid, ids, context=context)
1035 def do_pending(self, cr, uid, ids, context=None):
1036 """ Compatibility when changing to case_pending. """
1037 return self.case_pending(cr, uid, ids, context=context)
1039 def case_pending(self, cr, uid, ids, context=None):
1040 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1041 return self.case_pending_send_note(cr, uid, ids, context=context)
1043 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1044 attachment = self.pool.get('ir.attachment')
1045 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1046 new_attachment_ids = []
1047 for attachment_id in attachment_ids:
1048 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1049 return new_attachment_ids
1051 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1053 Delegate Task to another users.
1055 if delegate_data is None:
1057 assert delegate_data['user_id'], _("Delegated User should be specified")
1058 delegated_tasks = {}
1059 for task in self.browse(cr, uid, ids, context=context):
1060 delegated_task_id = self.copy(cr, uid, task.id, {
1061 'name': delegate_data['name'],
1062 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1063 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1064 'planned_hours': delegate_data['planned_hours'] or 0.0,
1065 'parent_ids': [(6, 0, [task.id])],
1066 'description': delegate_data['new_task_description'] or '',
1070 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1071 newname = delegate_data['prefix'] or ''
1073 'remaining_hours': delegate_data['planned_hours_me'],
1074 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1077 if delegate_data['state'] == 'pending':
1078 self.do_pending(cr, uid, [task.id], context=context)
1079 elif delegate_data['state'] == 'done':
1080 self.do_close(cr, uid, [task.id], context=context)
1081 self.do_delegation_send_note(cr, uid, [task.id], context)
1082 delegated_tasks[task.id] = delegated_task_id
1083 return delegated_tasks
1085 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1086 for task in self.browse(cr, uid, ids, context=context):
1087 if (task.state=='draft') or (task.planned_hours==0.0):
1088 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1089 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1092 def set_remaining_time_1(self, cr, uid, ids, context=None):
1093 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1095 def set_remaining_time_2(self, cr, uid, ids, context=None):
1096 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1098 def set_remaining_time_5(self, cr, uid, ids, context=None):
1099 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1101 def set_remaining_time_10(self, cr, uid, ids, context=None):
1102 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1104 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1105 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1108 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1109 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1112 def set_kanban_state_done(self, cr, uid, ids, context=None):
1113 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1116 def _store_history(self, cr, uid, ids, context=None):
1117 for task in self.browse(cr, uid, ids, context=context):
1118 self.pool.get('project.task.history').create(cr, uid, {
1120 'remaining_hours': task.remaining_hours,
1121 'planned_hours': task.planned_hours,
1122 'kanban_state': task.kanban_state,
1123 'type_id': task.stage_id.id,
1124 'state': task.state,
1125 'user_id': task.user_id.id
1130 def create(self, cr, uid, vals, context=None):
1131 task_id = super(task, self).create(cr, uid, vals, context=context)
1132 task_record = self.browse(cr, uid, task_id, context=context)
1133 if task_record.project_id:
1134 project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1135 self.message_subscribe(cr, uid, [task_id], project_follower_ids,
1137 self._store_history(cr, uid, [task_id], context=context)
1138 self.create_send_note(cr, uid, [task_id], context=context)
1141 # Overridden to reset the kanban_state to normal whenever
1142 # the stage (stage_id) of the task changes.
1143 def write(self, cr, uid, ids, vals, context=None):
1144 if isinstance(ids, (int, long)):
1146 if vals.get('project_id'):
1147 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1148 vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1149 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1150 new_stage = vals.get('stage_id')
1151 vals_reset_kstate = dict(vals, kanban_state='normal')
1152 for t in self.browse(cr, uid, ids, context=context):
1153 #TO FIX:Kanban view doesn't raise warning
1154 #stages = [stage.id for stage in t.project_id.type_ids]
1155 #if new_stage not in stages:
1156 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1157 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1158 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1159 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1162 result = super(task,self).write(cr, uid, ids, vals, context=context)
1163 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1164 self._store_history(cr, uid, ids, context=context)
1167 def unlink(self, cr, uid, ids, context=None):
1170 self._check_child_task(cr, uid, ids, context=context)
1171 res = super(task, self).unlink(cr, uid, ids, context)
1174 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1175 context = context or {}
1179 if task.state in ('done','cancelled'):
1184 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1186 for t2 in task.parent_ids:
1187 start.append("up.Task_%s.end" % (t2.id,))
1191 ''' % (ident,','.join(start))
1196 ''' % (ident, 'User_'+str(task.user_id.id))
1201 # ---------------------------------------------------
1203 # ---------------------------------------------------
1205 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1206 """ Override to updates the document according to the email. """
1207 if custom_values is None: custom_values = {}
1208 custom_values.update({
1209 'name': msg.get('subject'),
1210 'planned_hours': 0.0,
1212 return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1214 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1215 """ Override to update the task according to the email. """
1216 if update_vals is None: update_vals = {}
1219 'cost':'planned_hours',
1221 for line in msg['body'].split('\n'):
1223 res = tools.misc.command_re.match(line)
1225 match = res.group(1).lower()
1226 field = maps.get(match)
1229 update_vals[field] = float(res.group(2).lower())
1230 except (ValueError, TypeError):
1232 elif match.lower() == 'state' \
1233 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1234 act = 'do_%s' % res.group(2).lower()
1236 getattr(self,act)(cr, uid, ids, context=context)
1237 return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1239 # ---------------------------------------------------
1240 # OpenChatter methods and notifications
1241 # ---------------------------------------------------
1243 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1244 """ Override of default prefix for notifications. """
1247 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1248 """ Returns the user_ids that have to perform an action.
1249 Add to the previous results given by super the document responsible
1251 :return: dict { record_id: [user_ids], }
1253 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1254 for obj in self.browse(cr, uid, ids, context=context):
1255 if obj.state == 'draft' and obj.user_id:
1256 result[obj.id].append(obj.user_id.id)
1259 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1260 """ Add 'user_id' and 'manager_id' to the monitored fields """
1261 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1262 return res + ['user_id', 'manager_id']
1264 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1265 """ Override of the (void) default notification method. """
1266 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1267 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1270 def create_send_note(self, cr, uid, ids, context=None):
1271 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1273 def case_draft_send_note(self, cr, uid, ids, context=None):
1274 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1276 def do_delegation_send_note(self, cr, uid, ids, context=None):
1277 for task in self.browse(cr, uid, ids, context=context):
1278 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1279 self.message_post(cr, uid, [task.id], body=msg, context=context)
1283 class project_work(osv.osv):
1284 _name = "project.task.work"
1285 _description = "Project Task Work"
1287 'name': fields.char('Work summary', size=128),
1288 'date': fields.datetime('Date', select="1"),
1289 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1290 'hours': fields.float('Time Spent'),
1291 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1292 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1296 'user_id': lambda obj, cr, uid, context: uid,
1297 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1300 _order = "date desc"
1301 def create(self, cr, uid, vals, *args, **kwargs):
1302 if 'hours' in vals and (not vals['hours']):
1303 vals['hours'] = 0.00
1304 if 'task_id' in vals:
1305 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1306 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1308 def write(self, cr, uid, ids, vals, context=None):
1309 if 'hours' in vals and (not vals['hours']):
1310 vals['hours'] = 0.00
1312 for work in self.browse(cr, uid, ids, context=context):
1313 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))
1314 return super(project_work,self).write(cr, uid, ids, vals, context)
1316 def unlink(self, cr, uid, ids, *args, **kwargs):
1317 for work in self.browse(cr, uid, ids):
1318 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1319 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1322 class account_analytic_account(osv.osv):
1323 _inherit = 'account.analytic.account'
1324 _description = 'Analytic Account'
1326 '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"),
1327 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1330 def on_change_template(self, cr, uid, ids, template_id, context=None):
1331 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1332 if template_id and 'value' in res:
1333 template = self.browse(cr, uid, template_id, context=context)
1334 res['value']['use_tasks'] = template.use_tasks
1337 def _trigger_project_creation(self, cr, uid, vals, context=None):
1339 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.
1341 if context is None: context = {}
1342 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1344 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1346 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.
1348 project_pool = self.pool.get('project.project')
1349 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1350 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1352 'name': vals.get('name'),
1353 'analytic_account_id': analytic_account_id,
1355 return project_pool.create(cr, uid, project_values, context=context)
1358 def create(self, cr, uid, vals, context=None):
1361 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1362 vals['child_ids'] = []
1363 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1364 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1365 return analytic_account_id
1367 def write(self, cr, uid, ids, vals, context=None):
1368 name = vals.get('name')
1369 for account in self.browse(cr, uid, ids, context=context):
1371 vals['name'] = account.name
1372 self.project_create(cr, uid, account.id, vals, context=context)
1373 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1375 def unlink(self, cr, uid, ids, *args, **kwargs):
1376 project_obj = self.pool.get('project.project')
1377 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1379 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1380 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1382 class project_project(osv.osv):
1383 _inherit = 'project.project'
1388 class project_task_history(osv.osv):
1390 Tasks History, used for cumulative flow charts (Lean/Agile)
1392 _name = 'project.task.history'
1393 _description = 'History of Tasks'
1394 _rec_name = 'task_id'
1397 def _get_date(self, cr, uid, ids, name, arg, context=None):
1399 for history in self.browse(cr, uid, ids, context=context):
1400 if history.state in ('done','cancelled'):
1401 result[history.id] = history.date
1403 cr.execute('''select
1406 project_task_history
1410 order by id limit 1''', (history.task_id.id, history.id))
1412 result[history.id] = res and res[0] or False
1415 def _get_related_date(self, cr, uid, ids, context=None):
1417 for history in self.browse(cr, uid, ids, context=context):
1418 cr.execute('''select
1421 project_task_history
1425 order by id desc limit 1''', (history.task_id.id, history.id))
1428 result.append(res[0])
1432 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1433 'type_id': fields.many2one('project.task.type', 'Stage'),
1434 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1435 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1436 'date': fields.date('Date', select=True),
1437 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1438 'project.task.history': (_get_related_date, None, 20)
1440 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1441 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1442 'user_id': fields.many2one('res.users', 'Responsible'),
1445 'date': fields.date.context_today,
1448 class project_task_history_cumulative(osv.osv):
1449 _name = 'project.task.history.cumulative'
1450 _table = 'project_task_history_cumulative'
1451 _inherit = 'project.task.history'
1455 'end_date': fields.date('End Date'),
1456 'project_id': fields.many2one('project.project', 'Project'),
1460 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1462 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1464 history.date::varchar||'-'||history.history_id::varchar AS id,
1465 history.date AS end_date,
1470 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1471 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1472 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1475 project_task_history AS h
1476 JOIN project_task AS t ON (h.task_id = t.id)
1482 class project_category(osv.osv):
1483 """ Category of project's task (or issue) """
1484 _name = "project.category"
1485 _description = "Category of project's task, issue, ..."
1487 'name': fields.char('Name', size=64, required=True, translate=True),