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, 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 analytic_account_to_delete = set()
170 for proj in self.browse(cr, uid, ids, context=context):
172 raise osv.except_osv(_('Invalid Action!'),
173 _('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.'))
175 alias_ids.append(proj.alias_id.id)
176 if proj.analytic_account_id and not proj.analytic_account_id.line_ids:
177 analytic_account_to_delete.add(proj.analytic_account_id.id)
178 res = super(project, self).unlink(cr, uid, ids, context=context)
179 mail_alias.unlink(cr, uid, alias_ids, context=context)
180 self.pool['account.analytic.account'].unlink(cr, uid, list(analytic_account_to_delete), context=context)
183 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
185 attachment = self.pool.get('ir.attachment')
186 task = self.pool.get('project.task')
188 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
189 task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
190 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
191 res[id] = (project_attachments or 0) + (task_attachments or 0)
193 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
195 for tasks in self.browse(cr, uid, ids, context):
196 res[tasks.id] = len(tasks.task_ids)
198 def _get_alias_models(self, cr, uid, context=None):
199 """ Overriden in project_issue to offer more options """
200 return [('project.task', "Tasks")]
202 def _get_visibility_selection(self, cr, uid, context=None):
203 """ Overriden in portal_project to offer more options """
204 return [('public', 'Public project'),
205 ('employees', 'Internal project: all employees can access'),
206 ('followers', 'Private project: followers Only')]
208 def attachment_tree_view(self, cr, uid, ids, context):
209 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
212 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
213 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)]
214 res_id = ids and ids[0] or False
216 'name': _('Attachments'),
218 'res_model': 'ir.attachment',
219 'type': 'ir.actions.act_window',
221 'view_mode': 'kanban,tree,form',
224 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
227 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
228 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
229 _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs)
232 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
233 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
234 '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),
235 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
236 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)]}),
237 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
238 '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.",
240 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
241 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
243 '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.",
245 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
246 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
248 '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.",
250 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
251 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
253 '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.",
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', 'stage_id'], 20),
258 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
259 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
260 'task_count': fields.function(_task_count, type='integer', string="Tasks",),
261 'task_ids': fields.one2many('project.task', 'project_id',
262 domain=[('stage_id.fold', '=', False)]),
263 'color': fields.integer('Color Index'),
264 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="restrict", required=True,
265 help="Internal email associated with this project. Incoming emails are automatically synchronized"
266 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
267 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
268 help="The kind of document created when an email is received on this project's email alias"),
269 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy / Visibility', required=True,
270 help="Holds visibility of the tasks or issues that belong to the current project:\n"
271 "- Public: everybody sees everything; if portal is activated, portal users\n"
272 " see all tasks or issues; if anonymous portal is activated, visitors\n"
273 " see all tasks or issues\n"
274 "- Portal (only available if Portal is installed): employees see everything;\n"
275 " if portal is activated, portal users see the tasks or issues followed by\n"
276 " them or by someone of their company\n"
277 "- Employees Only: employees see all tasks or issues\n"
278 "- Followers Only: employees see only the followed tasks or issues; if portal\n"
279 " is activated, portal users see the followed tasks or issues."),
280 'state': fields.selection([('template', 'Template'),
282 ('open','In Progress'),
283 ('cancelled', 'Cancelled'),
284 ('pending','Pending'),
286 'Status', required=True, copy=False),
287 'doc_count': fields.function(
288 _get_attached_docs, string="Number of documents attached", type='integer'
292 def _get_type_common(self, cr, uid, context):
293 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
296 _order = "sequence, id"
302 'type_ids': _get_type_common,
303 'alias_model': 'project.task',
304 'privacy_visibility': 'employees',
307 # TODO: Why not using a SQL contraints ?
308 def _check_dates(self, cr, uid, ids, context=None):
309 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
310 if leave['date_start'] and leave['date']:
311 if leave['date_start'] > leave['date']:
316 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
319 def set_template(self, cr, uid, ids, context=None):
320 return self.setActive(cr, uid, ids, value=False, context=context)
322 def set_done(self, cr, uid, ids, context=None):
323 return self.write(cr, uid, ids, {'state': 'close'}, context=context)
325 def set_cancel(self, cr, uid, ids, context=None):
326 return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
328 def set_pending(self, cr, uid, ids, context=None):
329 return self.write(cr, uid, ids, {'state': 'pending'}, context=context)
331 def set_open(self, cr, uid, ids, context=None):
332 return self.write(cr, uid, ids, {'state': 'open'}, context=context)
334 def reset_project(self, cr, uid, ids, context=None):
335 return self.setActive(cr, uid, ids, value=True, context=context)
337 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
338 """ copy and map tasks from old to new project """
342 task_obj = self.pool.get('project.task')
343 proj = self.browse(cr, uid, old_project_id, context=context)
344 for task in proj.tasks:
345 # preserve task name and stage, normally altered during copy
346 defaults = {'stage_id': task.stage_id.id,
348 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, defaults, 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):
356 context = dict(context or {})
357 context['active_test'] = False
358 proj = self.browse(cr, uid, id, context=context)
359 if not default.get('name'):
360 default.update(name=_("%s (copy)") % (proj.name))
361 res = super(project, self).copy(cr, uid, id, default, context)
362 self.map_tasks(cr, uid, id, res, context=context)
365 def duplicate_template(self, cr, uid, ids, context=None):
366 context = dict(context or {})
367 data_obj = self.pool.get('ir.model.data')
369 for proj in self.browse(cr, uid, ids, context=context):
370 parent_id = context.get('parent_id', False)
371 context.update({'analytic_project_copy': True})
372 new_date_start = time.strftime('%Y-%m-%d')
374 if proj.date_start and proj.date:
375 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
376 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
377 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
378 context.update({'copy':True})
379 new_id = self.copy(cr, uid, proj.id, default = {
380 'name':_("%s (copy)") % (proj.name),
382 'date_start':new_date_start,
384 'parent_id':parent_id}, context=context)
385 result.append(new_id)
387 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
388 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
390 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
392 if result and len(result):
394 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
395 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
396 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
397 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
398 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
399 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
401 'name': _('Projects'),
403 'view_mode': 'form,tree',
404 'res_model': 'project.project',
407 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
408 'type': 'ir.actions.act_window',
409 'search_view_id': search_view['res_id'],
413 # set active value for a project, its sub projects and its tasks
414 def setActive(self, cr, uid, ids, value=True, context=None):
415 task_obj = self.pool.get('project.task')
416 for proj in self.browse(cr, uid, ids, context=None):
417 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
418 cr.execute('select id from project_task where project_id=%s', (proj.id,))
419 tasks_id = [x[0] for x in cr.fetchall()]
421 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
422 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
424 self.setActive(cr, uid, child_ids, value, context=None)
427 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
428 context = context or {}
429 if type(ids) in (long, int,):
431 projects = self.browse(cr, uid, ids, context=context)
433 for project in projects:
434 if (not project.members) and force_members:
435 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
437 resource_pool = self.pool.get('resource.resource')
439 result = "from openerp.addons.resource.faces import *\n"
440 result += "import datetime\n"
441 for project in self.browse(cr, uid, ids, context=context):
442 u_ids = [i.id for i in project.members]
443 if project.user_id and (project.user_id.id not in u_ids):
444 u_ids.append(project.user_id.id)
445 for task in project.tasks:
446 if task.user_id and (task.user_id.id not in u_ids):
447 u_ids.append(task.user_id.id)
448 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
449 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
450 for key, vals in resource_objs.items():
452 class User_%s(Resource):
454 ''' % (key, vals.get('efficiency', False))
461 def _schedule_project(self, cr, uid, project, context=None):
462 resource_pool = self.pool.get('resource.resource')
463 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
464 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
465 # TODO: check if we need working_..., default values are ok.
466 puids = [x.id for x in project.members]
468 puids.append(project.user_id.id)
476 project.date_start or time.strftime('%Y-%m-%d'), working_days,
477 '|'.join(['User_'+str(x) for x in puids]) or 'None'
479 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
486 #TODO: DO Resource allocation and compute availability
487 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
493 def schedule_tasks(self, cr, uid, ids, context=None):
494 context = context or {}
495 if type(ids) in (long, int,):
497 projects = self.browse(cr, uid, ids, context=context)
498 result = self._schedule_header(cr, uid, ids, False, context=context)
499 for project in projects:
500 result += self._schedule_project(cr, uid, project, context=context)
501 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
504 exec result in local_dict
505 projects_gantt = Task.BalancedProject(local_dict['Project'])
507 for project in projects:
508 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
509 for task in project.tasks:
510 if task.stage_id and task.stage_id.fold:
513 p = getattr(project_gantt, 'Task_%d' % (task.id,))
515 self.pool.get('project.task').write(cr, uid, [task.id], {
516 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
517 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
519 if (not task.user_id) and (p.booked_resource):
520 self.pool.get('project.task').write(cr, uid, [task.id], {
521 'user_id': int(p.booked_resource[0].name[5:]),
525 def create(self, cr, uid, vals, context=None):
528 # Prevent double project creation when 'use_tasks' is checked + alias management
529 create_context = dict(context, project_creation_in_progress=True,
530 alias_model_name=vals.get('alias_model', 'project.task'),
531 alias_parent_model_name=self._name)
533 if vals.get('type', False) not in ('template', 'contract'):
534 vals['type'] = 'contract'
536 project_id = super(project, self).create(cr, uid, vals, context=create_context)
537 project_rec = self.browse(cr, uid, project_id, context=context)
538 ir_values = self.pool.get('ir.values').get_default( cr, uid, 'project.config.settings', 'generate_project_alias' )
539 values = { 'alias_parent_thread_id': project_id, 'alias_defaults': {'project_id': project_id}}
541 values = dict(values, alias_name=vals['name'])
542 self.pool.get('mail.alias').write(cr, uid, [project_rec.alias_id.id], values, context=context)
545 def write(self, cr, uid, ids, vals, context=None):
546 # if alias_model has been changed, update alias_model_id accordingly
547 if vals.get('alias_model'):
548 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
549 vals.update(alias_model_id=model_ids[0])
550 return super(project, self).write(cr, uid, ids, vals, context=context)
554 _name = "project.task"
555 _description = "Task"
556 _date_name = "date_start"
557 _inherit = ['mail.thread', 'ir.needaction_mixin']
559 _mail_post_access = 'read'
562 # this is only an heuristics; depending on your particular stage configuration it may not match all 'new' stages
563 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.sequence <= 1,
564 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj.stage_id.sequence > 1,
567 'project.mt_task_assigned': lambda self, cr, uid, obj, ctx=None: obj.user_id and obj.user_id.id,
570 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj.kanban_state == 'blocked',
571 'project.mt_task_ready': lambda self, cr, uid, obj, ctx=None: obj.kanban_state == 'done',
575 def _get_default_partner(self, cr, uid, context=None):
576 project_id = self._get_default_project_id(cr, uid, context)
578 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
579 if project and project.partner_id:
580 return project.partner_id.id
583 def _get_default_project_id(self, cr, uid, context=None):
584 """ Gives default section by checking if present in the context """
585 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
587 def _get_default_stage_id(self, cr, uid, context=None):
588 """ Gives default stage_id """
589 project_id = self._get_default_project_id(cr, uid, context=context)
590 return self.stage_find(cr, uid, [], project_id, [('fold', '=', False)], context=context)
592 def _resolve_project_id_from_context(self, cr, uid, context=None):
593 """ Returns ID of project based on the value of 'default_project_id'
594 context key, or None if it cannot be resolved to a single
599 if type(context.get('default_project_id')) in (int, long):
600 return context['default_project_id']
601 if isinstance(context.get('default_project_id'), basestring):
602 project_name = context['default_project_id']
603 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
604 if len(project_ids) == 1:
605 return project_ids[0][0]
608 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
609 stage_obj = self.pool.get('project.task.type')
610 order = stage_obj._order
611 access_rights_uid = access_rights_uid or uid
612 if read_group_order == 'stage_id desc':
613 order = '%s desc' % order
615 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
617 search_domain += ['|', ('project_ids', '=', project_id)]
618 search_domain += [('id', 'in', ids)]
619 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
620 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
621 # restore order of the search
622 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
625 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
626 fold[stage.id] = stage.fold or False
629 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
630 res_users = self.pool.get('res.users')
631 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
632 access_rights_uid = access_rights_uid or uid
634 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
635 order = res_users._order
636 # lame way to allow reverting search, should just work in the trivial case
637 if read_group_order == 'user_id desc':
638 order = '%s desc' % order
639 # de-duplicate and apply search order
640 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
641 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
642 # restore order of the search
643 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
647 'stage_id': _read_group_stage_ids,
648 'user_id': _read_group_user_id,
651 def _str_get(self, task, level=0, border='***', context=None):
652 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'+ \
653 border[0]+' '+(task.name or '')+'\n'+ \
654 (task.description or '')+'\n\n'
656 # Compute: effective_hours, total_hours, progress
657 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
659 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
660 hours = dict(cr.fetchall())
661 for task in self.browse(cr, uid, ids, context=context):
662 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)}
663 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
664 res[task.id]['progress'] = 0.0
665 if (task.remaining_hours + hours.get(task.id, 0.0)):
666 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
667 # TDE CHECK: if task.state in ('done','cancelled'):
668 if task.stage_id and task.stage_id.fold:
669 res[task.id]['progress'] = 100.0
672 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
673 if remaining and not planned:
674 return {'value': {'planned_hours': remaining}}
677 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
678 return {'value': {'remaining_hours': planned - effective}}
680 def onchange_project(self, cr, uid, id, project_id, context=None):
682 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
683 if project and project.partner_id:
684 return {'value': {'partner_id': project.partner_id.id}}
687 def onchange_user_id(self, cr, uid, ids, user_id, context=None):
690 vals['date_start'] = fields.datetime.now()
691 return {'value': vals}
693 def duplicate_task(self, cr, uid, map_ids, context=None):
694 mapper = lambda t: map_ids.get(t.id, t.id)
695 for task in self.browse(cr, uid, map_ids.values(), context):
696 new_child_ids = set(map(mapper, task.child_ids))
697 new_parent_ids = set(map(mapper, task.parent_ids))
698 if new_child_ids or new_parent_ids:
699 task.write({'parent_ids': [(6,0,list(new_parent_ids))],
700 'child_ids': [(6,0,list(new_child_ids))]})
702 def copy_data(self, cr, uid, id, default=None, context=None):
705 if not default.get('name'):
706 current = self.browse(cr, uid, id, context=context)
707 default['name'] = _("%s (copy)") % current.name
708 return super(task, self).copy_data(cr, uid, id, default, context)
710 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
712 for task in self.browse(cr, uid, ids, context=context):
715 if task.project_id.active == False or task.project_id.state == 'template':
719 def _get_task(self, cr, uid, ids, context=None):
721 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
722 if work.task_id: result[work.task_id.id] = True
726 '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."),
727 'name': fields.char('Task Summary', track_visibility='onchange', size=128, required=True, select=True),
728 'description': fields.text('Description'),
729 'priority': fields.selection([('0','Low'), ('1','Normal'), ('2','High')], 'Priority', select=True),
730 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
731 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange', select=True,
732 domain="[('project_ids', '=', project_id)]", copy=False),
733 'categ_ids': fields.many2many('project.category', string='Tags'),
734 'kanban_state': fields.selection([('normal', 'In Progress'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
735 track_visibility='onchange',
736 help="A task's kanban state indicates special situations affecting it:\n"
737 " * Normal is the default situation\n"
738 " * Blocked indicates something is preventing the progress of this task\n"
739 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
740 required=False, copy=False),
741 'create_date': fields.datetime('Create Date', readonly=True, select=True),
742 '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)
743 'date_start': fields.datetime('Starting Date', select=True, copy=False),
744 'date_end': fields.datetime('Ending Date', select=True, copy=False),
745 'date_deadline': fields.date('Deadline', select=True, copy=False),
746 'date_last_stage_update': fields.datetime('Last Stage Update', select=True, copy=False),
747 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select=True, track_visibility='onchange', change_default=True),
748 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
749 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
750 'notes': fields.text('Notes'),
751 '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.'),
752 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
754 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
755 'project.task.work': (_get_task, ['hours'], 10),
757 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
758 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
760 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
761 'project.task.work': (_get_task, ['hours'], 10),
763 '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",
765 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours', 'state', 'stage_id'], 10),
766 'project.task.work': (_get_task, ['hours'], 10),
768 '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.",
770 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
771 'project.task.work': (_get_task, ['hours'], 10),
773 'reviewer_id': fields.many2one('res.users', 'Reviewer', select=True, track_visibility='onchange'),
774 'user_id': fields.many2one('res.users', 'Assigned to', select=True, track_visibility='onchange'),
775 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
776 'partner_id': fields.many2one('res.partner', 'Customer'),
777 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
778 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
779 'company_id': fields.many2one('res.company', 'Company'),
780 'id': fields.integer('ID', readonly=True),
781 'color': fields.integer('Color Index'),
782 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
785 'stage_id': _get_default_stage_id,
786 'project_id': _get_default_project_id,
787 'date_last_stage_update': fields.datetime.now,
788 'kanban_state': 'normal',
793 'reviewer_id': lambda obj, cr, uid, ctx=None: uid,
794 'user_id': lambda obj, cr, uid, ctx=None: uid,
795 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
796 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
798 _order = "priority, sequence, date_start, name, id"
800 def _check_recursion(self, cr, uid, ids, context=None):
802 visited_branch = set()
804 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
810 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
811 if id in visited_branch: #Cycle
814 if id in visited_node: #Already tested don't work one more time for nothing
817 visited_branch.add(id)
820 #visit child using DFS
821 task = self.browse(cr, uid, id, context=context)
822 for child in task.child_ids:
823 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
827 visited_branch.remove(id)
830 def _check_dates(self, cr, uid, ids, context=None):
833 obj_task = self.browse(cr, uid, ids[0], context=context)
834 start = obj_task.date_start or False
835 end = obj_task.date_end or False
842 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
843 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
846 # Override view according to the company definition
847 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
848 users_obj = self.pool.get('res.users')
849 if context is None: context = {}
850 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
851 # this should be safe (no context passed to avoid side-effects)
852 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
853 tm = obj_tm and obj_tm.name or 'Hours'
855 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
857 if tm in ['Hours','Hour']:
860 eview = etree.fromstring(res['arch'])
862 def _check_rec(eview):
863 if eview.attrib.get('widget','') == 'float_time':
864 eview.set('widget','float')
871 res['arch'] = etree.tostring(eview)
873 for f in res['fields']:
874 if 'Hours' in res['fields'][f]['string']:
875 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
878 def get_empty_list_help(self, cr, uid, help, context=None):
879 context = dict(context or {})
880 context['empty_list_help_id'] = context.get('default_project_id')
881 context['empty_list_help_model'] = 'project.project'
882 context['empty_list_help_document_name'] = _("tasks")
883 return super(task, self).get_empty_list_help(cr, uid, help, context=context)
885 # ----------------------------------------
887 # ----------------------------------------
889 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
890 """ Override of the base.stage method
891 Parameter of the stage search taken from the lead:
892 - section_id: if set, stages must belong to this section or
893 be a default stage; if not set, stages must be default
896 if isinstance(cases, (int, long)):
897 cases = self.browse(cr, uid, cases, context=context)
898 # collect all section_ids
901 section_ids.append(section_id)
904 section_ids.append(task.project_id.id)
907 search_domain = [('|')] * (len(section_ids) - 1)
908 for section_id in section_ids:
909 search_domain.append(('project_ids', '=', section_id))
910 search_domain += list(domain)
911 # perform search, return the first found
912 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
917 def _check_child_task(self, cr, uid, ids, context=None):
920 tasks = self.browse(cr, uid, ids, context=context)
923 for child in task.child_ids:
924 if child.stage_id and not child.stage_id.fold:
925 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
928 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
929 attachment = self.pool.get('ir.attachment')
930 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
931 new_attachment_ids = []
932 for attachment_id in attachment_ids:
933 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
934 return new_attachment_ids
936 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
938 Delegate Task to another users.
940 if delegate_data is None:
942 assert delegate_data['user_id'], _("Delegated User should be specified")
944 for task in self.browse(cr, uid, ids, context=context):
945 delegated_task_id = self.copy(cr, uid, task.id, {
946 'name': delegate_data['name'],
947 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
948 'stage_id': delegate_data.get('stage_id') and delegate_data.get('stage_id')[0] or False,
949 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
950 'planned_hours': delegate_data['planned_hours'] or 0.0,
951 'parent_ids': [(6, 0, [task.id])],
952 'description': delegate_data['new_task_description'] or '',
956 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
957 newname = delegate_data['prefix'] or ''
959 'remaining_hours': delegate_data['planned_hours_me'],
960 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
963 delegated_tasks[task.id] = delegated_task_id
964 return delegated_tasks
966 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
967 for task in self.browse(cr, uid, ids, context=context):
968 if (task.stage_id and task.stage_id.sequence <= 1) or (task.planned_hours == 0.0):
969 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
970 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
973 def set_remaining_time_1(self, cr, uid, ids, context=None):
974 return self.set_remaining_time(cr, uid, ids, 1.0, context)
976 def set_remaining_time_2(self, cr, uid, ids, context=None):
977 return self.set_remaining_time(cr, uid, ids, 2.0, context)
979 def set_remaining_time_5(self, cr, uid, ids, context=None):
980 return self.set_remaining_time(cr, uid, ids, 5.0, context)
982 def set_remaining_time_10(self, cr, uid, ids, context=None):
983 return self.set_remaining_time(cr, uid, ids, 10.0, context)
985 def _store_history(self, cr, uid, ids, context=None):
986 for task in self.browse(cr, uid, ids, context=context):
987 self.pool.get('project.task.history').create(cr, uid, {
989 'remaining_hours': task.remaining_hours,
990 'planned_hours': task.planned_hours,
991 'kanban_state': task.kanban_state,
992 'type_id': task.stage_id.id,
993 'user_id': task.user_id.id
998 # ------------------------------------------------
1000 # ------------------------------------------------
1002 def create(self, cr, uid, vals, context=None):
1003 context = dict(context or {})
1006 if vals.get('project_id') and not context.get('default_project_id'):
1007 context['default_project_id'] = vals.get('project_id')
1008 # user_id change: update date_start
1009 if vals.get('user_id') and not vals.get('start_date'):
1010 vals['date_start'] = fields.datetime.now()
1012 # context: no_log, because subtype already handle this
1013 create_context = dict(context, mail_create_nolog=True)
1014 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1015 self._store_history(cr, uid, [task_id], context=context)
1018 def write(self, cr, uid, ids, vals, context=None):
1019 if isinstance(ids, (int, long)):
1022 # stage change: update date_last_stage_update
1023 if 'stage_id' in vals:
1024 vals['date_last_stage_update'] = fields.datetime.now()
1025 # user_id change: update date_start
1026 if vals.get('user_id') and 'date_start' not in vals:
1027 vals['date_start'] = fields.datetime.now()
1029 # Overridden to reset the kanban_state to normal whenever
1030 # the stage (stage_id) of the task changes.
1031 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1032 new_stage = vals.get('stage_id')
1033 vals_reset_kstate = dict(vals, kanban_state='normal')
1034 for t in self.browse(cr, uid, ids, context=context):
1035 write_vals = vals_reset_kstate if t.stage_id.id != new_stage else vals
1036 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1039 result = super(task, self).write(cr, uid, ids, vals, context=context)
1041 if any(item in vals for item in ['stage_id', 'remaining_hours', 'user_id', 'kanban_state']):
1042 self._store_history(cr, uid, ids, context=context)
1045 def unlink(self, cr, uid, ids, context=None):
1048 self._check_child_task(cr, uid, ids, context=context)
1049 res = super(task, self).unlink(cr, uid, ids, context)
1052 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1053 context = context or {}
1057 if task.stage_id and task.stage_id.fold:
1062 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1064 for t2 in task.parent_ids:
1065 start.append("up.Task_%s.end" % (t2.id,))
1069 ''' % (ident,','.join(start))
1074 ''' % (ident, 'User_'+str(task.user_id.id))
1079 # ---------------------------------------------------
1081 # ---------------------------------------------------
1083 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=None, context=None):
1084 if auto_follow_fields is None:
1085 auto_follow_fields = ['user_id', 'reviewer_id']
1086 return super(task, self)._message_get_auto_subscribe_fields(cr, uid, updated_fields, auto_follow_fields, context=context)
1088 def message_get_reply_to(self, cr, uid, ids, context=None):
1089 """ Override to get the reply_to of the parent project. """
1090 tasks = self.browse(cr, SUPERUSER_ID, ids, context=context)
1091 project_ids = set([task.project_id.id for task in tasks if task.project_id])
1092 aliases = self.pool['project.project'].message_get_reply_to(cr, uid, list(project_ids), context=context)
1093 return dict((task.id, aliases.get(task.project_id and task.project_id.id or 0, False)) for task in tasks)
1095 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1096 """ Override to updates the document according to the email. """
1097 if custom_values is None:
1100 'name': msg.get('subject'),
1101 'planned_hours': 0.0,
1103 defaults.update(custom_values)
1104 return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1106 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1107 """ Override to update the task according to the email. """
1108 if update_vals is None:
1111 'cost': 'planned_hours',
1113 for line in msg['body'].split('\n'):
1115 res = tools.command_re.match(line)
1117 match = res.group(1).lower()
1118 field = maps.get(match)
1121 update_vals[field] = float(res.group(2).lower())
1122 except (ValueError, TypeError):
1124 return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1126 class project_work(osv.osv):
1127 _name = "project.task.work"
1128 _description = "Project Task Work"
1130 'name': fields.char('Work summary'),
1131 'date': fields.datetime('Date', select="1"),
1132 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1133 'hours': fields.float('Time Spent'),
1134 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1135 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1139 'user_id': lambda obj, cr, uid, context: uid,
1140 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1143 _order = "date desc"
1144 def create(self, cr, uid, vals, context=None):
1145 if 'hours' in vals and (not vals['hours']):
1146 vals['hours'] = 0.00
1147 if 'task_id' in vals:
1148 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1149 self.pool.get('project.task').invalidate_cache(cr, uid, ['remaining_hours'], [vals['task_id']], context=context)
1150 return super(project_work,self).create(cr, uid, vals, context=context)
1152 def write(self, cr, uid, ids, vals, context=None):
1153 if 'hours' in vals and (not vals['hours']):
1154 vals['hours'] = 0.00
1156 task_obj = self.pool.get('project.task')
1157 for work in self.browse(cr, uid, ids, context=context):
1158 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))
1159 task_obj.invalidate_cache(cr, uid, ['remaining_hours'], [work.task_id.id], context=context)
1160 return super(project_work,self).write(cr, uid, ids, vals, context)
1162 def unlink(self, cr, uid, ids, context=None):
1163 task_obj = self.pool.get('project.task')
1164 for work in self.browse(cr, uid, ids):
1165 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1166 task_obj.invalidate_cache(cr, uid, ['remaining_hours'], [work.task_id.id], context=context)
1167 return super(project_work,self).unlink(cr, uid, ids, context=context)
1170 class account_analytic_account(osv.osv):
1171 _inherit = 'account.analytic.account'
1172 _description = 'Analytic Account'
1174 '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"),
1175 'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1178 def on_change_template(self, cr, uid, ids, template_id, date_start=False, context=None):
1179 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, date_start=date_start, context=context)
1180 if template_id and 'value' in res:
1181 template = self.browse(cr, uid, template_id, context=context)
1182 res['value']['use_tasks'] = template.use_tasks
1185 def _trigger_project_creation(self, cr, uid, vals, context=None):
1187 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.
1189 if context is None: context = {}
1190 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1192 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1194 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.
1196 project_pool = self.pool.get('project.project')
1197 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1198 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1200 'name': vals.get('name'),
1201 'analytic_account_id': analytic_account_id,
1202 'type': vals.get('type','contract'),
1204 return project_pool.create(cr, uid, project_values, context=context)
1207 def create(self, cr, uid, vals, context=None):
1210 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1211 vals['child_ids'] = []
1212 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1213 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1214 return analytic_account_id
1216 def write(self, cr, uid, ids, vals, context=None):
1217 if isinstance(ids, (int, long)):
1219 vals_for_project = vals.copy()
1220 for account in self.browse(cr, uid, ids, context=context):
1221 if not vals.get('name'):
1222 vals_for_project['name'] = account.name
1223 if not vals.get('type'):
1224 vals_for_project['type'] = account.type
1225 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1226 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1228 def unlink(self, cr, uid, ids, context=None):
1229 proj_ids = self.pool['project.project'].search(cr, uid, [('analytic_account_id', 'in', ids)])
1230 has_tasks = self.pool['project.task'].search(cr, uid, [('project_id', 'in', proj_ids)], count=True, context=context)
1232 raise osv.except_osv(_('Warning!'), _('Please remove existing tasks in the project linked to the accounts you want to delete.'))
1233 return super(account_analytic_account, self).unlink(cr, uid, ids, context=context)
1235 def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
1240 if context.get('current_model') == 'project.project':
1241 project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context)
1242 return self.name_get(cr, uid, project_ids, context=context)
1244 return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit)
1247 class project_project(osv.osv):
1248 _inherit = 'project.project'
1253 class project_task_history(osv.osv):
1255 Tasks History, used for cumulative flow charts (Lean/Agile)
1257 _name = 'project.task.history'
1258 _description = 'History of Tasks'
1259 _rec_name = 'task_id'
1262 def _get_date(self, cr, uid, ids, name, arg, context=None):
1264 for history in self.browse(cr, uid, ids, context=context):
1265 if history.type_id and history.type_id.fold:
1266 result[history.id] = history.date
1268 cr.execute('''select
1271 project_task_history
1275 order by id limit 1''', (history.task_id.id, history.id))
1277 result[history.id] = res and res[0] or False
1280 def _get_related_date(self, cr, uid, ids, context=None):
1282 for history in self.browse(cr, uid, ids, context=context):
1283 cr.execute('''select
1286 project_task_history
1290 order by id desc limit 1''', (history.task_id.id, history.id))
1293 result.append(res[0])
1297 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1298 'type_id': fields.many2one('project.task.type', 'Stage'),
1299 'kanban_state': fields.selection([('normal', 'Normal'), ('blocked', 'Blocked'), ('done', 'Ready for next stage')], 'Kanban State', required=False),
1300 'date': fields.date('Date', select=True),
1301 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1302 'project.task.history': (_get_related_date, None, 20)
1304 'remaining_hours': fields.float('Remaining Time', digits=(16, 2)),
1305 'planned_hours': fields.float('Planned Time', digits=(16, 2)),
1306 'user_id': fields.many2one('res.users', 'Responsible'),
1309 'date': fields.date.context_today,
1312 class project_task_history_cumulative(osv.osv):
1313 _name = 'project.task.history.cumulative'
1314 _table = 'project_task_history_cumulative'
1315 _inherit = 'project.task.history'
1319 'end_date': fields.date('End Date'),
1320 'project_id': fields.many2one('project.project', 'Project'),
1324 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1326 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1328 history.date::varchar||'-'||history.history_id::varchar AS id,
1329 history.date AS end_date,
1334 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1335 h.task_id, h.type_id, h.user_id, h.kanban_state,
1336 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1339 project_task_history AS h
1340 JOIN project_task AS t ON (h.task_id = t.id)
1346 class project_category(osv.osv):
1347 """ Category of project's task (or issue) """
1348 _name = "project.category"
1349 _description = "Category of project's task, issue, ..."
1351 'name': fields.char('Name', required=True, translate=True),
1353 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: