1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
22 from base_status.base_stage import base_stage
23 from datetime import datetime, date
24 from lxml import etree
25 from osv import fields, osv
26 from openerp.addons.resource.faces import task as Task
28 from tools.translate import _
29 from openerp import SUPERUSER_ID
31 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
33 class project_task_type(osv.osv):
34 _name = 'project.task.type'
35 _description = 'Task Stage'
38 'name': fields.char('Stage Name', required=True, size=64, translate=True),
39 'description': fields.text('Description'),
40 'sequence': fields.integer('Sequence'),
41 'case_default': fields.boolean('Common to All Projects',
42 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."),
43 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
44 'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
45 help="The status of your document is automatically changed regarding the selected stage. " \
46 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
47 'fold': fields.boolean('Hide in views if empty',
48 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
59 """Keep first word(s) of name to make it small enough
61 if not name: return name
62 # keep 7 chars + end of the last word
63 keep_words = name[:7].strip().split()
64 return ' '.join(name.split()[:len(keep_words)])
66 class project(osv.osv):
67 _name = "project.project"
68 _description = "Project"
69 _inherits = {'account.analytic.account': "analytic_account_id",
70 "mail.alias": "alias_id"}
71 _inherit = ['mail.thread', 'ir.needaction_mixin']
73 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
75 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
76 if context and context.get('user_preference'):
77 cr.execute("""SELECT project.id FROM project_project project
78 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
79 LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
80 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
81 return [(r[0]) for r in cr.fetchall()]
82 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
83 context=context, count=count)
85 def _complete_name(self, cr, uid, ids, name, args, context=None):
87 for m in self.browse(cr, uid, ids, context=context):
88 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
91 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
92 partner_obj = self.pool.get('res.partner')
96 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
97 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
98 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
99 val['pricelist_id'] = pricelist_id
100 return {'value': val}
102 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
103 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
104 project_ids = [task.project_id.id for task in tasks if task.project_id]
105 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
107 def _get_project_and_parents(self, cr, uid, ids, context=None):
108 """ return the project ids and all their parent projects """
112 SELECT DISTINCT parent.id
113 FROM project_project project, project_project parent, account_analytic_account account
114 WHERE project.analytic_account_id = account.id
115 AND parent.analytic_account_id = account.parent_id
118 ids = [t[0] for t in cr.fetchall()]
122 def _get_project_and_children(self, cr, uid, ids, context=None):
123 """ retrieve all children projects of project ids;
124 return a dictionary mapping each project to its parent project (or None)
126 res = dict.fromkeys(ids, None)
129 SELECT project.id, parent.id
130 FROM project_project project, project_project parent, account_analytic_account account
131 WHERE project.analytic_account_id = account.id
132 AND parent.analytic_account_id = account.parent_id
135 dic = dict(cr.fetchall())
140 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
141 child_parent = self._get_project_and_children(cr, uid, ids, context)
142 # compute planned_hours, total_hours, effective_hours specific to each project
144 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
145 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
146 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
148 """, (tuple(child_parent.keys()),))
149 # aggregate results into res
150 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
151 for id, planned, total, effective in cr.fetchall():
152 # add the values specific to id to all parent projects of id in the result
155 res[id]['planned_hours'] += planned
156 res[id]['total_hours'] += total
157 res[id]['effective_hours'] += effective
158 id = child_parent[id]
159 # compute progress rates
161 if res[id]['total_hours']:
162 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
164 res[id]['progress_rate'] = 0.0
167 def unlink(self, cr, uid, ids, *args, **kwargs):
169 mail_alias = self.pool.get('mail.alias')
170 for proj in self.browse(cr, uid, ids):
172 raise osv.except_osv(_('Invalid Action!'),
173 _('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.'))
175 alias_ids.append(proj.alias_id.id)
176 res = super(project, self).unlink(cr, uid, ids, *args, **kwargs)
177 mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
180 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
181 res = dict.fromkeys(ids, 0)
182 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
183 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
184 res[task.project_id.id] += 1
187 def _get_alias_models(self, cr, uid, context=None):
188 """Overriden in project_issue to offer more options"""
189 return [('project.task', "Tasks")]
191 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
192 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
194 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
195 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
196 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
197 '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),
198 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
199 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
200 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)]}),
201 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
202 '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.",
204 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
205 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
207 '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.",
209 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
210 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
212 '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.",
214 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
215 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
217 '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.",
219 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
220 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
222 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
223 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
224 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
225 'color': fields.integer('Color Index'),
226 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
227 help="Internal email associated with this project. Incoming emails are automatically synchronized"
228 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
229 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
230 help="The kind of document created when an email is received on this project's email alias"),
231 'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
232 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
235 def _get_type_common(self, cr, uid, context):
236 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
246 'type_ids': _get_type_common,
247 'alias_model': 'project.task',
248 'privacy_visibility': 'public',
249 'alias_domain': False, # always hide alias during creation
252 # TODO: Why not using a SQL contraints ?
253 def _check_dates(self, cr, uid, ids, context=None):
254 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
255 if leave['date_start'] and leave['date']:
256 if leave['date_start'] > leave['date']:
261 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
264 def set_template(self, cr, uid, ids, context=None):
265 res = self.setActive(cr, uid, ids, value=False, context=context)
268 def set_done(self, cr, uid, ids, context=None):
269 task_obj = self.pool.get('project.task')
270 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
271 task_obj.case_close(cr, uid, task_ids, context=context)
272 self.write(cr, uid, ids, {'state':'close'}, context=context)
273 self.set_close_send_note(cr, uid, ids, context=context)
276 def set_cancel(self, cr, uid, ids, context=None):
277 task_obj = self.pool.get('project.task')
278 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
279 task_obj.case_cancel(cr, uid, task_ids, context=context)
280 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
281 self.set_cancel_send_note(cr, uid, ids, context=context)
284 def set_pending(self, cr, uid, ids, context=None):
285 self.write(cr, uid, ids, {'state':'pending'}, context=context)
286 self.set_pending_send_note(cr, uid, ids, context=context)
289 def set_open(self, cr, uid, ids, context=None):
290 self.write(cr, uid, ids, {'state':'open'}, context=context)
291 self.set_open_send_note(cr, uid, ids, context=context)
294 def reset_project(self, cr, uid, ids, context=None):
295 res = self.setActive(cr, uid, ids, value=True, context=context)
296 self.set_open_send_note(cr, uid, ids, context=context)
299 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
300 """ copy and map tasks from old to new project """
304 task_obj = self.pool.get('project.task')
305 proj = self.browse(cr, uid, old_project_id, context=context)
306 for task in proj.tasks:
307 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
308 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
309 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
312 def copy(self, cr, uid, id, default=None, context=None):
318 context['active_test'] = False
319 default['state'] = 'open'
320 default['tasks'] = []
321 default.pop('alias_name', None)
322 default.pop('alias_id', None)
323 proj = self.browse(cr, uid, id, context=context)
324 if not default.get('name', False):
325 default.update(name=_("%s (copy)") % (proj.name))
326 res = super(project, self).copy(cr, uid, id, default, context)
327 self.map_tasks(cr,uid,id,res,context)
330 def duplicate_template(self, cr, uid, ids, context=None):
333 data_obj = self.pool.get('ir.model.data')
335 for proj in self.browse(cr, uid, ids, context=context):
336 parent_id = context.get('parent_id', False)
337 context.update({'analytic_project_copy': True})
338 new_date_start = time.strftime('%Y-%m-%d')
340 if proj.date_start and proj.date:
341 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
342 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
343 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
344 context.update({'copy':True})
345 new_id = self.copy(cr, uid, proj.id, default = {
346 'name':_("%s (copy)") % (proj.name),
348 'date_start':new_date_start,
350 'parent_id':parent_id}, context=context)
351 result.append(new_id)
353 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
354 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
356 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
358 if result and len(result):
360 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
361 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
362 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
363 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
364 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
365 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
367 'name': _('Projects'),
369 'view_mode': 'form,tree',
370 'res_model': 'project.project',
373 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
374 'type': 'ir.actions.act_window',
375 'search_view_id': search_view['res_id'],
379 # set active value for a project, its sub projects and its tasks
380 def setActive(self, cr, uid, ids, value=True, context=None):
381 task_obj = self.pool.get('project.task')
382 for proj in self.browse(cr, uid, ids, context=None):
383 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
384 cr.execute('select id from project_task where project_id=%s', (proj.id,))
385 tasks_id = [x[0] for x in cr.fetchall()]
387 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
388 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
390 self.setActive(cr, uid, child_ids, value, context=None)
393 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
394 context = context or {}
395 if type(ids) in (long, int,):
397 projects = self.browse(cr, uid, ids, context=context)
399 for project in projects:
400 if (not project.members) and force_members:
401 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
403 resource_pool = self.pool.get('resource.resource')
405 result = "from openerp.addons.resource.faces import *\n"
406 result += "import datetime\n"
407 for project in self.browse(cr, uid, ids, context=context):
408 u_ids = [i.id for i in project.members]
409 if project.user_id and (project.user_id.id not in u_ids):
410 u_ids.append(project.user_id.id)
411 for task in project.tasks:
412 if task.state in ('done','cancelled'):
414 if task.user_id and (task.user_id.id not in u_ids):
415 u_ids.append(task.user_id.id)
416 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
417 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
418 for key, vals in resource_objs.items():
420 class User_%s(Resource):
422 ''' % (key, vals.get('efficiency', False))
429 def _schedule_project(self, cr, uid, project, context=None):
430 resource_pool = self.pool.get('resource.resource')
431 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
432 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
433 # TODO: check if we need working_..., default values are ok.
434 puids = [x.id for x in project.members]
436 puids.append(project.user_id.id)
444 project.date_start, working_days,
445 '|'.join(['User_'+str(x) for x in puids])
447 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
454 #TODO: DO Resource allocation and compute availability
455 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
461 def schedule_tasks(self, cr, uid, ids, context=None):
462 context = context or {}
463 if type(ids) in (long, int,):
465 projects = self.browse(cr, uid, ids, context=context)
466 result = self._schedule_header(cr, uid, ids, False, context=context)
467 for project in projects:
468 result += self._schedule_project(cr, uid, project, context=context)
469 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
472 exec result in local_dict
473 projects_gantt = Task.BalancedProject(local_dict['Project'])
475 for project in projects:
476 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
477 for task in project.tasks:
478 if task.state in ('done','cancelled'):
481 p = getattr(project_gantt, 'Task_%d' % (task.id,))
483 self.pool.get('project.task').write(cr, uid, [task.id], {
484 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
485 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
487 if (not task.user_id) and (p.booked_resource):
488 self.pool.get('project.task').write(cr, uid, [task.id], {
489 'user_id': int(p.booked_resource[0].name[5:]),
493 # ------------------------------------------------
494 # OpenChatter methods and notifications
495 # ------------------------------------------------
497 def create(self, cr, uid, vals, context=None):
498 if context is None: context = {}
499 # Prevent double project creation when 'use_tasks' is checked!
500 context = dict(context, project_creation_in_progress=True)
501 mail_alias = self.pool.get('mail.alias')
502 if not vals.get('alias_id'):
503 vals.pop('alias_name', None) # prevent errors during copy()
504 alias_id = mail_alias.create_unique_alias(cr, uid,
505 # Using '+' allows using subaddressing for those who don't
506 # have a catchall domain setup.
507 {'alias_name': "project+"+short_name(vals['name'])},
508 model_name=vals.get('alias_model', 'project.task'),
510 vals['alias_id'] = alias_id
511 if vals.get('partner_id', False):
512 vals['type'] = 'contract'
513 project_id = super(project, self).create(cr, uid, vals, context)
514 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
515 self.create_send_note(cr, uid, [project_id], context=context)
518 def create_send_note(self, cr, uid, ids, context=None):
519 return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
521 def set_open_send_note(self, cr, uid, ids, context=None):
522 return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
524 def set_pending_send_note(self, cr, uid, ids, context=None):
525 return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
527 def set_cancel_send_note(self, cr, uid, ids, context=None):
528 return self.message_post(cr, uid, ids, body=_("Project has been <b>canceled</b>."), context=context)
530 def set_close_send_note(self, cr, uid, ids, context=None):
531 return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), context=context)
533 def write(self, cr, uid, ids, vals, context=None):
534 # if alias_model has been changed, update alias_model_id accordingly
535 if vals.get('alias_model'):
536 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
537 vals.update(alias_model_id=model_ids[0])
538 return super(project, self).write(cr, uid, ids, vals, context=context)
540 class task(base_stage, osv.osv):
541 _name = "project.task"
542 _description = "Task"
543 _date_name = "date_start"
544 _inherit = ['mail.thread', 'ir.needaction_mixin']
546 def _get_default_project_id(self, cr, uid, context=None):
547 """ Gives default section by checking if present in the context """
548 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
550 def _get_default_stage_id(self, cr, uid, context=None):
551 """ Gives default stage_id """
552 project_id = self._get_default_project_id(cr, uid, context=context)
553 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
555 def _resolve_project_id_from_context(self, cr, uid, context=None):
556 """ Returns ID of project based on the value of 'default_project_id'
557 context key, or None if it cannot be resolved to a single
560 if context is None: context = {}
561 if type(context.get('default_project_id')) in (int, long):
562 return context['default_project_id']
563 if isinstance(context.get('default_project_id'), basestring):
564 project_name = context['default_project_id']
565 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
566 if len(project_ids) == 1:
567 return project_ids[0][0]
570 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
571 stage_obj = self.pool.get('project.task.type')
572 order = stage_obj._order
573 access_rights_uid = access_rights_uid or uid
574 # lame way to allow reverting search, should just work in the trivial case
575 if read_group_order == 'stage_id desc':
576 order = '%s desc' % order
577 # retrieve section_id from the context and write the domain
578 # - ('id', 'in', 'ids'): add columns that should be present
579 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
580 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
582 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
584 search_domain += ['|', ('project_ids', '=', project_id)]
585 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
586 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
587 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
588 # restore order of the search
589 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
592 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
593 fold[stage.id] = stage.fold or False
596 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
597 res_users = self.pool.get('res.users')
598 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
599 access_rights_uid = access_rights_uid or uid
601 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
602 order = res_users._order
603 # lame way to allow reverting search, should just work in the trivial case
604 if read_group_order == 'user_id desc':
605 order = '%s desc' % order
606 # de-duplicate and apply search order
607 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
608 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
609 # restore order of the search
610 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
614 'stage_id': _read_group_stage_ids,
615 'user_id': _read_group_user_id,
618 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
619 obj_project = self.pool.get('project.project')
621 if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
622 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
623 if id and isinstance(id, (long, int)):
624 if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
625 args.append(('active', '=', False))
626 return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
628 def _str_get(self, task, level=0, border='***', context=None):
629 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'+ \
630 border[0]+' '+(task.name or '')+'\n'+ \
631 (task.description or '')+'\n\n'
633 # Compute: effective_hours, total_hours, progress
634 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
636 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
637 hours = dict(cr.fetchall())
638 for task in self.browse(cr, uid, ids, context=context):
639 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)}
640 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
641 res[task.id]['progress'] = 0.0
642 if (task.remaining_hours + hours.get(task.id, 0.0)):
643 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
644 if task.state in ('done','cancelled'):
645 res[task.id]['progress'] = 100.0
648 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
649 if remaining and not planned:
650 return {'value':{'planned_hours': remaining}}
653 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
654 return {'value':{'remaining_hours': planned - effective}}
656 def onchange_project(self, cr, uid, id, project_id):
659 data = self.pool.get('project.project').browse(cr, uid, [project_id])
660 partner_id=data and data[0].partner_id
662 return {'value':{'partner_id':partner_id.id}}
665 def duplicate_task(self, cr, uid, map_ids, context=None):
666 for new in map_ids.values():
667 task = self.browse(cr, uid, new, context)
668 child_ids = [ ch.id for ch in task.child_ids]
670 for child in task.child_ids:
671 if child.id in map_ids.keys():
672 child_ids.remove(child.id)
673 child_ids.append(map_ids[child.id])
675 parent_ids = [ ch.id for ch in task.parent_ids]
677 for parent in task.parent_ids:
678 if parent.id in map_ids.keys():
679 parent_ids.remove(parent.id)
680 parent_ids.append(map_ids[parent.id])
681 #FIXME why there is already the copy and the old one
682 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
684 def copy_data(self, cr, uid, id, default=None, context=None):
687 default = default or {}
688 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
689 if not default.get('remaining_hours', False):
690 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
691 default['active'] = True
692 if not default.get('name', False):
693 default['name'] = self.browse(cr, uid, id, context=context).name or ''
694 if not context.get('copy',False):
695 new_name = _("%s (copy)") % (default.get('name', ''))
696 default.update({'name':new_name})
697 return super(task, self).copy_data(cr, uid, id, default, context)
699 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
701 for task in self.browse(cr, uid, ids, context=context):
704 if task.project_id.active == False or task.project_id.state == 'template':
708 def _get_task(self, cr, uid, ids, context=None):
710 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
711 if work.task_id: result[work.task_id.id] = True
715 '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."),
716 'name': fields.char('Task Summary', size=128, required=True, select=True),
717 'description': fields.text('Description'),
718 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
719 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
720 'stage_id': fields.many2one('project.task.type', 'Stage',
721 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
722 'state': fields.related('stage_id', 'state', type="selection", store=True,
723 selection=_TASK_STATE, string="State", readonly=True,
724 help='The state is set to \'Draft\', when a case is created.\
725 If the case is in progress the state is set to \'Open\'.\
726 When the case is over, the state is set to \'Done\'.\
727 If the case needs to be reviewed then the state is \
728 set to \'Pending\'.'),
729 'categ_ids': fields.many2many('project.category', string='Tags'),
730 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
731 help="A task's kanban state indicates special situations affecting it:\n"
732 " * Normal is the default situation\n"
733 " * Blocked indicates something is preventing the progress of this task\n"
734 " * Ready To Pull indicates the task is ready to be pulled to the next stage",
735 readonly=True, required=False),
736 'create_date': fields.datetime('Create Date', readonly=True,select=True),
737 'date_start': fields.datetime('Starting Date',select=True),
738 'date_end': fields.datetime('Ending Date',select=True),
739 'date_deadline': fields.date('Deadline',select=True),
740 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
741 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
742 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
743 'notes': fields.text('Notes'),
744 '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.'),
745 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
747 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
748 'project.task.work': (_get_task, ['hours'], 10),
750 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
751 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
753 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
754 'project.task.work': (_get_task, ['hours'], 10),
756 '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",
758 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
759 'project.task.work': (_get_task, ['hours'], 10),
761 '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.",
763 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
764 'project.task.work': (_get_task, ['hours'], 10),
766 'user_id': fields.many2one('res.users', 'Assigned to'),
767 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
768 'partner_id': fields.many2one('res.partner', 'Customer'),
769 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
770 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
771 'company_id': fields.many2one('res.company', 'Company'),
772 'id': fields.integer('ID', readonly=True),
773 'color': fields.integer('Color Index'),
774 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
777 'stage_id': _get_default_stage_id,
778 'project_id': _get_default_project_id,
780 'kanban_state': 'normal',
785 'user_id': lambda obj, cr, uid, context: uid,
786 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
788 _order = "priority, sequence, date_start, name, id"
790 def set_priority(self, cr, uid, ids, priority, *args):
793 return self.write(cr, uid, ids, {'priority' : priority})
795 def set_high_priority(self, cr, uid, ids, *args):
796 """Set task priority to high
798 return self.set_priority(cr, uid, ids, '1')
800 def set_normal_priority(self, cr, uid, ids, *args):
801 """Set task priority to normal
803 return self.set_priority(cr, uid, ids, '2')
805 def _check_recursion(self, cr, uid, ids, context=None):
807 visited_branch = set()
809 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
815 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
816 if id in visited_branch: #Cycle
819 if id in visited_node: #Already tested don't work one more time for nothing
822 visited_branch.add(id)
825 #visit child using DFS
826 task = self.browse(cr, uid, id, context=context)
827 for child in task.child_ids:
828 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
832 visited_branch.remove(id)
835 def _check_dates(self, cr, uid, ids, context=None):
838 obj_task = self.browse(cr, uid, ids[0], context=context)
839 start = obj_task.date_start or False
840 end = obj_task.date_end or False
847 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
848 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
851 # Override view according to the company definition
852 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
853 users_obj = self.pool.get('res.users')
854 if context is None: context = {}
855 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
856 # this should be safe (no context passed to avoid side-effects)
857 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
858 tm = obj_tm and obj_tm.name or 'Hours'
860 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
862 if tm in ['Hours','Hour']:
865 eview = etree.fromstring(res['arch'])
867 def _check_rec(eview):
868 if eview.attrib.get('widget','') == 'float_time':
869 eview.set('widget','float')
876 res['arch'] = etree.tostring(eview)
878 for f in res['fields']:
879 if 'Hours' in res['fields'][f]['string']:
880 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
883 # ----------------------------------------
885 # ----------------------------------------
887 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
888 """ Override of the base.stage method
889 Parameter of the stage search taken from the lead:
890 - section_id: if set, stages must belong to this section or
891 be a default stage; if not set, stages must be default
894 if isinstance(cases, (int, long)):
895 cases = self.browse(cr, uid, cases, context=context)
896 # collect all section_ids
899 section_ids.append(section_id)
902 section_ids.append(task.project_id.id)
903 # OR all section_ids and OR with case_default
906 search_domain += [('|')] * len(section_ids)
907 for section_id in section_ids:
908 search_domain.append(('project_ids', '=', section_id))
909 search_domain.append(('case_default', '=', True))
910 # AND with the domain in parameter
911 search_domain += list(domain)
912 # perform search, return the first found
913 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
918 def _check_child_task(self, cr, uid, ids, context=None):
921 tasks = self.browse(cr, uid, ids, context=context)
924 for child in task.child_ids:
925 if child.state in ['draft', 'open', 'pending']:
926 raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
929 def action_close(self, cr, uid, ids, context=None):
930 """ This action closes the task
932 task_id = len(ids) and ids[0] or False
933 self._check_child_task(cr, uid, ids, context=context)
934 if not task_id: return False
935 return self.do_close(cr, uid, [task_id], context=context)
937 def do_close(self, cr, uid, ids, context=None):
938 """ Compatibility when changing to case_close. """
939 return self.case_close(cr, uid, ids, context=context)
941 def case_close(self, cr, uid, ids, context=None):
943 if not isinstance(ids, list): ids = [ids]
944 for task in self.browse(cr, uid, ids, context=context):
946 project = task.project_id
947 for parent_id in task.parent_ids:
948 if parent_id.state in ('pending','draft'):
950 for child in parent_id.child_ids:
951 if child.id != task.id and child.state not in ('done','cancelled'):
954 self.do_reopen(cr, uid, [parent_id.id], context=context)
956 vals['remaining_hours'] = 0.0
957 if not task.date_end:
958 vals['date_end'] = fields.datetime.now()
959 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
960 self.case_close_send_note(cr, uid, [task.id], context=context)
963 def do_reopen(self, cr, uid, ids, context=None):
964 for task in self.browse(cr, uid, ids, context=context):
965 project = task.project_id
966 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
967 self.case_open_send_note(cr, uid, [task.id], context)
970 def do_cancel(self, cr, uid, ids, context=None):
971 """ Compatibility when changing to case_cancel. """
972 return self.case_cancel(cr, uid, ids, context=context)
974 def case_cancel(self, cr, uid, ids, context=None):
975 tasks = self.browse(cr, uid, ids, context=context)
976 self._check_child_task(cr, uid, ids, context=context)
978 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
979 self.case_cancel_send_note(cr, uid, [task.id], context=context)
982 def do_open(self, cr, uid, ids, context=None):
983 """ Compatibility when changing to case_open. """
984 return self.case_open(cr, uid, ids, context=context)
986 def case_open(self, cr, uid, ids, context=None):
987 if not isinstance(ids,list): ids = [ids]
988 self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
989 self.case_open_send_note(cr, uid, ids, context)
992 def do_draft(self, cr, uid, ids, context=None):
993 """ Compatibility when changing to case_draft. """
994 return self.case_draft(cr, uid, ids, context=context)
996 def case_draft(self, cr, uid, ids, context=None):
997 self.case_set(cr, uid, ids, 'draft', {}, context=context)
998 self.case_draft_send_note(cr, uid, ids, context=context)
1001 def do_pending(self, cr, uid, ids, context=None):
1002 """ Compatibility when changing to case_pending. """
1003 return self.case_pending(cr, uid, ids, context=context)
1005 def case_pending(self, cr, uid, ids, context=None):
1006 self.case_set(cr, uid, ids, 'pending', {}, context=context)
1007 return self.case_pending_send_note(cr, uid, ids, context=context)
1009 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1010 attachment = self.pool.get('ir.attachment')
1011 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1012 new_attachment_ids = []
1013 for attachment_id in attachment_ids:
1014 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1015 return new_attachment_ids
1017 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1019 Delegate Task to another users.
1021 if delegate_data is None:
1023 assert delegate_data['user_id'], _("Delegated User should be specified")
1024 delegated_tasks = {}
1025 for task in self.browse(cr, uid, ids, context=context):
1026 delegated_task_id = self.copy(cr, uid, task.id, {
1027 'name': delegate_data['name'],
1028 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1029 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1030 'planned_hours': delegate_data['planned_hours'] or 0.0,
1031 'parent_ids': [(6, 0, [task.id])],
1033 'description': delegate_data['new_task_description'] or '',
1037 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1038 newname = delegate_data['prefix'] or ''
1040 'remaining_hours': delegate_data['planned_hours_me'],
1041 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1044 if delegate_data['state'] == 'pending':
1045 self.do_pending(cr, uid, [task.id], context=context)
1046 elif delegate_data['state'] == 'done':
1047 self.do_close(cr, uid, [task.id], context=context)
1048 self.do_delegation_send_note(cr, uid, [task.id], context)
1049 delegated_tasks[task.id] = delegated_task_id
1050 return delegated_tasks
1052 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1053 for task in self.browse(cr, uid, ids, context=context):
1054 if (task.state=='draft') or (task.planned_hours==0.0):
1055 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1056 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1059 def set_remaining_time_1(self, cr, uid, ids, context=None):
1060 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1062 def set_remaining_time_2(self, cr, uid, ids, context=None):
1063 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1065 def set_remaining_time_5(self, cr, uid, ids, context=None):
1066 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1068 def set_remaining_time_10(self, cr, uid, ids, context=None):
1069 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1071 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1072 self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1075 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1076 self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1079 def set_kanban_state_done(self, cr, uid, ids, context=None):
1080 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1083 def _store_history(self, cr, uid, ids, context=None):
1084 for task in self.browse(cr, uid, ids, context=context):
1085 self.pool.get('project.task.history').create(cr, uid, {
1087 'remaining_hours': task.remaining_hours,
1088 'planned_hours': task.planned_hours,
1089 'kanban_state': task.kanban_state,
1090 'type_id': task.stage_id.id,
1091 'state': task.state,
1092 'user_id': task.user_id.id
1097 def create(self, cr, uid, vals, context=None):
1098 task_id = super(task, self).create(cr, uid, vals, context=context)
1099 task_record = self.browse(cr, uid, task_id, context=context)
1100 project_obj = self.pool.get("project.project")
1101 subtype_obj = self.pool.get('mail.message.subtype')
1102 if task_record.project_id:
1103 poject_id = self.browse(cr, uid, task_id, context=context).project_id.id
1104 pro_subtype = project_obj.browse(cr, uid, poject_id, context=context).message_subtype_data
1105 for key in pro_subtype:
1106 subtype_ids = subtype_obj.search(cr, uid, [('res_model', '=', self._name), ('name', '=', key)], context=context)
1108 for subtype_id in subtype_ids:
1109 subtype_obj.write(cr,uid, subtype_id, {'default': pro_subtype[key]['default']},context=context)
1110 project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1111 self.message_subscribe(cr, uid, [task_id], project_follower_ids,
1113 self._store_history(cr, uid, [task_id], context=context)
1114 self.create_send_note(cr, uid, [task_id], context=context)
1117 # Overridden to reset the kanban_state to normal whenever
1118 # the stage (stage_id) of the task changes.
1119 def write(self, cr, uid, ids, vals, context=None):
1120 if isinstance(ids, (int, long)):
1122 project_obj = self.pool.get("project.project")
1123 subtype_obj = self.pool.get('mail.message.subtype')
1124 if vals.get('project_id'):
1125 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1126 vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1127 pro_subtype = project_obj.browse(cr, uid, project_id.id, context=context).message_subtype_data
1128 for key in pro_subtype:
1129 subtype_ids = subtype_obj.search(cr, uid, [('res_model', '=', self._name), ('name', '=', key)], context=context)
1131 for subtype_id in subtype_ids:
1132 subtype_obj.write(cr,uid, subtype_id, {'default': pro_subtype[key]['default']},context=context)
1133 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1134 new_stage = vals.get('stage_id')
1135 vals_reset_kstate = dict(vals, kanban_state='normal')
1136 for t in self.browse(cr, uid, ids, context=context):
1137 #TO FIX:Kanban view doesn't raise warning
1138 #stages = [stage.id for stage in t.project_id.type_ids]
1139 #if new_stage not in stages:
1140 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1141 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1142 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1143 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1146 result = super(task,self).write(cr, uid, ids, vals, context=context)
1147 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1148 self._store_history(cr, uid, ids, context=context)
1151 def unlink(self, cr, uid, ids, context=None):
1154 self._check_child_task(cr, uid, ids, context=context)
1155 res = super(task, self).unlink(cr, uid, ids, context)
1158 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1159 context = context or {}
1163 if task.state in ('done','cancelled'):
1168 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1170 for t2 in task.parent_ids:
1171 start.append("up.Task_%s.end" % (t2.id,))
1175 ''' % (ident,','.join(start))
1180 ''' % (ident, 'User_'+str(task.user_id.id))
1185 # ---------------------------------------------------
1187 # ---------------------------------------------------
1189 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1190 """ Override to updates the document according to the email. """
1191 if custom_values is None: custom_values = {}
1192 custom_values.update({
1194 'planned_hours': 0.0,
1195 'subject': msg.get('subject'),
1197 return super(project_tasks,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1199 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1200 """ Override to update the task according to the email. """
1201 if update_vals is None: update_vals = {}
1204 'cost':'planned_hours',
1206 for line in msg['body'].split('\n'):
1208 res = tools.misc.command_re.match(line)
1210 match = res.group(1).lower()
1211 field = maps.get(match)
1214 update_vals[field] = float(res.group(2).lower())
1215 except (ValueError, TypeError):
1217 elif match.lower() == 'state' \
1218 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1219 act = 'do_%s' % res.group(2).lower()
1221 getattr(self,act)(cr, uid, ids, context=context)
1222 return super(project_tasks,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1224 # ---------------------------------------------------
1225 # OpenChatter methods and notifications
1226 # ---------------------------------------------------
1228 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1229 """ Override of default prefix for notifications. """
1232 def get_needaction_user_ids(self, cr, uid, ids, context=None):
1233 """ Returns the user_ids that have to perform an action.
1234 Add to the previous results given by super the document responsible
1236 :return: dict { record_id: [user_ids], }
1238 result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1239 for obj in self.browse(cr, uid, ids, context=context):
1240 if obj.state == 'draft' and obj.user_id:
1241 result[obj.id].append(obj.user_id.id)
1244 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1245 """ Add 'user_id' and 'manager_id' to the monitored fields """
1246 res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1247 return res + ['user_id', 'manager_id']
1249 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1250 """ Override of the (void) default notification method. """
1251 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1252 return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1255 def create_send_note(self, cr, uid, ids, context=None):
1256 return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1258 def case_draft_send_note(self, cr, uid, ids, context=None):
1259 return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1261 def do_delegation_send_note(self, cr, uid, ids, context=None):
1262 for task in self.browse(cr, uid, ids, context=context):
1263 msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1264 self.message_post(cr, uid, [task.id], body=msg, context=context)
1268 class project_work(osv.osv):
1269 _name = "project.task.work"
1270 _description = "Project Task Work"
1272 'name': fields.char('Work summary', size=128),
1273 'date': fields.datetime('Date', select="1"),
1274 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1275 'hours': fields.float('Time Spent'),
1276 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1277 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1281 'user_id': lambda obj, cr, uid, context: uid,
1282 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1285 _order = "date desc"
1286 def create(self, cr, uid, vals, *args, **kwargs):
1287 if 'hours' in vals and (not vals['hours']):
1288 vals['hours'] = 0.00
1289 if 'task_id' in vals:
1290 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1291 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1293 def write(self, cr, uid, ids, vals, context=None):
1294 if 'hours' in vals and (not vals['hours']):
1295 vals['hours'] = 0.00
1297 for work in self.browse(cr, uid, ids, context=context):
1298 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))
1299 return super(project_work,self).write(cr, uid, ids, vals, context)
1301 def unlink(self, cr, uid, ids, *args, **kwargs):
1302 for work in self.browse(cr, uid, ids):
1303 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1304 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1307 class account_analytic_account(osv.osv):
1308 _inherit = 'account.analytic.account'
1309 _description = 'Analytic Account'
1311 '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"),
1312 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1315 def on_change_template(self, cr, uid, ids, template_id, context=None):
1316 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1317 if template_id and 'value' in res:
1318 template = self.browse(cr, uid, template_id, context=context)
1319 res['value']['use_tasks'] = template.use_tasks
1322 def _trigger_project_creation(self, cr, uid, vals, context=None):
1324 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.
1326 if context is None: context = {}
1327 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1329 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1331 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.
1333 project_pool = self.pool.get('project.project')
1334 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1335 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1337 'name': vals.get('name'),
1338 'analytic_account_id': analytic_account_id,
1340 return project_pool.create(cr, uid, project_values, context=context)
1343 def create(self, cr, uid, vals, context=None):
1346 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1347 vals['child_ids'] = []
1348 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1349 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1350 return analytic_account_id
1352 def write(self, cr, uid, ids, vals, context=None):
1353 name = vals.get('name')
1354 for account in self.browse(cr, uid, ids, context=context):
1356 vals['name'] = account.name
1357 self.project_create(cr, uid, account.id, vals, context=context)
1358 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1360 def unlink(self, cr, uid, ids, *args, **kwargs):
1361 project_obj = self.pool.get('project.project')
1362 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1364 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1365 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1367 class project_project(osv.osv):
1368 _inherit = 'project.project'
1375 # Tasks History, used for cumulative flow charts (Lean/Agile)
1378 class project_task_history(osv.osv):
1379 _name = 'project.task.history'
1380 _description = 'History of Tasks'
1381 _rec_name = 'task_id'
1383 def _get_date(self, cr, uid, ids, name, arg, context=None):
1385 for history in self.browse(cr, uid, ids, context=context):
1386 if history.state in ('done','cancelled'):
1387 result[history.id] = history.date
1389 cr.execute('''select
1392 project_task_history
1396 order by id limit 1''', (history.task_id.id, history.id))
1398 result[history.id] = res and res[0] or False
1401 def _get_related_date(self, cr, uid, ids, context=None):
1403 for history in self.browse(cr, uid, ids, context=context):
1404 cr.execute('''select
1407 project_task_history
1411 order by id desc limit 1''', (history.task_id.id, history.id))
1414 result.append(res[0])
1418 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1419 'type_id': fields.many2one('project.task.type', 'Stage'),
1420 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1421 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1422 'date': fields.date('Date', select=True),
1423 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1424 'project.task.history': (_get_related_date, None, 20)
1426 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1427 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1428 'user_id': fields.many2one('res.users', 'Responsible'),
1431 'date': fields.date.context_today,
1435 class project_task_history_cumulative(osv.osv):
1436 _name = 'project.task.history.cumulative'
1437 _table = 'project_task_history_cumulative'
1438 _inherit = 'project.task.history'
1441 'end_date': fields.date('End Date'),
1442 'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1445 cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1447 history.date::varchar||'-'||history.history_id::varchar as id,
1448 history.date as end_date,
1453 date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1454 task_id, type_id, user_id, kanban_state, state,
1455 greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1457 project_task_history
1463 class project_category(osv.osv):
1464 """ Category of project's task (or issue) """
1465 _name = "project.category"
1466 _description = "Category of project's task, issue, ..."
1468 'name': fields.char('Name', size=64, required=True, translate=True),