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.addons.resource.faces import task as Task
29 from openerp.osv import fields, osv
30 from openerp.tools.translate import _
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('Default for New 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 'fold': fields.boolean('Folded in Kanban View',
45 help='This stage is folded in the kanban view when'
46 'there are no records in that stage to display.'),
49 def _get_default_project_ids(self, cr, uid, ctx={}):
50 project_id = self.pool['project.task']._get_default_project_id(cr, uid, context=ctx)
57 'project_ids': _get_default_project_ids,
62 class project(osv.osv):
63 _name = "project.project"
64 _description = "Project"
65 _inherits = {'account.analytic.account': "analytic_account_id",
66 "mail.alias": "alias_id"}
67 _inherit = ['mail.thread', 'ir.needaction_mixin']
69 def _auto_init(self, cr, context=None):
70 """ Installation hook: aliases, project.project """
71 # create aliases for all projects and avoid constraint errors
72 alias_context = dict(context, alias_model_name='project.task')
73 return self.pool.get('mail.alias').migrate_to_alias(cr, self._name, self._table, super(project, self)._auto_init,
74 'project.task', self._columns['alias_id'], 'id', alias_prefix='project+', alias_defaults={'project_id':'id'}, context=alias_context)
76 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
78 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
79 if context and context.get('user_preference'):
80 cr.execute("""SELECT project.id FROM project_project project
81 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
82 LEFT JOIN project_user_rel rel ON rel.project_id = project.id
83 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
84 return [(r[0]) for r in cr.fetchall()]
85 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
86 context=context, count=count)
88 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
89 partner_obj = self.pool.get('res.partner')
93 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
94 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
95 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
96 val['pricelist_id'] = pricelist_id
99 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
100 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
101 project_ids = [task.project_id.id for task in tasks if task.project_id]
102 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
104 def _get_project_and_parents(self, cr, uid, ids, context=None):
105 """ return the project ids and all their parent projects """
109 SELECT DISTINCT parent.id
110 FROM project_project project, project_project parent, account_analytic_account account
111 WHERE project.analytic_account_id = account.id
112 AND parent.analytic_account_id = account.parent_id
115 ids = [t[0] for t in cr.fetchall()]
119 def _get_project_and_children(self, cr, uid, ids, context=None):
120 """ retrieve all children projects of project ids;
121 return a dictionary mapping each project to its parent project (or None)
123 res = dict.fromkeys(ids, None)
126 SELECT project.id, parent.id
127 FROM project_project project, project_project parent, account_analytic_account account
128 WHERE project.analytic_account_id = account.id
129 AND parent.analytic_account_id = account.parent_id
132 dic = dict(cr.fetchall())
137 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
138 child_parent = self._get_project_and_children(cr, uid, ids, context)
139 # compute planned_hours, total_hours, effective_hours specific to each project
141 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
142 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
144 LEFT JOIN project_task_type ON project_task.stage_id = project_task_type.id
145 WHERE project_task.project_id IN %s AND project_task_type.fold = False
147 """, (tuple(child_parent.keys()),))
148 # aggregate results into res
149 res = dict([(id, {'planned_hours':0.0, 'total_hours':0.0, 'effective_hours':0.0}) for id in ids])
150 for id, planned, total, effective in cr.fetchall():
151 # add the values specific to id to all parent projects of id in the result
154 res[id]['planned_hours'] += planned
155 res[id]['total_hours'] += total
156 res[id]['effective_hours'] += effective
157 id = child_parent[id]
158 # compute progress rates
160 if res[id]['total_hours']:
161 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
163 res[id]['progress_rate'] = 0.0
166 def unlink(self, cr, uid, ids, context=None):
168 mail_alias = self.pool.get('mail.alias')
169 for proj in self.browse(cr, uid, ids, context=context):
171 raise osv.except_osv(_('Invalid Action!'),
172 _('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.'))
174 alias_ids.append(proj.alias_id.id)
175 res = super(project, self).unlink(cr, uid, ids, context=context)
176 mail_alias.unlink(cr, uid, alias_ids, context=context)
179 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
181 attachment = self.pool.get('ir.attachment')
182 task = self.pool.get('project.task')
184 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
185 task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
186 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
187 res[id] = (project_attachments or 0) + (task_attachments or 0)
190 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
191 """ :deprecated: this method will be removed with OpenERP v8. Use task_ids
195 res = dict.fromkeys(ids, 0)
197 ctx['active_test'] = False
198 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)], context=ctx)
199 for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
200 res[task.project_id.id] += 1
203 def _get_alias_models(self, cr, uid, context=None):
204 """ Overriden in project_issue to offer more options """
205 return [('project.task', "Tasks")]
207 def _get_visibility_selection(self, cr, uid, context=None):
208 """ Overriden in portal_project to offer more options """
209 return [('public', 'Public project'),
210 ('employees', 'Internal project: all employees can access'),
211 ('followers', 'Private project: followers Only')]
213 def attachment_tree_view(self, cr, uid, ids, context):
214 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
217 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
218 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)]
219 res_id = ids and ids[0] or False
221 'name': _('Attachments'),
223 'res_model': 'ir.attachment',
224 'type': 'ir.actions.act_window',
226 'view_mode': 'tree,form',
229 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
232 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
233 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
234 _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs)
237 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
238 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
239 '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),
240 'priority': fields.integer('Sequence (deprecated)',
241 deprecated='Will be removed with OpenERP v8; use sequence field instead',
242 help="Gives the sequence order when displaying the list of projects"),
243 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
244 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)]}),
245 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
246 '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.",
248 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
249 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
251 '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.",
253 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
254 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
256 '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.",
258 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
259 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
261 '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.",
263 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
264 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
266 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
267 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
268 'task_count': fields.function(_task_count, type='integer', string="Open Tasks",
269 deprecated="This field will be removed in OpenERP v8. Use task_ids one2many field instead."),
270 'task_ids': fields.one2many('project.task', 'project_id',
271 domain=[('stage_id.fold', '=', False)]),
272 'color': fields.integer('Color Index'),
273 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="restrict", required=True,
274 help="Internal email associated with this project. Incoming emails are automatically synchronized"
275 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
276 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
277 help="The kind of document created when an email is received on this project's email alias"),
278 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy / Visibility', required=True,
279 help="Holds visibility of the tasks or issues that belong to the current project:\n"
280 "- Public: everybody sees everything; if portal is activated, portal users\n"
281 " see all tasks or issues; if anonymous portal is activated, visitors\n"
282 " see all tasks or issues\n"
283 "- Portal (only available if Portal is installed): employees see everything;\n"
284 " if portal is activated, portal users see the tasks or issues followed by\n"
285 " them or by someone of their company\n"
286 "- Employees Only: employees see all tasks or issues\n"
287 "- Followers Only: employees see only the followed tasks or issues; if portal\n"
288 " is activated, portal users see the followed tasks or issues."),
289 'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
290 'doc_count': fields.function(
291 _get_attached_docs, string="Number of documents attached", type='integer'
295 def _get_type_common(self, cr, uid, context):
296 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
299 _order = "sequence, id"
305 'type_ids': _get_type_common,
306 'alias_model': 'project.task',
307 'privacy_visibility': 'employees',
310 # TODO: Why not using a SQL contraints ?
311 def _check_dates(self, cr, uid, ids, context=None):
312 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
313 if leave['date_start'] and leave['date']:
314 if leave['date_start'] > leave['date']:
319 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
322 def set_template(self, cr, uid, ids, context=None):
323 return self.setActive(cr, uid, ids, value=False, context=context)
325 def set_done(self, cr, uid, ids, context=None):
326 return self.write(cr, uid, ids, {'state': 'close'}, context=context)
328 def set_cancel(self, cr, uid, ids, context=None):
329 return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
331 def set_pending(self, cr, uid, ids, context=None):
332 return self.write(cr, uid, ids, {'state': 'pending'}, context=context)
334 def set_open(self, cr, uid, ids, context=None):
335 return self.write(cr, uid, ids, {'state': 'open'}, context=context)
337 def reset_project(self, cr, uid, ids, context=None):
338 return self.setActive(cr, uid, ids, value=True, context=context)
340 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
341 """ copy and map tasks from old to new project """
345 task_obj = self.pool.get('project.task')
346 proj = self.browse(cr, uid, old_project_id, context=context)
347 for task in proj.tasks:
348 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, {}, context=context)
349 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
350 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
353 def copy(self, cr, uid, id, default=None, context=None):
359 context['active_test'] = False
360 default['state'] = 'open'
361 default['line_ids'] = []
362 default['tasks'] = []
364 # Don't prepare (expensive) data to copy children (analytic accounts),
365 # they are discarded in analytic.copy(), and handled in duplicate_template()
366 default['child_ids'] = []
368 proj = self.browse(cr, uid, id, context=context)
369 if not default.get('name', False):
370 default.update(name=_("%s (copy)") % (proj.name))
371 res = super(project, self).copy(cr, uid, id, default, context)
372 self.map_tasks(cr, uid, id, res, context=context)
375 def duplicate_template(self, cr, uid, ids, context=None):
378 data_obj = self.pool.get('ir.model.data')
380 for proj in self.browse(cr, uid, ids, context=context):
381 parent_id = context.get('parent_id', False)
382 context.update({'analytic_project_copy': True})
383 new_date_start = time.strftime('%Y-%m-%d')
385 if proj.date_start and proj.date:
386 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
387 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
388 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
389 context.update({'copy':True})
390 new_id = self.copy(cr, uid, proj.id, default = {
391 'name':_("%s (copy)") % (proj.name),
393 'date_start':new_date_start,
395 'parent_id':parent_id}, context=context)
396 result.append(new_id)
398 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
399 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
401 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
403 if result and len(result):
405 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
406 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
407 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
408 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
409 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
410 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
412 'name': _('Projects'),
414 'view_mode': 'form,tree',
415 'res_model': 'project.project',
418 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
419 'type': 'ir.actions.act_window',
420 'search_view_id': search_view['res_id'],
424 # set active value for a project, its sub projects and its tasks
425 def setActive(self, cr, uid, ids, value=True, context=None):
426 task_obj = self.pool.get('project.task')
427 for proj in self.browse(cr, uid, ids, context=None):
428 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
429 cr.execute('select id from project_task where project_id=%s', (proj.id,))
430 tasks_id = [x[0] for x in cr.fetchall()]
432 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
433 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
435 self.setActive(cr, uid, child_ids, value, context=None)
438 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
439 context = context or {}
440 if type(ids) in (long, int,):
442 projects = self.browse(cr, uid, ids, context=context)
444 for project in projects:
445 if (not project.members) and force_members:
446 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
448 resource_pool = self.pool.get('resource.resource')
450 result = "from openerp.addons.resource.faces import *\n"
451 result += "import datetime\n"
452 for project in self.browse(cr, uid, ids, context=context):
453 u_ids = [i.id for i in project.members]
454 if project.user_id and (project.user_id.id not in u_ids):
455 u_ids.append(project.user_id.id)
456 for task in project.tasks:
457 if task.user_id and (task.user_id.id not in u_ids):
458 u_ids.append(task.user_id.id)
459 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
460 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
461 for key, vals in resource_objs.items():
463 class User_%s(Resource):
465 ''' % (key, vals.get('efficiency', False))
472 def _schedule_project(self, cr, uid, project, context=None):
473 resource_pool = self.pool.get('resource.resource')
474 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
475 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
476 # TODO: check if we need working_..., default values are ok.
477 puids = [x.id for x in project.members]
479 puids.append(project.user_id.id)
487 project.date_start or time.strftime('%Y-%m-%d'), working_days,
488 '|'.join(['User_'+str(x) for x in puids]) or 'None'
490 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
497 #TODO: DO Resource allocation and compute availability
498 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
504 def schedule_tasks(self, cr, uid, ids, context=None):
505 context = context or {}
506 if type(ids) in (long, int,):
508 projects = self.browse(cr, uid, ids, context=context)
509 result = self._schedule_header(cr, uid, ids, False, context=context)
510 for project in projects:
511 result += self._schedule_project(cr, uid, project, context=context)
512 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
515 exec result in local_dict
516 projects_gantt = Task.BalancedProject(local_dict['Project'])
518 for project in projects:
519 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
520 for task in project.tasks:
521 if task.stage_id and task.stage_id.fold:
524 p = getattr(project_gantt, 'Task_%d' % (task.id,))
526 self.pool.get('project.task').write(cr, uid, [task.id], {
527 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
528 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
530 if (not task.user_id) and (p.booked_resource):
531 self.pool.get('project.task').write(cr, uid, [task.id], {
532 'user_id': int(p.booked_resource[0].name[5:]),
536 def create(self, cr, uid, vals, context=None):
539 # Prevent double project creation when 'use_tasks' is checked + alias management
540 create_context = dict(context, project_creation_in_progress=True,
541 alias_model_name=vals.get('alias_model', 'project.task'),
542 alias_parent_model_name=self._name)
544 if vals.get('type', False) not in ('template', 'contract'):
545 vals['type'] = 'contract'
547 project_id = super(project, self).create(cr, uid, vals, context=create_context)
548 project_rec = self.browse(cr, uid, project_id, context=context)
549 self.pool.get('mail.alias').write(cr, uid, [project_rec.alias_id.id], {'alias_parent_thread_id': project_id, 'alias_defaults': {'project_id': project_id}}, context)
552 def write(self, cr, uid, ids, vals, context=None):
553 # if alias_model has been changed, update alias_model_id accordingly
554 if vals.get('alias_model'):
555 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
556 vals.update(alias_model_id=model_ids[0])
557 return super(project, self).write(cr, uid, ids, vals, context=context)
561 _name = "project.task"
562 _description = "Task"
563 _date_name = "date_start"
564 _inherit = ['mail.thread', 'ir.needaction_mixin']
566 _mail_post_access = 'read'
569 # this is only an heuristics; depending on your particular stage configuration it may not match all 'new' stages
570 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.sequence <= 1,
571 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj.stage_id.sequence > 1,
574 'project.mt_task_assigned': lambda self, cr, uid, obj, ctx=None: obj.user_id and obj.user_id.id,
577 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj.kanban_state == 'blocked',
581 def _get_default_partner(self, cr, uid, context=None):
582 project_id = self._get_default_project_id(cr, uid, context)
584 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
585 if project and project.partner_id:
586 return project.partner_id.id
589 def _get_default_project_id(self, cr, uid, context=None):
590 """ Gives default section by checking if present in the context """
591 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
593 def _get_default_stage_id(self, cr, uid, context=None):
594 """ Gives default stage_id """
595 project_id = self._get_default_project_id(cr, uid, context=context)
596 return self.stage_find(cr, uid, [], project_id, [('fold', '=', False)], context=context)
598 def _resolve_project_id_from_context(self, cr, uid, context=None):
599 """ Returns ID of project based on the value of 'default_project_id'
600 context key, or None if it cannot be resolved to a single
605 if type(context.get('default_project_id')) in (int, long):
606 return context['default_project_id']
607 if isinstance(context.get('default_project_id'), basestring):
608 project_name = context['default_project_id']
609 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
610 if len(project_ids) == 1:
611 return project_ids[0][0]
614 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
615 stage_obj = self.pool.get('project.task.type')
616 order = stage_obj._order
617 access_rights_uid = access_rights_uid or uid
618 if read_group_order == 'stage_id desc':
619 order = '%s desc' % order
621 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
623 search_domain += ['|', ('project_ids', '=', project_id)]
624 search_domain += [('id', 'in', ids)]
625 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
626 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
627 # restore order of the search
628 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
631 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
632 fold[stage.id] = stage.fold or False
635 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
636 res_users = self.pool.get('res.users')
637 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
638 access_rights_uid = access_rights_uid or uid
640 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
641 order = res_users._order
642 # lame way to allow reverting search, should just work in the trivial case
643 if read_group_order == 'user_id desc':
644 order = '%s desc' % order
645 # de-duplicate and apply search order
646 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
647 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
648 # restore order of the search
649 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
653 'stage_id': _read_group_stage_ids,
654 'user_id': _read_group_user_id,
657 def _str_get(self, task, level=0, border='***', context=None):
658 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'+ \
659 border[0]+' '+(task.name or '')+'\n'+ \
660 (task.description or '')+'\n\n'
662 # Compute: effective_hours, total_hours, progress
663 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
665 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
666 hours = dict(cr.fetchall())
667 for task in self.browse(cr, uid, ids, context=context):
668 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)}
669 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
670 res[task.id]['progress'] = 0.0
671 if (task.remaining_hours + hours.get(task.id, 0.0)):
672 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
673 # TDE CHECK: if task.state in ('done','cancelled'):
674 if task.stage_id and task.stage_id.fold:
675 res[task.id]['progress'] = 100.0
678 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
679 if remaining and not planned:
680 return {'value': {'planned_hours': remaining}}
683 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
684 return {'value': {'remaining_hours': planned - effective}}
686 def onchange_project(self, cr, uid, id, project_id, context=None):
688 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
689 if project and project.partner_id:
690 return {'value': {'partner_id': project.partner_id.id}}
693 def onchange_user_id(self, cr, uid, ids, user_id, context=None):
696 vals['date_start'] = fields.datetime.now()
697 return {'value': vals}
699 def duplicate_task(self, cr, uid, map_ids, context=None):
700 mapper = lambda t: map_ids.get(t.id, t.id)
701 for task in self.browse(cr, uid, map_ids.values(), context):
702 new_child_ids = set(map(mapper, task.child_ids))
703 new_parent_ids = set(map(mapper, task.parent_ids))
704 if new_child_ids or new_parent_ids:
705 task.write({'parent_ids': [(6,0,list(new_parent_ids))],
706 'child_ids': [(6,0,list(new_child_ids))]})
708 def copy_data(self, cr, uid, id, default=None, context=None):
711 default = default or {}
712 default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
713 if not default.get('remaining_hours', False):
714 default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
715 default['active'] = True
716 if not default.get('name', False):
717 default['name'] = self.browse(cr, uid, id, context=context).name or ''
718 if not context.get('copy',False):
719 new_name = _("%s (copy)") % (default.get('name', ''))
720 default.update({'name':new_name})
721 return super(task, self).copy_data(cr, uid, id, default, context)
723 def copy(self, cr, uid, id, default=None, context=None):
728 if not context.get('copy', False):
729 stage = self._get_default_stage_id(cr, uid, context=context)
731 default['stage_id'] = stage
732 return super(task, self).copy(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', track_visibility='onchange', 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', select=True,
756 domain="[('project_ids', '=', project_id)]"),
757 'categ_ids': fields.many2many('project.category', string='Tags'),
758 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
759 track_visibility='onchange',
760 help="A task's kanban state indicates special situations affecting it:\n"
761 " * Normal is the default situation\n"
762 " * Blocked indicates something is preventing the progress of this task\n"
763 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
764 readonly=True, required=False),
765 'create_date': fields.datetime('Create Date', readonly=True, select=True),
766 'write_date': fields.datetime('Last Modification Date', readonly=True, select=True), #not displayed in the view but it might be useful with base_action_rule module (and it needs to be defined first for that)
767 'date_start': fields.datetime('Starting Date',select=True),
768 'date_end': fields.datetime('Ending Date',select=True),
769 'date_deadline': fields.date('Deadline',select=True),
770 'date_last_stage_update': fields.datetime('Last Stage Update', select=True),
771 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select=True, track_visibility='onchange', change_default=True),
772 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
773 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
774 'notes': fields.text('Notes'),
775 'planned_hours': fields.float('Initially Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
776 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
778 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
779 'project.task.work': (_get_task, ['hours'], 10),
781 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
782 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
784 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
785 'project.task.work': (_get_task, ['hours'], 10),
787 'progress': fields.function(_hours_get, string='Working Time Progress (%)', multi='hours', group_operator="avg", help="If the task has a progress of 99.99% you should close the task if it's finished or reevaluate the time",
789 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours', 'state', 'stage_id'], 10),
790 'project.task.work': (_get_task, ['hours'], 10),
792 'delay_hours': fields.function(_hours_get, string='Delay Hours', multi='hours', help="Computed as difference between planned hours by the project manager and the total hours of the task.",
794 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
795 'project.task.work': (_get_task, ['hours'], 10),
797 'user_id': fields.many2one('res.users', 'Assigned to', select=True, track_visibility='onchange'),
798 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
799 'partner_id': fields.many2one('res.partner', 'Customer'),
800 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
801 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
802 'company_id': fields.many2one('res.company', 'Company'),
803 'id': fields.integer('ID', readonly=True),
804 'color': fields.integer('Color Index'),
805 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
808 'stage_id': _get_default_stage_id,
809 'project_id': _get_default_project_id,
810 'date_last_stage_update': fields.datetime.now,
811 'kanban_state': 'normal',
816 'user_id': lambda obj, cr, uid, ctx=None: uid,
817 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
818 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
820 _order = "priority, sequence, date_start, name, id"
822 def set_high_priority(self, cr, uid, ids, *args):
823 """Set task priority to high
825 return self.write(cr, uid, ids, {'priority' : '0'})
827 def set_normal_priority(self, cr, uid, ids, *args):
828 """Set task priority to normal
830 return self.write(cr, uid, ids, {'priority' : '2'})
832 def _check_recursion(self, cr, uid, ids, context=None):
834 visited_branch = set()
836 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
842 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
843 if id in visited_branch: #Cycle
846 if id in visited_node: #Already tested don't work one more time for nothing
849 visited_branch.add(id)
852 #visit child using DFS
853 task = self.browse(cr, uid, id, context=context)
854 for child in task.child_ids:
855 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
859 visited_branch.remove(id)
862 def _check_dates(self, cr, uid, ids, context=None):
865 obj_task = self.browse(cr, uid, ids[0], context=context)
866 start = obj_task.date_start or False
867 end = obj_task.date_end or False
874 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
875 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
878 # Override view according to the company definition
879 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
880 users_obj = self.pool.get('res.users')
881 if context is None: context = {}
882 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
883 # this should be safe (no context passed to avoid side-effects)
884 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
885 tm = obj_tm and obj_tm.name or 'Hours'
887 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
889 if tm in ['Hours','Hour']:
892 eview = etree.fromstring(res['arch'])
894 def _check_rec(eview):
895 if eview.attrib.get('widget','') == 'float_time':
896 eview.set('widget','float')
903 res['arch'] = etree.tostring(eview)
905 for f in res['fields']:
906 if 'Hours' in res['fields'][f]['string']:
907 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
910 def get_empty_list_help(self, cr, uid, help, context=None):
911 context['empty_list_help_id'] = context.get('default_project_id')
912 context['empty_list_help_model'] = 'project.project'
913 context['empty_list_help_document_name'] = _("tasks")
914 return super(task, self).get_empty_list_help(cr, uid, help, context=context)
916 # ----------------------------------------
918 # ----------------------------------------
920 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
921 """ Override of the base.stage method
922 Parameter of the stage search taken from the lead:
923 - section_id: if set, stages must belong to this section or
924 be a default stage; if not set, stages must be default
927 if isinstance(cases, (int, long)):
928 cases = self.browse(cr, uid, cases, context=context)
929 # collect all section_ids
932 section_ids.append(section_id)
935 section_ids.append(task.project_id.id)
938 search_domain = [('|')] * (len(section_ids) - 1)
939 for section_id in section_ids:
940 search_domain.append(('project_ids', '=', section_id))
941 search_domain += list(domain)
942 # perform search, return the first found
943 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
948 def _check_child_task(self, cr, uid, ids, context=None):
951 tasks = self.browse(cr, uid, ids, context=context)
954 for child in task.child_ids:
955 if child.stage_id and not child.stage_id.fold:
956 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
959 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
960 attachment = self.pool.get('ir.attachment')
961 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
962 new_attachment_ids = []
963 for attachment_id in attachment_ids:
964 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
965 return new_attachment_ids
967 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
969 Delegate Task to another users.
971 if delegate_data is None:
973 assert delegate_data['user_id'], _("Delegated User should be specified")
975 for task in self.browse(cr, uid, ids, context=context):
976 delegated_task_id = self.copy(cr, uid, task.id, {
977 'name': delegate_data['name'],
978 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
979 'stage_id': delegate_data.get('stage_id') and delegate_data.get('stage_id')[0] or False,
980 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
981 'planned_hours': delegate_data['planned_hours'] or 0.0,
982 'parent_ids': [(6, 0, [task.id])],
983 'description': delegate_data['new_task_description'] or '',
987 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
988 newname = delegate_data['prefix'] or ''
990 'remaining_hours': delegate_data['planned_hours_me'],
991 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
994 delegated_tasks[task.id] = delegated_task_id
995 return delegated_tasks
997 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
998 for task in self.browse(cr, uid, ids, context=context):
999 if (task.stage_id and task.stage_id.sequence <= 1) or (task.planned_hours == 0.0):
1000 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1001 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1004 def set_remaining_time_1(self, cr, uid, ids, context=None):
1005 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1007 def set_remaining_time_2(self, cr, uid, ids, context=None):
1008 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1010 def set_remaining_time_5(self, cr, uid, ids, context=None):
1011 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1013 def set_remaining_time_10(self, cr, uid, ids, context=None):
1014 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1016 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1017 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1019 def set_kanban_state_normal(self, cr, uid, ids, context=None):
1020 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1022 def set_kanban_state_done(self, cr, uid, ids, context=None):
1023 self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1026 def _store_history(self, cr, uid, ids, context=None):
1027 for task in self.browse(cr, uid, ids, context=context):
1028 self.pool.get('project.task.history').create(cr, uid, {
1030 'remaining_hours': task.remaining_hours,
1031 'planned_hours': task.planned_hours,
1032 'kanban_state': task.kanban_state,
1033 'type_id': task.stage_id.id,
1034 'user_id': task.user_id.id
1039 # ------------------------------------------------
1041 # ------------------------------------------------
1043 def create(self, cr, uid, vals, context=None):
1048 if vals.get('project_id') and not context.get('default_project_id'):
1049 context['default_project_id'] = vals.get('project_id')
1050 # user_id change: update date_start
1051 if vals.get('user_id') and not vals.get('start_date'):
1052 vals['date_start'] = fields.datetime.now()
1054 # context: no_log, because subtype already handle this
1055 create_context = dict(context, mail_create_nolog=True)
1056 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1057 self._store_history(cr, uid, [task_id], context=context)
1060 def write(self, cr, uid, ids, vals, context=None):
1061 if isinstance(ids, (int, long)):
1064 # stage change: update date_last_stage_update
1065 if 'stage_id' in vals:
1066 vals['date_last_stage_update'] = fields.datetime.now()
1067 # user_id change: update date_start
1068 if vals.get('user_id') and 'date_start' not in vals:
1069 vals['date_start'] = fields.datetime.now()
1071 # Overridden to reset the kanban_state to normal whenever
1072 # the stage (stage_id) of the task changes.
1073 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1074 new_stage = vals.get('stage_id')
1075 vals_reset_kstate = dict(vals, kanban_state='normal')
1076 for t in self.browse(cr, uid, ids, context=context):
1077 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1078 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1081 result = super(task, self).write(cr, uid, ids, vals, context=context)
1083 if any(item in vals for item in ['stage_id', 'remaining_hours', 'user_id', 'kanban_state']):
1084 self._store_history(cr, uid, ids, context=context)
1087 def unlink(self, cr, uid, ids, context=None):
1090 self._check_child_task(cr, uid, ids, context=context)
1091 res = super(task, self).unlink(cr, uid, ids, context)
1094 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1095 context = context or {}
1099 if task.stage_id and task.stage_id.fold:
1104 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1106 for t2 in task.parent_ids:
1107 start.append("up.Task_%s.end" % (t2.id,))
1111 ''' % (ident,','.join(start))
1116 ''' % (ident, 'User_'+str(task.user_id.id))
1121 # ---------------------------------------------------
1123 # ---------------------------------------------------
1125 def message_get_reply_to(self, cr, uid, ids, context=None):
1126 """ Override to get the reply_to of the parent project. """
1127 return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1128 for task in self.browse(cr, uid, ids, context=context)]
1130 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1131 """ Override to updates the document according to the email. """
1132 if custom_values is None:
1135 'name': msg.get('subject'),
1136 'planned_hours': 0.0,
1138 defaults.update(custom_values)
1139 return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1141 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1142 """ Override to update the task according to the email. """
1143 if update_vals is None:
1146 'cost': 'planned_hours',
1148 for line in msg['body'].split('\n'):
1150 res = tools.command_re.match(line)
1152 match = res.group(1).lower()
1153 field = maps.get(match)
1156 update_vals[field] = float(res.group(2).lower())
1157 except (ValueError, TypeError):
1159 return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1161 class project_work(osv.osv):
1162 _name = "project.task.work"
1163 _description = "Project Task Work"
1165 'name': fields.char('Work summary', size=128),
1166 'date': fields.datetime('Date', select="1"),
1167 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1168 'hours': fields.float('Time Spent'),
1169 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1170 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1174 'user_id': lambda obj, cr, uid, context: uid,
1175 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1178 _order = "date desc"
1179 def create(self, cr, uid, vals, *args, **kwargs):
1180 if 'hours' in vals and (not vals['hours']):
1181 vals['hours'] = 0.00
1182 if 'task_id' in vals:
1183 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1184 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1186 def write(self, cr, uid, ids, vals, context=None):
1187 if 'hours' in vals and (not vals['hours']):
1188 vals['hours'] = 0.00
1190 for work in self.browse(cr, uid, ids, context=context):
1191 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))
1192 return super(project_work,self).write(cr, uid, ids, vals, context)
1194 def unlink(self, cr, uid, ids, *args, **kwargs):
1195 for work in self.browse(cr, uid, ids):
1196 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1197 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1200 class account_analytic_account(osv.osv):
1201 _inherit = 'account.analytic.account'
1202 _description = 'Analytic Account'
1204 '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"),
1205 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1208 def on_change_template(self, cr, uid, ids, template_id, context=None):
1209 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1210 if template_id and 'value' in res:
1211 template = self.browse(cr, uid, template_id, context=context)
1212 res['value']['use_tasks'] = template.use_tasks
1215 def _trigger_project_creation(self, cr, uid, vals, context=None):
1217 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.
1219 if context is None: context = {}
1220 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1222 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1224 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.
1226 project_pool = self.pool.get('project.project')
1227 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1228 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1230 'name': vals.get('name'),
1231 'analytic_account_id': analytic_account_id,
1232 'type': vals.get('type','contract'),
1234 return project_pool.create(cr, uid, project_values, context=context)
1237 def create(self, cr, uid, vals, context=None):
1240 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1241 vals['child_ids'] = []
1242 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1243 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1244 return analytic_account_id
1246 def write(self, cr, uid, ids, vals, context=None):
1247 if isinstance(ids, (int, long)):
1249 vals_for_project = vals.copy()
1250 for account in self.browse(cr, uid, ids, context=context):
1251 if not vals.get('name'):
1252 vals_for_project['name'] = account.name
1253 if not vals.get('type'):
1254 vals_for_project['type'] = account.type
1255 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1256 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1258 def unlink(self, cr, uid, ids, *args, **kwargs):
1259 project_obj = self.pool.get('project.project')
1260 analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1262 raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1263 return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1265 def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
1270 if context.get('current_model') == 'project.project':
1271 project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context)
1272 return self.name_get(cr, uid, project_ids, context=context)
1274 return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit)
1277 class project_project(osv.osv):
1278 _inherit = 'project.project'
1283 class project_task_history(osv.osv):
1285 Tasks History, used for cumulative flow charts (Lean/Agile)
1287 _name = 'project.task.history'
1288 _description = 'History of Tasks'
1289 _rec_name = 'task_id'
1292 def _get_date(self, cr, uid, ids, name, arg, context=None):
1294 for history in self.browse(cr, uid, ids, context=context):
1295 if history.type_id and history.type_id.fold:
1296 result[history.id] = history.date
1298 cr.execute('''select
1301 project_task_history
1305 order by id limit 1''', (history.task_id.id, history.id))
1307 result[history.id] = res and res[0] or False
1310 def _get_related_date(self, cr, uid, ids, context=None):
1312 for history in self.browse(cr, uid, ids, context=context):
1313 cr.execute('''select
1316 project_task_history
1320 order by id desc limit 1''', (history.task_id.id, history.id))
1323 result.append(res[0])
1327 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1328 'type_id': fields.many2one('project.task.type', 'Stage'),
1329 'kanban_state': fields.selection([('normal', 'Normal'), ('blocked', 'Blocked'), ('done', 'Ready for next stage')], 'Kanban State', required=False),
1330 'date': fields.date('Date', select=True),
1331 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1332 'project.task.history': (_get_related_date, None, 20)
1334 'remaining_hours': fields.float('Remaining Time', digits=(16, 2)),
1335 'planned_hours': fields.float('Planned Time', digits=(16, 2)),
1336 'user_id': fields.many2one('res.users', 'Responsible'),
1339 'date': fields.date.context_today,
1342 class project_task_history_cumulative(osv.osv):
1343 _name = 'project.task.history.cumulative'
1344 _table = 'project_task_history_cumulative'
1345 _inherit = 'project.task.history'
1349 'end_date': fields.date('End Date'),
1350 'project_id': fields.many2one('project.project', 'Project'),
1354 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1356 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1358 history.date::varchar||'-'||history.history_id::varchar AS id,
1359 history.date AS end_date,
1364 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1365 h.task_id, h.type_id, h.user_id, h.kanban_state,
1366 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1369 project_task_history AS h
1370 JOIN project_task AS t ON (h.task_id = t.id)
1376 class project_category(osv.osv):
1377 """ Category of project's task (or issue) """
1378 _name = "project.category"
1379 _description = "Category of project's task, issue, ..."
1381 'name': fields.char('Name', size=64, required=True, translate=True),
1383 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: