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 To Pull')], '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 To Pull 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_high_priority(self, cr, uid, ids, *args):
825 """Set task priority to high
827 return self.set_priority(cr, uid, ids, '1')
829 def set_normal_priority(self, cr, uid, ids, *args):
830 """Set task priority to normal
832 return self.set_priority(cr, uid, ids, '2')
834 def _check_recursion(self, cr, uid, ids, context=None):
836 visited_branch = set()
838 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
844 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
845 if id in visited_branch: #Cycle
848 if id in visited_node: #Already tested don't work one more time for nothing
851 visited_branch.add(id)
854 #visit child using DFS
855 task = self.browse(cr, uid, id, context=context)
856 for child in task.child_ids:
857 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
861 visited_branch.remove(id)
864 def _check_dates(self, cr, uid, ids, context=None):
867 obj_task = self.browse(cr, uid, ids[0], context=context)
868 start = obj_task.date_start or False
869 end = obj_task.date_end or False
876 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
877 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
880 # Override view according to the company definition
881 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
882 users_obj = self.pool.get('res.users')
883 if context is None: context = {}
884 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
885 # this should be safe (no context passed to avoid side-effects)
886 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
887 tm = obj_tm and obj_tm.name or 'Hours'
889 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
891 if tm in ['Hours','Hour']:
894 eview = etree.fromstring(res['arch'])
896 def _check_rec(eview):
897 if eview.attrib.get('widget','') == 'float_time':
898 eview.set('widget','float')
905 res['arch'] = etree.tostring(eview)
907 for f in res['fields']:
908 if 'Hours' in res['fields'][f]['string']:
909 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
912 # ----------------------------------------
914 # ----------------------------------------
916 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
917 """ Override of the base.stage method
918 Parameter of the stage search taken from the lead:
919 - section_id: if set, stages must belong to this section or
920 be a default stage; if not set, stages must be default
923 if isinstance(cases, (int, long)):
924 cases = self.browse(cr, uid, cases, context=context)
925 # collect all section_ids
928 section_ids.append(section_id)
931 section_ids.append(task.project_id.id)
932 # OR all section_ids and OR with case_default
935 search_domain += [('|')] * len(section_ids)
936 for section_id in section_ids:
937 search_domain.append(('project_ids', '=', section_id))
938 search_domain.append(('case_default', '=', True))
939 # AND with the domain in parameter
940 search_domain += list(domain)
941 # perform search, return the first found
942 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
947 def _check_child_task(self, cr, uid, ids, context=None):
950 tasks = self.browse(cr, uid, ids, context=context)
953 for child in task.child_ids:
954 if child.state in ['draft', 'open', 'pending']:
955 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
958 def action_close(self, cr, uid, ids, context=None):
959 """ This action closes the task
961 task_id = len(ids) and ids[0] or False
962 self._check_child_task(cr, uid, ids, context=context)
963 if not task_id: return False
964 return self.do_close(cr, uid, [task_id], context=context)
966 def do_close(self, cr, uid, ids, context=None):
967 """ Compatibility when changing to case_close. """
968 return self.case_close(cr, uid, ids, context=context)
970 def case_close(self, cr, uid, ids, context=None):
972 if not isinstance(ids, list): ids = [ids]
973 for task in self.browse(cr, uid, ids, context=context):
975 project = task.project_id
976 for parent_id in task.parent_ids:
977 if parent_id.state in ('pending','draft'):
979 for child in parent_id.child_ids:
980 if child.id != task.id and child.state not in ('done','cancelled'):
983 self.do_reopen(cr, uid, [parent_id.id], context=context)
985 vals['remaining_hours'] = 0.0
986 if not task.date_end:
987 vals['date_end'] = fields.datetime.now()
988 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
989 self.case_close_send_note(cr, uid, [task.id], context=context)
992 def do_reopen(self, cr, uid, ids, context=None):
993 for task in self.browse(cr, uid, ids, context=context):
994 project = task.project_id
995 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
996 self.case_open_send_note(cr, uid, [task.id], context)
999 def do_cancel(self, cr, uid, ids, context=None):
1000 """ Compatibility when changing to case_cancel. """
1001 return self.case_cancel(cr, uid, ids, context=context)
1003 def case_cancel(self, cr, uid, ids, context=None):
1004 tasks = self.browse(cr, uid, ids, context=context)
1005 self._check_child_task(cr, uid, ids, context=context)
1007 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1008 self.case_cancel_send_note(cr, uid, [task.id], context=context)
1011 def do_open(self, cr, uid, ids, context=None):
1012 """ Compatibility when changing to case_open. """
1013 return self.case_open(cr, uid, ids, context=context)
1015 def case_open(self, cr, uid, ids, context=None):
1016 if not isinstance(ids,list): ids = [ids]
1017 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1018 self.case_open_send_note(cr, uid, ids, context)
1021 def do_draft(self, cr, uid, ids, context=None):
1022 """ Compatibility when changing to case_draft. """
1023 return self.case_draft(cr, uid, ids, context=context)
1025 def case_draft(self, cr, uid, ids, context=None):
1026 self.case_set(cr, uid, ids, 'draft', {}, context=context)
1027 self.case_draft_send_note(cr, uid, ids, context=context)
1030 def do_pending(self, cr, uid, ids, context=None):
1031 """ Compatibility when changing to case_pending. """
1032 return self.case_pending(cr, uid, ids, context=context)
1034 def case_pending(self, cr, uid, ids, context=None):
1035 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1036 return self.case_pending_send_note(cr, uid, ids, context=context)
1038 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1039 attachment = self.pool.get('ir.attachment')
1040 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1041 new_attachment_ids = []
1042 for attachment_id in attachment_ids:
1043 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1044 return new_attachment_ids
1046 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1048 Delegate Task to another users.
1050 if delegate_data is None:
1052 assert delegate_data['user_id'], _("Delegated User should be specified")
1053 delegated_tasks = {}
1054 for task in self.browse(cr, uid, ids, context=context):
1055 delegated_task_id = self.copy(cr, uid, task.id, {
1056 'name': delegate_data['name'],
1057 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1058 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1059 'planned_hours': delegate_data['planned_hours'] or 0.0,
1060 'parent_ids': [(6, 0, [task.id])],
1061 'description': delegate_data['new_task_description'] or '',
1065 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1066 newname = delegate_data['prefix'] or ''
1068 'remaining_hours': delegate_data['planned_hours_me'],
1069 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1072 if delegate_data['state'] == 'pending':
1073 self.do_pending(cr, uid, [task.id], context=context)
1074 elif delegate_data['state'] == 'done':
1075 self.do_close(cr, uid, [task.id], context=context)
1076 self.do_delegation_send_note(cr, uid, [task.id], context)
1077 delegated_tasks[task.id] = delegated_task_id
1078 return delegated_tasks
1080 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1081 for task in self.browse(cr, uid, ids, context=context):
1082 if (task.state=='draft') or (task.planned_hours==0.0):
1083 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1084 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1087 def set_remaining_time_1(self, cr, uid, ids, context=None):
1088 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1090 def set_remaining_time_2(self, cr, uid, ids, context=None):
1091 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1093 def set_remaining_time_5(self, cr, uid, ids, context=None):
1094 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1096 def set_remaining_time_10(self, cr, uid, ids, context=None):
1097 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1099 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1100 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1103 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1104 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1107 def set_kanban_state_done(self, cr, uid, ids, context=None):
1108 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1111 def _store_history(self, cr, uid, ids, context=None):
1112 for task in self.browse(cr, uid, ids, context=context):
1113 self.pool.get('project.task.history').create(cr, uid, {
1115 'remaining_hours': task.remaining_hours,
1116 'planned_hours': task.planned_hours,
1117 'kanban_state': task.kanban_state,
1118 'type_id': task.stage_id.id,
1119 'state': task.state,
1120 'user_id': task.user_id.id
1125 def create(self, cr, uid, vals, context=None):
1126 task_id = super(task, self).create(cr, uid, vals, context=context)
1127 task_record = self.browse(cr, uid, task_id, context=context)
1128 if task_record.project_id:
1129 project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1130 self.message_subscribe(cr, uid, [task_id], project_follower_ids,
1132 self._store_history(cr, uid, [task_id], context=context)
1133 self.create_send_note(cr, uid, [task_id], context=context)
1136 # Overridden to reset the kanban_state to normal whenever
1137 # the stage (stage_id) of the task changes.
1138 def write(self, cr, uid, ids, vals, context=None):
1139 if isinstance(ids, (int, long)):
1141 if vals.get('project_id'):
1142 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1143 vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1144 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1145 new_stage = vals.get('stage_id')
1146 vals_reset_kstate = dict(vals, kanban_state='normal')
1147 for t in self.browse(cr, uid, ids, context=context):
1148 #TO FIX:Kanban view doesn't raise warning
1149 #stages = [stage.id for stage in t.project_id.type_ids]
1150 #if new_stage not in stages:
1151 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1152 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1153 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1154 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1157 result = super(task,self).write(cr, uid, ids, vals, context=context)
1158 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1159 self._store_history(cr, uid, ids, context=context)
1162 def unlink(self, cr, uid, ids, context=None):
1165 self._check_child_task(cr, uid, ids, context=context)
1166 res = super(task, self).unlink(cr, uid, ids, context)
1169 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1170 context = context or {}
1174 if task.state in ('done','cancelled'):
1179 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1181 for t2 in task.parent_ids:
1182 start.append("up.Task_%s.end" % (t2.id,))
1186 ''' % (ident,','.join(start))
1191 ''' % (ident, 'User_'+str(task.user_id.id))
1196 # ---------------------------------------------------
1198 # ---------------------------------------------------
1200 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1201 """ Override to updates the document according to the email. """
1202 if custom_values is None: custom_values = {}
1203 custom_values.update({
1204 'name': msg.get('subject'),
1205 'planned_hours': 0.0,
1207 return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1209 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1210 """ Override to update the task according to the email. """
1211 if update_vals is None: update_vals = {}
1214 'cost':'planned_hours',
1216 for line in msg['body'].split('\n'):
1218 res = tools.misc.command_re.match(line)
1220 match = res.group(1).lower()
1221 field = maps.get(match)
1224 update_vals[field] = float(res.group(2).lower())
1225 except (ValueError, TypeError):
1227 elif match.lower() == 'state' \
1228 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1229 act = 'do_%s' % res.group(2).lower()
1231 getattr(self,act)(cr, uid, ids, context=context)
1232 return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1234 # ---------------------------------------------------
1235 # OpenChatter methods and notifications
1236 # ---------------------------------------------------
1238 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1239 """ Override of default prefix for notifications. """
1242 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1243 """ Returns the user_ids that have to perform an action.
1244 Add to the previous results given by super the document responsible
1246 :return: dict { record_id: [user_ids], }
1248 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1249 for obj in self.browse(cr, uid, ids, context=context):
1250 if obj.state == 'draft' and obj.user_id:
1251 result[obj.id].append(obj.user_id.id)
1254 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1255 """ Add 'user_id' and 'manager_id' to the monitored fields """
1256 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1257 return res + ['user_id', 'manager_id']
1259 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1260 """ Override of the (void) default notification method. """
1261 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1262 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1265 def create_send_note(self, cr, uid, ids, context=None):
1266 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1268 def case_draft_send_note(self, cr, uid, ids, context=None):
1269 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1271 def do_delegation_send_note(self, cr, uid, ids, context=None):
1272 for task in self.browse(cr, uid, ids, context=context):
1273 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1274 self.message_post(cr, uid, [task.id], body=msg, context=context)
1278 class project_work(osv.osv):
1279 _name = "project.task.work"
1280 _description = "Project Task Work"
1282 'name': fields.char('Work summary', size=128),
1283 'date': fields.datetime('Date', select="1"),
1284 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1285 'hours': fields.float('Time Spent'),
1286 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1287 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1291 'user_id': lambda obj, cr, uid, context: uid,
1292 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1295 _order = "date desc"
1296 def create(self, cr, uid, vals, *args, **kwargs):
1297 if 'hours' in vals and (not vals['hours']):
1298 vals['hours'] = 0.00
1299 if 'task_id' in vals:
1300 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1301 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1303 def write(self, cr, uid, ids, vals, context=None):
1304 if 'hours' in vals and (not vals['hours']):
1305 vals['hours'] = 0.00
1307 for work in self.browse(cr, uid, ids, context=context):
1308 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))
1309 return super(project_work,self).write(cr, uid, ids, vals, context)
1311 def unlink(self, cr, uid, ids, *args, **kwargs):
1312 for work in self.browse(cr, uid, ids):
1313 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1314 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1317 class account_analytic_account(osv.osv):
1318 _inherit = 'account.analytic.account'
1319 _description = 'Analytic Account'
1321 '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"),
1322 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1325 def on_change_template(self, cr, uid, ids, template_id, context=None):
1326 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1327 if template_id and 'value' in res:
1328 template = self.browse(cr, uid, template_id, context=context)
1329 res['value']['use_tasks'] = template.use_tasks
1332 def _trigger_project_creation(self, cr, uid, vals, context=None):
1334 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.
1336 if context is None: context = {}
1337 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1339 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1341 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.
1343 project_pool = self.pool.get('project.project')
1344 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1345 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1347 'name': vals.get('name'),
1348 'analytic_account_id': analytic_account_id,
1350 return project_pool.create(cr, uid, project_values, context=context)
1353 def create(self, cr, uid, vals, context=None):
1356 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1357 vals['child_ids'] = []
1358 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1359 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1360 return analytic_account_id
1362 def write(self, cr, uid, ids, vals, context=None):
1363 name = vals.get('name')
1364 for account in self.browse(cr, uid, ids, context=context):
1366 vals['name'] = account.name
1367 self.project_create(cr, uid, account.id, vals, context=context)
1368 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1370 def unlink(self, cr, uid, ids, *args, **kwargs):
1371 project_obj = self.pool.get('project.project')
1372 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1374 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1375 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1377 class project_project(osv.osv):
1378 _inherit = 'project.project'
1383 class project_task_history(osv.osv):
1385 Tasks History, used for cumulative flow charts (Lean/Agile)
1387 _name = 'project.task.history'
1388 _description = 'History of Tasks'
1389 _rec_name = 'task_id'
1392 def _get_date(self, cr, uid, ids, name, arg, context=None):
1394 for history in self.browse(cr, uid, ids, context=context):
1395 if history.state in ('done','cancelled'):
1396 result[history.id] = history.date
1398 cr.execute('''select
1401 project_task_history
1405 order by id limit 1''', (history.task_id.id, history.id))
1407 result[history.id] = res and res[0] or False
1410 def _get_related_date(self, cr, uid, ids, context=None):
1412 for history in self.browse(cr, uid, ids, context=context):
1413 cr.execute('''select
1416 project_task_history
1420 order by id desc limit 1''', (history.task_id.id, history.id))
1423 result.append(res[0])
1427 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1428 'type_id': fields.many2one('project.task.type', 'Stage'),
1429 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1430 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1431 'date': fields.date('Date', select=True),
1432 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1433 'project.task.history': (_get_related_date, None, 20)
1435 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1436 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1437 'user_id': fields.many2one('res.users', 'Responsible'),
1440 'date': fields.date.context_today,
1443 class project_task_history_cumulative(osv.osv):
1444 _name = 'project.task.history.cumulative'
1445 _table = 'project_task_history_cumulative'
1446 _inherit = 'project.task.history'
1450 'end_date': fields.date('End Date'),
1451 'project_id': fields.many2one('project.project', 'Project'),
1455 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1457 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1459 history.date::varchar||'-'||history.history_id::varchar AS id,
1460 history.date AS end_date,
1465 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1466 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1467 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1470 project_task_history AS h
1471 JOIN project_task AS t ON (h.task_id = t.id)
1477 class project_category(osv.osv):
1478 """ Category of project's task (or issue) """
1479 _name = "project.category"
1480 _description = "Category of project's task, issue, ..."
1482 'name': fields.char('Name', size=64, required=True, translate=True),