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 datetime import datetime, date
23 from lxml import etree
26 from openerp import SUPERUSER_ID
27 from openerp import tools
28 from openerp.osv import fields, osv
29 from openerp.tools.translate import _
31 from openerp.addons.base_status.base_stage import base_stage
32 from openerp.addons.resource.faces import task as Task
34 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
36 class project_task_type(osv.osv):
37 _name = 'project.task.type'
38 _description = 'Task Stage'
41 'name': fields.char('Stage Name', required=True, size=64, translate=True),
42 'description': fields.text('Description'),
43 'sequence': fields.integer('Sequence'),
44 'case_default': fields.boolean('Default for New Projects',
45 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."),
46 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
47 'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
48 help="The status of your document is automatically changed regarding the selected stage. " \
49 "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
50 'fold': fields.boolean('Folded by Default',
51 help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
53 def _get_default_project_id(self, cr, uid, ctx={}):
54 proj = ctx.get('default_project_id', False)
62 'case_default': False,
63 'project_ids': _get_default_project_id
68 """Keep first word(s) of name to make it small enough
70 if not name: return name
71 # keep 7 chars + end of the last word
72 keep_words = name[:7].strip().split()
73 return ' '.join(name.split()[:len(keep_words)])
75 class project(osv.osv):
76 _name = "project.project"
77 _description = "Project"
78 _inherits = {'account.analytic.account': "analytic_account_id",
79 "mail.alias": "alias_id"}
80 _inherit = ['mail.thread', 'ir.needaction_mixin']
82 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
84 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
85 if context and context.get('user_preference'):
86 cr.execute("""SELECT project.id FROM project_project project
87 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
88 LEFT JOIN project_user_rel rel ON rel.project_id = project.id
89 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
90 return [(r[0]) for r in cr.fetchall()]
91 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
92 context=context, count=count)
94 def _complete_name(self, cr, uid, ids, name, args, context=None):
96 for m in self.browse(cr, uid, ids, context=context):
97 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
100 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
101 partner_obj = self.pool.get('res.partner')
105 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
106 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
107 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
108 val['pricelist_id'] = pricelist_id
109 return {'value': val}
111 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
112 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
113 project_ids = [task.project_id.id for task in tasks if task.project_id]
114 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
116 def _get_project_and_parents(self, cr, uid, ids, context=None):
117 """ return the project ids and all their parent projects """
121 SELECT DISTINCT parent.id
122 FROM project_project project, project_project parent, account_analytic_account account
123 WHERE project.analytic_account_id = account.id
124 AND parent.analytic_account_id = account.parent_id
127 ids = [t[0] for t in cr.fetchall()]
131 def _get_project_and_children(self, cr, uid, ids, context=None):
132 """ retrieve all children projects of project ids;
133 return a dictionary mapping each project to its parent project (or None)
135 res = dict.fromkeys(ids, None)
138 SELECT project.id, parent.id
139 FROM project_project project, project_project parent, account_analytic_account account
140 WHERE project.analytic_account_id = account.id
141 AND parent.analytic_account_id = account.parent_id
144 dic = dict(cr.fetchall())
149 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
150 child_parent = self._get_project_and_children(cr, uid, ids, context)
151 # compute planned_hours, total_hours, effective_hours specific to each project
153 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
154 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
155 FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
157 """, (tuple(child_parent.keys()),))
158 # aggregate results into res
159 res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
160 for id, planned, total, effective in cr.fetchall():
161 # add the values specific to id to all parent projects of id in the result
164 res[id]['planned_hours'] += planned
165 res[id]['total_hours'] += total
166 res[id]['effective_hours'] += effective
167 id = child_parent[id]
168 # compute progress rates
170 if res[id]['total_hours']:
171 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
173 res[id]['progress_rate'] = 0.0
176 def unlink(self, cr, uid, ids, context=None):
178 mail_alias = self.pool.get('mail.alias')
179 for proj in self.browse(cr, uid, ids, context=context):
181 raise osv.except_osv(_('Invalid Action!'),
182 _('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.'))
184 alias_ids.append(proj.alias_id.id)
185 res = super(project, self).unlink(cr, uid, ids, context=context)
186 mail_alias.unlink(cr, uid, alias_ids, context=context)
189 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
191 attachment = self.pool.get('ir.attachment')
192 task = self.pool.get('project.task')
194 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
195 task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
196 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
197 res[id] = (project_attachments or 0) + (task_attachments or 0)
200 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
203 res = dict.fromkeys(ids, 0)
205 ctx['active_test'] = False
206 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)], context=ctx)
207 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
208 res[task.project_id.id] += 1
211 def _get_alias_models(self, cr, uid, context=None):
212 """Overriden in project_issue to offer more options"""
213 return [('project.task', "Tasks")]
215 def _get_visibility_selection(self, cr, uid, context=None):
216 """ Overriden in portal_project to offer more options """
217 return [('public', 'Public'),
218 ('employees', 'Employees Only'),
219 ('followers', 'Followers Only')]
221 def attachment_tree_view(self, cr, uid, ids, context):
222 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
225 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
226 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)
228 res_id = ids and ids[0] or False
230 'name': _('Attachments'),
232 'res_model': 'ir.attachment',
233 'type': 'ir.actions.act_window',
235 'view_mode': 'tree,form',
238 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
240 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
241 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
242 _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs)
245 'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
246 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
247 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
248 '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),
249 'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
250 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
251 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)]}),
252 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
253 '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.",
255 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
256 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
258 '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.",
260 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
261 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
263 '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.",
265 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
266 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
268 '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.",
270 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
271 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
273 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
274 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
275 'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
276 'color': fields.integer('Color Index'),
277 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
278 help="Internal email associated with this project. Incoming emails are automatically synchronized"
279 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
280 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
281 help="The kind of document created when an email is received on this project's email alias"),
282 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy / Visibility', required=True,
283 help="Holds visibility of the tasks or issues that belong to the current project:\n"
284 "- Public: everybody sees everything; if portal is activated, portal users\n"
285 " see all tasks or issues; if anonymous portal is activated, visitors\n"
286 " see all tasks or issues\n"
287 "- Portal (only available if Portal is installed): employees see everything;\n"
288 " if portal is activated, portal users see the tasks or issues followed by\n"
289 " them or by someone of their company\n"
290 "- Employees Only: employees see all tasks or issues\n"
291 "- Followers Only: employees see only the followed tasks or issues; if portal\n"
292 " is activated, portal users see the followed tasks or issues."),
293 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
294 'doc_count':fields.function(_get_attached_docs, string="Number of documents attached", type='int')
297 def _get_type_common(self, cr, uid, context):
298 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
301 _order = "sequence, id"
308 'type_ids': _get_type_common,
309 'alias_model': 'project.task',
310 'privacy_visibility': 'employees',
313 # TODO: Why not using a SQL contraints ?
314 def _check_dates(self, cr, uid, ids, context=None):
315 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
316 if leave['date_start'] and leave['date']:
317 if leave['date_start'] > leave['date']:
322 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
325 def set_template(self, cr, uid, ids, context=None):
326 res = self.setActive(cr, uid, ids, value=False, context=context)
329 def set_done(self, cr, uid, ids, context=None):
330 task_obj = self.pool.get('project.task')
331 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
332 task_obj.case_close(cr, uid, task_ids, context=context)
333 return self.write(cr, uid, ids, {'state':'close'}, context=context)
335 def set_cancel(self, cr, uid, ids, context=None):
336 task_obj = self.pool.get('project.task')
337 task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
338 task_obj.case_cancel(cr, uid, task_ids, context=context)
339 return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
341 def set_pending(self, cr, uid, ids, context=None):
342 return self.write(cr, uid, ids, {'state':'pending'}, context=context)
344 def set_open(self, cr, uid, ids, context=None):
345 return self.write(cr, uid, ids, {'state':'open'}, context=context)
347 def reset_project(self, cr, uid, ids, context=None):
348 return self.setActive(cr, uid, ids, value=True, context=context)
350 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
351 """ copy and map tasks from old to new project """
355 task_obj = self.pool.get('project.task')
356 proj = self.browse(cr, uid, old_project_id, context=context)
357 for task in proj.tasks:
358 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
359 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
360 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
363 def copy(self, cr, uid, id, default=None, context=None):
369 context['active_test'] = False
370 default['state'] = 'open'
371 default['line_ids'] = []
372 default['tasks'] = []
373 default.pop('alias_name', None)
374 default.pop('alias_id', None)
375 proj = self.browse(cr, uid, id, context=context)
376 if not default.get('name', False):
377 default.update(name=_("%s (copy)") % (proj.name))
378 res = super(project, self).copy(cr, uid, id, default, context)
379 self.map_tasks(cr,uid,id,res,context)
382 def duplicate_template(self, cr, uid, ids, context=None):
385 data_obj = self.pool.get('ir.model.data')
387 for proj in self.browse(cr, uid, ids, context=context):
388 parent_id = context.get('parent_id', False)
389 context.update({'analytic_project_copy': True})
390 new_date_start = time.strftime('%Y-%m-%d')
392 if proj.date_start and proj.date:
393 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
394 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
395 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
396 context.update({'copy':True})
397 new_id = self.copy(cr, uid, proj.id, default = {
398 'name':_("%s (copy)") % (proj.name),
400 'date_start':new_date_start,
402 'parent_id':parent_id}, context=context)
403 result.append(new_id)
405 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
406 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
408 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
410 if result and len(result):
412 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
413 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
414 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
415 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
416 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
417 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
419 'name': _('Projects'),
421 'view_mode': 'form,tree',
422 'res_model': 'project.project',
425 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
426 'type': 'ir.actions.act_window',
427 'search_view_id': search_view['res_id'],
431 # set active value for a project, its sub projects and its tasks
432 def setActive(self, cr, uid, ids, value=True, context=None):
433 task_obj = self.pool.get('project.task')
434 for proj in self.browse(cr, uid, ids, context=None):
435 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
436 cr.execute('select id from project_task where project_id=%s', (proj.id,))
437 tasks_id = [x[0] for x in cr.fetchall()]
439 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
440 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
442 self.setActive(cr, uid, child_ids, value, context=None)
445 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
446 context = context or {}
447 if type(ids) in (long, int,):
449 projects = self.browse(cr, uid, ids, context=context)
451 for project in projects:
452 if (not project.members) and force_members:
453 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
455 resource_pool = self.pool.get('resource.resource')
457 result = "from openerp.addons.resource.faces import *\n"
458 result += "import datetime\n"
459 for project in self.browse(cr, uid, ids, context=context):
460 u_ids = [i.id for i in project.members]
461 if project.user_id and (project.user_id.id not in u_ids):
462 u_ids.append(project.user_id.id)
463 for task in project.tasks:
464 if task.state in ('done','cancelled'):
466 if task.user_id and (task.user_id.id not in u_ids):
467 u_ids.append(task.user_id.id)
468 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
469 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
470 for key, vals in resource_objs.items():
472 class User_%s(Resource):
474 ''' % (key, vals.get('efficiency', False))
481 def _schedule_project(self, cr, uid, project, context=None):
482 resource_pool = self.pool.get('resource.resource')
483 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
484 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
485 # TODO: check if we need working_..., default values are ok.
486 puids = [x.id for x in project.members]
488 puids.append(project.user_id.id)
496 project.date_start or time.strftime('%Y-%m-%d'), working_days,
497 '|'.join(['User_'+str(x) for x in puids])
499 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
506 #TODO: DO Resource allocation and compute availability
507 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
513 def schedule_tasks(self, cr, uid, ids, context=None):
514 context = context or {}
515 if type(ids) in (long, int,):
517 projects = self.browse(cr, uid, ids, context=context)
518 result = self._schedule_header(cr, uid, ids, False, context=context)
519 for project in projects:
520 result += self._schedule_project(cr, uid, project, context=context)
521 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
524 exec result in local_dict
525 projects_gantt = Task.BalancedProject(local_dict['Project'])
527 for project in projects:
528 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
529 for task in project.tasks:
530 if task.state in ('done','cancelled'):
533 p = getattr(project_gantt, 'Task_%d' % (task.id,))
535 self.pool.get('project.task').write(cr, uid, [task.id], {
536 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
537 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
539 if (not task.user_id) and (p.booked_resource):
540 self.pool.get('project.task').write(cr, uid, [task.id], {
541 'user_id': int(p.booked_resource[0].name[5:]),
545 # ------------------------------------------------
546 # OpenChatter methods and notifications
547 # ------------------------------------------------
549 def create(self, cr, uid, vals, context=None):
550 if context is None: context = {}
551 # Prevent double project creation when 'use_tasks' is checked!
552 context = dict(context, project_creation_in_progress=True)
553 mail_alias = self.pool.get('mail.alias')
554 if not vals.get('alias_id') and vals.get('name', False):
555 alias_name = vals.pop('alias_name', None) # prevent errors during copy()
556 alias_id = mail_alias.create_unique_alias(cr, uid,
557 # Using '+' allows using subaddressing for those who don't
558 # have a catchall domain setup.
559 {'alias_name': alias_name or "project+"+short_name(vals['name'])},
560 model_name=vals.get('alias_model', 'project.task'),
562 vals['alias_id'] = alias_id
563 if vals.get('type', False) not in ('template','contract'):
564 vals['type'] = 'contract'
565 project_id = super(project, self).create(cr, uid, vals, context)
566 mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
569 def write(self, cr, uid, ids, vals, context=None):
570 # if alias_model has been changed, update alias_model_id accordingly
571 if vals.get('alias_model'):
572 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
573 vals.update(alias_model_id=model_ids[0])
574 return super(project, self).write(cr, uid, ids, vals, context=context)
576 class task(base_stage, osv.osv):
577 _name = "project.task"
578 _description = "Task"
579 _date_name = "date_start"
580 _inherit = ['mail.thread', 'ir.needaction_mixin']
584 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
585 'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
586 'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
589 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'done', 'open'],
591 'kanban_state': { # kanban state: tracked, but only block subtype
592 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
596 def _get_default_project_id(self, cr, uid, context=None):
597 """ Gives default section by checking if present in the context """
598 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
600 def _get_default_stage_id(self, cr, uid, context=None):
601 """ Gives default stage_id """
602 project_id = self._get_default_project_id(cr, uid, context=context)
603 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
605 def _resolve_project_id_from_context(self, cr, uid, context=None):
606 """ Returns ID of project based on the value of 'default_project_id'
607 context key, or None if it cannot be resolved to a single
610 if context is None: context = {}
611 if type(context.get('default_project_id')) in (int, long):
612 return context['default_project_id']
613 if isinstance(context.get('default_project_id'), basestring):
614 project_name = context['default_project_id']
615 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
616 if len(project_ids) == 1:
617 return project_ids[0][0]
620 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
621 stage_obj = self.pool.get('project.task.type')
622 order = stage_obj._order
623 access_rights_uid = access_rights_uid or uid
624 if read_group_order == 'stage_id desc':
625 order = '%s desc' % order
627 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
629 search_domain += ['|', ('project_ids', '=', project_id)]
630 search_domain += [('id', 'in', ids)]
631 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
632 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
633 # restore order of the search
634 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
637 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
638 fold[stage.id] = stage.fold or False
641 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
642 res_users = self.pool.get('res.users')
643 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
644 access_rights_uid = access_rights_uid or uid
646 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
647 order = res_users._order
648 # lame way to allow reverting search, should just work in the trivial case
649 if read_group_order == 'user_id desc':
650 order = '%s desc' % order
651 # de-duplicate and apply search order
652 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
653 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
654 # restore order of the search
655 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
659 'stage_id': _read_group_stage_ids,
660 'user_id': _read_group_user_id,
663 def _str_get(self, task, level=0, border='***', context=None):
664 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'+ \
665 border[0]+' '+(task.name or '')+'\n'+ \
666 (task.description or '')+'\n\n'
668 # Compute: effective_hours, total_hours, progress
669 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
671 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
672 hours = dict(cr.fetchall())
673 for task in self.browse(cr, uid, ids, context=context):
674 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)}
675 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
676 res[task.id]['progress'] = 0.0
677 if (task.remaining_hours + hours.get(task.id, 0.0)):
678 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
679 if task.state in ('done','cancelled'):
680 res[task.id]['progress'] = 100.0
683 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
684 if remaining and not planned:
685 return {'value':{'planned_hours': remaining}}
688 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
689 return {'value':{'remaining_hours': planned - effective}}
691 def onchange_project(self, cr, uid, id, project_id):
694 data = self.pool.get('project.project').browse(cr, uid, [project_id])
695 partner_id=data and data[0].partner_id
697 return {'value':{'partner_id':partner_id.id}}
700 def duplicate_task(self, cr, uid, map_ids, context=None):
701 for new in map_ids.values():
702 task = self.browse(cr, uid, new, context)
703 child_ids = [ ch.id for ch in task.child_ids]
705 for child in task.child_ids:
706 if child.id in map_ids.keys():
707 child_ids.remove(child.id)
708 child_ids.append(map_ids[child.id])
710 parent_ids = [ ch.id for ch in task.parent_ids]
712 for parent in task.parent_ids:
713 if parent.id in map_ids.keys():
714 parent_ids.remove(parent.id)
715 parent_ids.append(map_ids[parent.id])
716 #FIXME why there is already the copy and the old one
717 self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
719 def copy_data(self, cr, uid, id, default=None, context=None):
722 default = default or {}
723 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
724 if not default.get('remaining_hours', False):
725 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
726 default['active'] = True
727 if not default.get('name', False):
728 default['name'] = self.browse(cr, uid, id, context=context).name or ''
729 if not context.get('copy',False):
730 new_name = _("%s (copy)") % (default.get('name', ''))
731 default.update({'name':new_name})
732 return super(task, self).copy_data(cr, uid, id, default, context)
734 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
736 for task in self.browse(cr, uid, ids, context=context):
739 if task.project_id.active == False or task.project_id.state == 'template':
743 def _get_task(self, cr, uid, ids, context=None):
745 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
746 if work.task_id: result[work.task_id.id] = True
750 '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."),
751 'name': fields.char('Task Summary', size=128, required=True, select=True),
752 'description': fields.text('Description'),
753 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
754 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
755 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange',
756 domain="[('project_ids', '=', project_id)]"),
757 'state': fields.related('stage_id', 'state', type="selection", store=True,
758 selection=_TASK_STATE, string="Status", readonly=True,
759 help='The status is set to \'Draft\', when a case is created.\
760 If the case is in progress the status is set to \'Open\'.\
761 When the case is over, the status is set to \'Done\'.\
762 If the case needs to be reviewed then the status is \
763 set to \'Pending\'.'),
764 'categ_ids': fields.many2many('project.category', string='Tags'),
765 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
766 track_visibility='onchange',
767 help="A task's kanban state indicates special situations affecting it:\n"
768 " * Normal is the default situation\n"
769 " * Blocked indicates something is preventing the progress of this task\n"
770 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
771 readonly=True, required=False),
772 'create_date': fields.datetime('Create Date', readonly=True,select=True),
773 'date_start': fields.datetime('Starting Date',select=True),
774 'date_end': fields.datetime('Ending Date',select=True),
775 'date_deadline': fields.date('Deadline',select=True),
776 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", track_visibility='onchange'),
777 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
778 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
779 'notes': fields.text('Notes'),
780 '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.'),
781 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
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 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
787 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
789 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
790 'project.task.work': (_get_task, ['hours'], 10),
792 '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",
794 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
795 'project.task.work': (_get_task, ['hours'], 10),
797 '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.",
799 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
800 'project.task.work': (_get_task, ['hours'], 10),
802 'user_id': fields.many2one('res.users', 'Assigned to', track_visibility='onchange'),
803 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
804 'partner_id': fields.many2one('res.partner', 'Customer'),
805 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
806 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
807 'company_id': fields.many2one('res.company', 'Company'),
808 'id': fields.integer('ID', readonly=True),
809 'color': fields.integer('Color Index'),
810 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
813 'stage_id': _get_default_stage_id,
814 'project_id': _get_default_project_id,
815 'kanban_state': 'normal',
820 'user_id': lambda obj, cr, uid, context: uid,
821 'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
823 _order = "priority, sequence, date_start, name, id"
825 def set_high_priority(self, cr, uid, ids, *args):
826 """Set task priority to high
828 return self.write(cr, uid, ids, {'priority' : '0'})
830 def set_normal_priority(self, cr, uid, ids, *args):
831 """Set task priority to normal
833 return self.write(cr, uid, ids, {'priority' : '2'})
835 def _check_recursion(self, cr, uid, ids, context=None):
837 visited_branch = set()
839 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
845 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
846 if id in visited_branch: #Cycle
849 if id in visited_node: #Already tested don't work one more time for nothing
852 visited_branch.add(id)
855 #visit child using DFS
856 task = self.browse(cr, uid, id, context=context)
857 for child in task.child_ids:
858 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
862 visited_branch.remove(id)
865 def _check_dates(self, cr, uid, ids, context=None):
868 obj_task = self.browse(cr, uid, ids[0], context=context)
869 start = obj_task.date_start or False
870 end = obj_task.date_end or False
877 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
878 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
881 # Override view according to the company definition
882 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
883 users_obj = self.pool.get('res.users')
884 if context is None: context = {}
885 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
886 # this should be safe (no context passed to avoid side-effects)
887 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
888 tm = obj_tm and obj_tm.name or 'Hours'
890 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
892 if tm in ['Hours','Hour']:
895 eview = etree.fromstring(res['arch'])
897 def _check_rec(eview):
898 if eview.attrib.get('widget','') == 'float_time':
899 eview.set('widget','float')
906 res['arch'] = etree.tostring(eview)
908 for f in res['fields']:
909 if 'Hours' in res['fields'][f]['string']:
910 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
913 def get_empty_list_help(self, cr, uid, help, context=None):
914 context['empty_list_help_id'] = context.get('default_project_id')
915 context['empty_list_help_model'] = 'project.project'
916 context['empty_list_help_document_name'] = _("tasks")
917 return super(task, self).get_empty_list_help(cr, uid, help, context=context)
919 # ----------------------------------------
921 # ----------------------------------------
923 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
924 """ Override of the base.stage method
925 Parameter of the stage search taken from the lead:
926 - section_id: if set, stages must belong to this section or
927 be a default stage; if not set, stages must be default
930 if isinstance(cases, (int, long)):
931 cases = self.browse(cr, uid, cases, context=context)
932 # collect all section_ids
935 section_ids.append(section_id)
938 section_ids.append(task.project_id.id)
941 search_domain = [('|')] * (len(section_ids)-1)
942 for section_id in section_ids:
943 search_domain.append(('project_ids', '=', section_id))
944 search_domain += list(domain)
945 # perform search, return the first found
946 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
951 def _check_child_task(self, cr, uid, ids, context=None):
954 tasks = self.browse(cr, uid, ids, context=context)
957 for child in task.child_ids:
958 if child.state in ['draft', 'open', 'pending']:
959 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
962 def action_close(self, cr, uid, ids, context=None):
963 """ This action closes the task
965 task_id = len(ids) and ids[0] or False
966 self._check_child_task(cr, uid, ids, context=context)
967 if not task_id: return False
968 return self.do_close(cr, uid, [task_id], context=context)
970 def do_close(self, cr, uid, ids, context=None):
971 """ Compatibility when changing to case_close. """
972 return self.case_close(cr, uid, ids, context=context)
974 def case_close(self, cr, uid, ids, context=None):
976 if not isinstance(ids, list): ids = [ids]
977 for task in self.browse(cr, uid, ids, context=context):
979 project = task.project_id
980 for parent_id in task.parent_ids:
981 if parent_id.state in ('pending','draft'):
983 for child in parent_id.child_ids:
984 if child.id != task.id and child.state not in ('done','cancelled'):
987 self.do_reopen(cr, uid, [parent_id.id], context=context)
989 vals['remaining_hours'] = 0.0
990 if not task.date_end:
991 vals['date_end'] = fields.datetime.now()
992 self.case_set(cr, uid, [task.id], 'done', vals, context=context)
995 def do_reopen(self, cr, uid, ids, context=None):
996 for task in self.browse(cr, uid, ids, context=context):
997 project = task.project_id
998 self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1001 def do_cancel(self, cr, uid, ids, context=None):
1002 """ Compatibility when changing to case_cancel. """
1003 return self.case_cancel(cr, uid, ids, context=context)
1005 def case_cancel(self, cr, uid, ids, context=None):
1006 tasks = self.browse(cr, uid, ids, context=context)
1007 self._check_child_task(cr, uid, ids, context=context)
1009 self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1012 def do_open(self, cr, uid, ids, context=None):
1013 """ Compatibility when changing to case_open. """
1014 return self.case_open(cr, uid, ids, context=context)
1016 def case_open(self, cr, uid, ids, context=None):
1017 if not isinstance(ids,list): ids = [ids]
1018 return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1020 def do_draft(self, cr, uid, ids, context=None):
1021 """ Compatibility when changing to case_draft. """
1022 return self.case_draft(cr, uid, ids, context=context)
1024 def case_draft(self, cr, uid, ids, context=None):
1025 return self.case_set(cr, uid, ids, 'draft', {}, context=context)
1027 def do_pending(self, cr, uid, ids, context=None):
1028 """ Compatibility when changing to case_pending. """
1029 return self.case_pending(cr, uid, ids, context=context)
1031 def case_pending(self, cr, uid, ids, context=None):
1032 return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1034 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1035 attachment = self.pool.get('ir.attachment')
1036 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1037 new_attachment_ids = []
1038 for attachment_id in attachment_ids:
1039 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1040 return new_attachment_ids
1042 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1044 Delegate Task to another users.
1046 if delegate_data is None:
1048 assert delegate_data['user_id'], _("Delegated User should be specified")
1049 delegated_tasks = {}
1050 for task in self.browse(cr, uid, ids, context=context):
1051 delegated_task_id = self.copy(cr, uid, task.id, {
1052 'name': delegate_data['name'],
1053 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1054 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1055 'planned_hours': delegate_data['planned_hours'] or 0.0,
1056 'parent_ids': [(6, 0, [task.id])],
1057 'description': delegate_data['new_task_description'] or '',
1061 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1062 newname = delegate_data['prefix'] or ''
1064 'remaining_hours': delegate_data['planned_hours_me'],
1065 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1068 if delegate_data['state'] == 'pending':
1069 self.do_pending(cr, uid, [task.id], context=context)
1070 elif delegate_data['state'] == 'done':
1071 self.do_close(cr, uid, [task.id], context=context)
1072 delegated_tasks[task.id] = delegated_task_id
1073 return delegated_tasks
1075 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1076 for task in self.browse(cr, uid, ids, context=context):
1077 if (task.state=='draft') or (task.planned_hours==0.0):
1078 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1079 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1082 def set_remaining_time_1(self, cr, uid, ids, context=None):
1083 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1085 def set_remaining_time_2(self, cr, uid, ids, context=None):
1086 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1088 def set_remaining_time_5(self, cr, uid, ids, context=None):
1089 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1091 def set_remaining_time_10(self, cr, uid, ids, context=None):
1092 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1094 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1095 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1097 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1098 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1100 def set_kanban_state_done(self, cr, uid, ids, context=None):
1101 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1104 def _store_history(self, cr, uid, ids, context=None):
1105 for task in self.browse(cr, uid, ids, context=context):
1106 self.pool.get('project.task.history').create(cr, uid, {
1108 'remaining_hours': task.remaining_hours,
1109 'planned_hours': task.planned_hours,
1110 'kanban_state': task.kanban_state,
1111 'type_id': task.stage_id.id,
1112 'state': task.state,
1113 'user_id': task.user_id.id
1118 def create(self, cr, uid, vals, context=None):
1121 if not vals.get('stage_id'):
1122 ctx = context.copy()
1123 if vals.get('project_id'):
1124 ctx['default_project_id'] = vals['project_id']
1125 vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
1126 # context: no_log, because subtype already handle this
1127 create_context = dict(context, mail_create_nolog=True)
1128 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1129 self._store_history(cr, uid, [task_id], context=context)
1132 # Overridden to reset the kanban_state to normal whenever
1133 # the stage (stage_id) of the task changes.
1134 def write(self, cr, uid, ids, vals, context=None):
1135 if isinstance(ids, (int, long)):
1137 if vals.get('project_id'):
1138 project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1140 vals.setdefault('message_follower_ids', [])
1141 vals['message_follower_ids'] += [(6, 0,[follower.id]) for follower in project_id.message_follower_ids]
1142 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1143 new_stage = vals.get('stage_id')
1144 vals_reset_kstate = dict(vals, kanban_state='normal')
1145 for t in self.browse(cr, uid, ids, context=context):
1146 #TO FIX:Kanban view doesn't raise warning
1147 #stages = [stage.id for stage in t.project_id.type_ids]
1148 #if new_stage not in stages:
1149 #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1150 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1151 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1154 result = super(task, self).write(cr, uid, ids, vals, context=context)
1155 if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1156 self._store_history(cr, uid, ids, context=context)
1159 def unlink(self, cr, uid, ids, context=None):
1162 self._check_child_task(cr, uid, ids, context=context)
1163 res = super(task, self).unlink(cr, uid, ids, context)
1166 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1167 context = context or {}
1171 if task.state in ('done','cancelled'):
1176 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1178 for t2 in task.parent_ids:
1179 start.append("up.Task_%s.end" % (t2.id,))
1183 ''' % (ident,','.join(start))
1188 ''' % (ident, 'User_'+str(task.user_id.id))
1193 # ---------------------------------------------------
1195 # ---------------------------------------------------
1197 def message_get_reply_to(self, cr, uid, ids, context=None):
1198 """ Override to get the reply_to of the parent project. """
1199 return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1200 for task in self.browse(cr, uid, ids, context=context)]
1202 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
1203 """ mail.message document permission rule: can post a new message if can read
1204 because of portal document. """
1207 if operation == 'create':
1208 model_obj.check_access_rights(cr, uid, 'read')
1209 model_obj.check_access_rule(cr, uid, mids, 'read', context=context)
1211 return super(task, self).check_mail_message_access(cr, uid, mids, operation, model_obj=model_obj, context=context)
1213 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1214 """ Override to updates the document according to the email. """
1215 if custom_values is None: custom_values = {}
1217 'name': msg.get('subject'),
1218 'planned_hours': 0.0,
1220 defaults.update(custom_values)
1221 return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1223 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1224 """ Override to update the task according to the email. """
1225 if update_vals is None: update_vals = {}
1228 'cost':'planned_hours',
1230 for line in msg['body'].split('\n'):
1232 res = tools.command_re.match(line)
1234 match = res.group(1).lower()
1235 field = maps.get(match)
1238 update_vals[field] = float(res.group(2).lower())
1239 except (ValueError, TypeError):
1241 elif match.lower() == 'state' \
1242 and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1243 act = 'do_%s' % res.group(2).lower()
1245 getattr(self,act)(cr, uid, ids, context=context)
1246 return super(task,self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1248 def project_task_reevaluate(self, cr, uid, ids, context=None):
1249 if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1251 'view_type': 'form',
1252 "view_mode": 'form',
1253 'res_model': 'project.task.reevaluate',
1254 'type': 'ir.actions.act_window',
1257 return self.do_reopen(cr, uid, ids, context=context)
1259 class project_work(osv.osv):
1260 _name = "project.task.work"
1261 _description = "Project Task Work"
1263 'name': fields.char('Work summary', size=128),
1264 'date': fields.datetime('Date', select="1"),
1265 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1266 'hours': fields.float('Time Spent'),
1267 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1268 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1272 'user_id': lambda obj, cr, uid, context: uid,
1273 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1276 _order = "date desc"
1277 def create(self, cr, uid, vals, *args, **kwargs):
1278 if 'hours' in vals and (not vals['hours']):
1279 vals['hours'] = 0.00
1280 if 'task_id' in vals:
1281 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1282 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1284 def write(self, cr, uid, ids, vals, context=None):
1285 if 'hours' in vals and (not vals['hours']):
1286 vals['hours'] = 0.00
1288 for work in self.browse(cr, uid, ids, context=context):
1289 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))
1290 return super(project_work,self).write(cr, uid, ids, vals, context)
1292 def unlink(self, cr, uid, ids, *args, **kwargs):
1293 for work in self.browse(cr, uid, ids):
1294 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1295 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1298 class account_analytic_account(osv.osv):
1299 _inherit = 'account.analytic.account'
1300 _description = 'Analytic Account'
1302 'use_tasks': fields.boolean('Tasks',help="If checked, this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1303 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1306 def on_change_template(self, cr, uid, ids, template_id, context=None):
1307 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1308 if template_id and 'value' in res:
1309 template = self.browse(cr, uid, template_id, context=context)
1310 res['value']['use_tasks'] = template.use_tasks
1313 def _trigger_project_creation(self, cr, uid, vals, context=None):
1315 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.
1317 if context is None: context = {}
1318 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1320 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1322 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.
1324 project_pool = self.pool.get('project.project')
1325 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1326 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1328 'name': vals.get('name'),
1329 'analytic_account_id': analytic_account_id,
1330 'type': vals.get('type','contract'),
1332 return project_pool.create(cr, uid, project_values, context=context)
1335 def create(self, cr, uid, vals, context=None):
1338 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1339 vals['child_ids'] = []
1340 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1341 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1342 return analytic_account_id
1344 def write(self, cr, uid, ids, vals, context=None):
1345 vals_for_project = vals.copy()
1346 for account in self.browse(cr, uid, ids, context=context):
1347 if not vals.get('name'):
1348 vals_for_project['name'] = account.name
1349 if not vals.get('type'):
1350 vals_for_project['type'] = account.type
1351 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1352 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1354 def unlink(self, cr, uid, ids, *args, **kwargs):
1355 project_obj = self.pool.get('project.project')
1356 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1358 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1359 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1361 class project_project(osv.osv):
1362 _inherit = 'project.project'
1367 class project_task_history(osv.osv):
1369 Tasks History, used for cumulative flow charts (Lean/Agile)
1371 _name = 'project.task.history'
1372 _description = 'History of Tasks'
1373 _rec_name = 'task_id'
1376 def _get_date(self, cr, uid, ids, name, arg, context=None):
1378 for history in self.browse(cr, uid, ids, context=context):
1379 if history.state in ('done','cancelled'):
1380 result[history.id] = history.date
1382 cr.execute('''select
1385 project_task_history
1389 order by id limit 1''', (history.task_id.id, history.id))
1391 result[history.id] = res and res[0] or False
1394 def _get_related_date(self, cr, uid, ids, context=None):
1396 for history in self.browse(cr, uid, ids, context=context):
1397 cr.execute('''select
1400 project_task_history
1404 order by id desc limit 1''', (history.task_id.id, history.id))
1407 result.append(res[0])
1411 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1412 'type_id': fields.many2one('project.task.type', 'Stage'),
1413 'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1414 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1415 'date': fields.date('Date', select=True),
1416 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1417 'project.task.history': (_get_related_date, None, 20)
1419 'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1420 'planned_hours': fields.float('Planned Time', digits=(16,2)),
1421 'user_id': fields.many2one('res.users', 'Responsible'),
1424 'date': fields.date.context_today,
1427 class project_task_history_cumulative(osv.osv):
1428 _name = 'project.task.history.cumulative'
1429 _table = 'project_task_history_cumulative'
1430 _inherit = 'project.task.history'
1434 'end_date': fields.date('End Date'),
1435 'project_id': fields.many2one('project.project', 'Project'),
1439 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1441 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1443 history.date::varchar||'-'||history.history_id::varchar AS id,
1444 history.date AS end_date,
1449 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1450 h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1451 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1454 project_task_history AS h
1455 JOIN project_task AS t ON (h.task_id = t.id)
1461 class project_category(osv.osv):
1462 """ Category of project's task (or issue) """
1463 _name = "project.category"
1464 _description = "Category of project's task, issue, ..."
1466 'name': fields.char('Name', size=64, required=True, translate=True),
1468 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: