1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
23 from datetime import datetime, date
24 from dateutil import relativedelta
25 from lxml import etree
29 from openerp import SUPERUSER_ID
30 from openerp import tools
31 from openerp.addons.resource.faces import task as Task
32 from openerp.osv import fields, osv
33 from openerp.tools.translate import _
36 class project_task_type(osv.osv):
37 _name = 'project.task.type'
38 _description = 'Task Stage'
41 'name': fields.char('Stage Name', required=True, translate=True),
42 'description': fields.text('Description'),
43 'sequence': fields.integer('Sequence'),
44 'case_default': fields.boolean('Default for New Projects',
45 help="If you check this field, this stage will be proposed by default on each new project. It will not assign this stage to existing projects."),
46 'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
47 'legend_priority': fields.text(
48 'Priority Management Explanation', translate=True,
49 help='Explanation text to help users using the star and priority mechanism on stages or issues that are in this stage.'),
50 'legend_blocked': fields.char(
51 'Kanban Blocked Explanation', translate=True,
52 help='Override the default value displayed for the blocked state for kanban selection, when the task or issue is in that stage.'),
53 'legend_done': fields.char(
54 'Kanban Valid Explanation', translate=True,
55 help='Override the default value displayed for the done state for kanban selection, when the task or issue is in that stage.'),
56 'legend_normal': fields.char(
57 'Kanban Ongoing Explanation', translate=True,
58 help='Override the default value displayed for the normal state for kanban selection, when the task or issue is in that stage.'),
59 'fold': fields.boolean('Folded in Kanban View',
60 help='This stage is folded in the kanban view when'
61 'there are no records in that stage to display.'),
64 def _get_default_project_ids(self, cr, uid, ctx={}):
65 project_id = self.pool['project.task']._get_default_project_id(cr, uid, context=ctx)
72 'project_ids': _get_default_project_ids,
77 class project(osv.osv):
78 _name = "project.project"
79 _description = "Project"
80 _inherits = {'account.analytic.account': "analytic_account_id",
81 "mail.alias": "alias_id"}
82 _inherit = ['mail.thread', 'ir.needaction_mixin']
85 def _auto_init(self, cr, context=None):
86 """ Installation hook: aliases, project.project """
87 # create aliases for all projects and avoid constraint errors
88 alias_context = dict(context, alias_model_name='project.task')
89 return self.pool.get('mail.alias').migrate_to_alias(cr, self._name, self._table, super(project, self)._auto_init,
90 'project.task', self._columns['alias_id'], 'id', alias_prefix='project+', alias_defaults={'project_id':'id'}, context=alias_context)
92 def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
94 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
95 if context and context.get('user_preference'):
96 cr.execute("""SELECT project.id FROM project_project project
97 LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
98 LEFT JOIN project_user_rel rel ON rel.project_id = project.id
99 WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
100 return [(r[0]) for r in cr.fetchall()]
101 return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
102 context=context, count=count)
104 def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
105 partner_obj = self.pool.get('res.partner')
108 return {'value': val}
109 if 'pricelist_id' in self.fields_get(cr, uid, context=context):
110 pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
111 pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
112 val['pricelist_id'] = pricelist_id
113 return {'value': val}
115 def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
116 tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
117 project_ids = [task.project_id.id for task in tasks if task.project_id]
118 return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
120 def _get_project_and_parents(self, cr, uid, ids, context=None):
121 """ return the project ids and all their parent projects """
125 SELECT DISTINCT parent.id
126 FROM project_project project, project_project parent, account_analytic_account account
127 WHERE project.analytic_account_id = account.id
128 AND parent.analytic_account_id = account.parent_id
131 ids = [t[0] for t in cr.fetchall()]
135 def _get_project_and_children(self, cr, uid, ids, context=None):
136 """ retrieve all children projects of project ids;
137 return a dictionary mapping each project to its parent project (or None)
139 res = dict.fromkeys(ids, None)
142 SELECT project.id, parent.id
143 FROM project_project project, project_project parent, account_analytic_account account
144 WHERE project.analytic_account_id = account.id
145 AND parent.analytic_account_id = account.parent_id
148 dic = dict(cr.fetchall())
153 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
154 child_parent = self._get_project_and_children(cr, uid, ids, context)
155 # compute planned_hours, total_hours, effective_hours specific to each project
157 SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
158 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
160 LEFT JOIN project_task_type ON project_task.stage_id = project_task_type.id
161 WHERE project_task.project_id IN %s AND project_task_type.fold = False
163 """, (tuple(child_parent.keys()),))
164 # aggregate results into res
165 res = dict([(id, {'planned_hours':0.0, 'total_hours':0.0, 'effective_hours':0.0}) for id in ids])
166 for id, planned, total, effective in cr.fetchall():
167 # add the values specific to id to all parent projects of id in the result
170 res[id]['planned_hours'] += planned
171 res[id]['total_hours'] += total
172 res[id]['effective_hours'] += effective
173 id = child_parent[id]
174 # compute progress rates
176 if res[id]['total_hours']:
177 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
179 res[id]['progress_rate'] = 0.0
182 def unlink(self, cr, uid, ids, context=None):
184 mail_alias = self.pool.get('mail.alias')
185 analytic_account_to_delete = set()
186 for proj in self.browse(cr, uid, ids, context=context):
188 raise osv.except_osv(_('Invalid Action!'),
189 _('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.'))
191 alias_ids.append(proj.alias_id.id)
192 if proj.analytic_account_id and not proj.analytic_account_id.line_ids:
193 analytic_account_to_delete.add(proj.analytic_account_id.id)
194 res = super(project, self).unlink(cr, uid, ids, context=context)
195 mail_alias.unlink(cr, uid, alias_ids, context=context)
196 self.pool['account.analytic.account'].unlink(cr, uid, list(analytic_account_to_delete), context=context)
199 def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
201 attachment = self.pool.get('ir.attachment')
202 task = self.pool.get('project.task')
204 project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
205 task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
206 task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
207 res[id] = (project_attachments or 0) + (task_attachments or 0)
209 def _task_count(self, cr, uid, ids, field_name, arg, context=None):
211 for tasks in self.browse(cr, uid, ids, context):
212 res[tasks.id] = len(tasks.task_ids)
214 def _get_alias_models(self, cr, uid, context=None):
215 """ Overriden in project_issue to offer more options """
216 return [('project.task', "Tasks")]
218 def _get_visibility_selection(self, cr, uid, context=None):
219 """ Overriden in portal_project to offer more options """
220 return [('public', _('Public project')),
221 ('employees', _('Internal project: all employees can access')),
222 ('followers', _('Private project: followers Only'))]
224 def attachment_tree_view(self, cr, uid, ids, context):
225 task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
228 '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
229 '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)]
230 res_id = ids and ids[0] or False
232 'name': _('Attachments'),
234 'res_model': 'ir.attachment',
235 'type': 'ir.actions.act_window',
237 'view_mode': 'kanban,tree,form',
240 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
243 def __get_bar_values(self, cr, uid, obj, domain, read_fields, value_field, groupby_field, context=None):
244 """ Generic method to generate data for bar chart values using SparklineBarWidget.
245 This method performs obj.read_group(cr, uid, domain, read_fields, groupby_field).
247 :param obj: the target model (i.e. crm_lead)
248 :param domain: the domain applied to the read_group
249 :param list read_fields: the list of fields to read in the read_group
250 :param str value_field: the field used to compute the value of the bar slice
251 :param str groupby_field: the fields used to group
253 :return list section_result: a list of dicts: [
254 { 'value': (int) bar_column_value,
255 'tootip': (str) bar_column_tooltip,
259 month_begin = date.today().replace(day=1)
262 'tooltip': (month_begin + relativedelta.relativedelta(months=-i)).strftime('%B'),
263 } for i in range(self._period_number - 1, -1, -1)]
264 group_obj = obj.read_group(cr, uid, domain, read_fields, groupby_field, context=context)
265 pattern = tools.DEFAULT_SERVER_DATE_FORMAT if obj.fields_get(cr, uid, groupby_field)[groupby_field]['type'] == 'date' else tools.DEFAULT_SERVER_DATETIME_FORMAT
266 for group in group_obj:
267 group_begin_date = datetime.strptime(group['__domain'][0][2], pattern)
268 month_delta = relativedelta.relativedelta(month_begin, group_begin_date)
269 section_result[self._period_number - (month_delta.months + 1)] = {'value': group.get(value_field, 0), 'tooltip': group.get(groupby_field, 0)}
270 return section_result
272 def _get_project_task_data(self, cr, uid, ids, field_name, arg, context=None):
273 obj = self.pool['project.task']
274 month_begin = date.today().replace(day=1)
275 date_begin = (month_begin - relativedelta.relativedelta(months=self._period_number - 1)).strftime(tools.DEFAULT_SERVER_DATE_FORMAT)
276 date_end = month_begin.replace(day=calendar.monthrange(month_begin.year, month_begin.month)[1]).strftime(tools.DEFAULT_SERVER_DATE_FORMAT)
279 created_domain = [('project_id', '=', id), ('create_date', '>=', date_begin ), ('create_date', '<=', date_end ), ('stage_id.fold', '=', False)]
280 res[id] = json.dumps(self.__get_bar_values(cr, uid, obj, created_domain, [ 'create_date'], 'create_date_count', 'create_date', context=context))
283 # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
284 _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
285 _visibility_selection = lambda self, *args, **kwargs: self._get_visibility_selection(*args, **kwargs)
288 'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
289 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
290 'analytic_account_id': fields.many2one(
291 'account.analytic.account', 'Contract/Analytic',
292 help="Link this project to an analytic account if you need financial management on projects. "
293 "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.",
294 ondelete="cascade", required=True, auto_join=True),
295 'label_tasks': fields.char('Use Tasks as', help="Gives label to tasks on project's kanaban view."),
296 'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
297 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)]}),
298 'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
299 '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.",
301 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
302 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
304 '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.",
306 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
307 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
309 '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.",
311 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
312 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
314 '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.",
316 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
317 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'stage_id'], 20),
319 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
320 'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
321 'task_count': fields.function(_task_count, type='integer', string="Tasks",),
322 'task_ids': fields.one2many('project.task', 'project_id',
323 domain=[('stage_id.fold', '=', False)]),
324 'color': fields.integer('Color Index'),
325 'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="restrict", required=True,
326 help="Internal email associated with this project. Incoming emails are automatically synchronized"
327 "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
328 'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
329 help="The kind of document created when an email is received on this project's email alias"),
330 'privacy_visibility': fields.selection(_visibility_selection, 'Privacy / Visibility', required=True,
331 help="Holds visibility of the tasks or issues that belong to the current project:\n"
332 "- Public: everybody sees everything; if portal is activated, portal users\n"
333 " see all tasks or issues; if anonymous portal is activated, visitors\n"
334 " see all tasks or issues\n"
335 "- Portal (only available if Portal is installed): employees see everything;\n"
336 " if portal is activated, portal users see the tasks or issues followed by\n"
337 " them or by someone of their company\n"
338 "- Employees Only: employees see all tasks or issues\n"
339 "- Followers Only: employees see only the followed tasks or issues; if portal\n"
340 " is activated, portal users see the followed tasks or issues."),
341 'state': fields.selection([('template', 'Template'),
343 ('open','In Progress'),
344 ('cancelled', 'Cancelled'),
345 ('pending','Pending'),
347 'Status', required=True, copy=False),
348 'monthly_tasks': fields.function(_get_project_task_data, type='char', readonly=True,
349 string='Project Task By Month'),
350 'doc_count': fields.function(
351 _get_attached_docs, string="Number of documents attached", type='integer'
355 def _get_type_common(self, cr, uid, context):
356 ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
359 _order = "sequence, id"
363 'label_tasks': 'Tasks',
366 'type_ids': _get_type_common,
367 'alias_model': 'project.task',
368 'privacy_visibility': 'employees',
371 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
372 recipients = super(project, self).message_get_suggested_recipients(cr, uid, ids, context=context)
373 for data in self.browse(cr, uid, ids, context=context):
375 reason = _('Customer Email') if data.partner_id.email else _('Customer')
376 self._message_add_suggested_recipient(cr, uid, recipients, data, partner=data.partner_id, reason= '%s' % reason)
379 # TODO: Why not using a SQL contraints ?
380 def _check_dates(self, cr, uid, ids, context=None):
381 for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
382 if leave['date_start'] and leave['date']:
383 if leave['date_start'] > leave['date']:
388 (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
391 def set_template(self, cr, uid, ids, context=None):
392 return self.setActive(cr, uid, ids, value=False, context=context)
394 def set_done(self, cr, uid, ids, context=None):
395 return self.write(cr, uid, ids, {'state': 'close'}, context=context)
397 def set_cancel(self, cr, uid, ids, context=None):
398 return self.write(cr, uid, ids, {'state': 'cancelled'}, context=context)
400 def set_pending(self, cr, uid, ids, context=None):
401 return self.write(cr, uid, ids, {'state': 'pending'}, context=context)
403 def set_open(self, cr, uid, ids, context=None):
404 return self.write(cr, uid, ids, {'state': 'open'}, context=context)
406 def reset_project(self, cr, uid, ids, context=None):
407 return self.setActive(cr, uid, ids, value=True, context=context)
409 def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
410 """ copy and map tasks from old to new project """
414 task_obj = self.pool.get('project.task')
415 proj = self.browse(cr, uid, old_project_id, context=context)
416 for task in proj.tasks:
417 # preserve task name and stage, normally altered during copy
418 defaults = {'stage_id': task.stage_id.id,
420 map_task_id[task.id] = task_obj.copy(cr, uid, task.id, defaults, context=context)
421 self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
422 task_obj.duplicate_task(cr, uid, map_task_id, context=context)
425 def copy(self, cr, uid, id, default=None, context=None):
428 context = dict(context or {})
429 context['active_test'] = False
430 proj = self.browse(cr, uid, id, context=context)
431 if not default.get('name'):
432 default.update(name=_("%s (copy)") % (proj.name))
433 res = super(project, self).copy(cr, uid, id, default, context)
434 self.map_tasks(cr, uid, id, res, context=context)
437 def duplicate_template(self, cr, uid, ids, context=None):
438 context = dict(context or {})
439 data_obj = self.pool.get('ir.model.data')
441 for proj in self.browse(cr, uid, ids, context=context):
442 parent_id = context.get('parent_id', False)
443 context.update({'analytic_project_copy': True})
444 new_date_start = time.strftime('%Y-%m-%d')
446 if proj.date_start and proj.date:
447 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
448 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
449 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
450 context.update({'copy':True})
451 new_id = self.copy(cr, uid, proj.id, default = {
452 'name':_("%s (copy)") % (proj.name),
454 'date_start':new_date_start,
456 'parent_id':parent_id}, context=context)
457 result.append(new_id)
459 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
460 parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
462 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
464 if result and len(result):
466 form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
467 form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
468 tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
469 tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
470 search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
471 search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
473 'name': _('Projects'),
475 'view_mode': 'form,tree',
476 'res_model': 'project.project',
479 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
480 'type': 'ir.actions.act_window',
481 'search_view_id': search_view['res_id'],
485 # set active value for a project, its sub projects and its tasks
486 def setActive(self, cr, uid, ids, value=True, context=None):
487 task_obj = self.pool.get('project.task')
488 for proj in self.browse(cr, uid, ids, context=None):
489 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
490 cr.execute('select id from project_task where project_id=%s', (proj.id,))
491 tasks_id = [x[0] for x in cr.fetchall()]
493 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
494 child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
496 self.setActive(cr, uid, child_ids, value, context=None)
499 def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
500 context = context or {}
501 if type(ids) in (long, int,):
503 projects = self.browse(cr, uid, ids, context=context)
505 for project in projects:
506 if (not project.members) and force_members:
507 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
509 resource_pool = self.pool.get('resource.resource')
511 result = "from openerp.addons.resource.faces import *\n"
512 result += "import datetime\n"
513 for project in self.browse(cr, uid, ids, context=context):
514 u_ids = [i.id for i in project.members]
515 if project.user_id and (project.user_id.id not in u_ids):
516 u_ids.append(project.user_id.id)
517 for task in project.tasks:
518 if task.user_id and (task.user_id.id not in u_ids):
519 u_ids.append(task.user_id.id)
520 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
521 resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
522 for key, vals in resource_objs.items():
524 class User_%s(Resource):
526 ''' % (key, vals.get('efficiency', False))
533 def _schedule_project(self, cr, uid, project, context=None):
534 resource_pool = self.pool.get('resource.resource')
535 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
536 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
537 # TODO: check if we need working_..., default values are ok.
538 puids = [x.id for x in project.members]
540 puids.append(project.user_id.id)
548 project.date_start or time.strftime('%Y-%m-%d'), working_days,
549 '|'.join(['User_'+str(x) for x in puids]) or 'None'
551 vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
558 #TODO: DO Resource allocation and compute availability
559 def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
565 def schedule_tasks(self, cr, uid, ids, context=None):
566 context = context or {}
567 if type(ids) in (long, int,):
569 projects = self.browse(cr, uid, ids, context=context)
570 result = self._schedule_header(cr, uid, ids, False, context=context)
571 for project in projects:
572 result += self._schedule_project(cr, uid, project, context=context)
573 result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
576 exec result in local_dict
577 projects_gantt = Task.BalancedProject(local_dict['Project'])
579 for project in projects:
580 project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
581 for task in project.tasks:
582 if task.stage_id and task.stage_id.fold:
585 p = getattr(project_gantt, 'Task_%d' % (task.id,))
587 self.pool.get('project.task').write(cr, uid, [task.id], {
588 'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
589 'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
591 if (not task.user_id) and (p.booked_resource):
592 self.pool.get('project.task').write(cr, uid, [task.id], {
593 'user_id': int(p.booked_resource[0].name[5:]),
597 def create(self, cr, uid, vals, context=None):
600 # Prevent double project creation when 'use_tasks' is checked + alias management
601 create_context = dict(context, project_creation_in_progress=True,
602 alias_model_name=vals.get('alias_model', 'project.task'),
603 alias_parent_model_name=self._name)
605 if vals.get('type', False) not in ('template', 'contract'):
606 vals['type'] = 'contract'
608 ir_values = self.pool.get('ir.values').get_default(cr, uid, 'project.config.settings', 'generate_project_alias')
610 vals['alias_name'] = vals.get('alias_name') or vals.get('name')
611 project_id = super(project, self).create(cr, uid, vals, context=create_context)
612 project_rec = self.browse(cr, uid, project_id, context=context)
613 values = {'alias_parent_thread_id': project_id, 'alias_defaults': {'project_id': project_id}}
614 self.pool.get('mail.alias').write(cr, uid, [project_rec.alias_id.id], values, context=context)
617 def write(self, cr, uid, ids, vals, context=None):
618 # if alias_model has been changed, update alias_model_id accordingly
619 if vals.get('alias_model'):
620 model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
621 vals.update(alias_model_id=model_ids[0])
622 return super(project, self).write(cr, uid, ids, vals, context=context)
626 _name = "project.task"
627 _description = "Task"
628 _date_name = "date_start"
629 _inherit = ['mail.thread', 'ir.needaction_mixin']
631 _mail_post_access = 'read'
634 # this is only an heuristics; depending on your particular stage configuration it may not match all 'new' stages
635 'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.sequence <= 1,
636 'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj.stage_id.sequence > 1,
639 'project.mt_task_assigned': lambda self, cr, uid, obj, ctx=None: obj.user_id and obj.user_id.id,
642 'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj.kanban_state == 'blocked',
643 'project.mt_task_ready': lambda self, cr, uid, obj, ctx=None: obj.kanban_state == 'done',
647 def _get_default_partner(self, cr, uid, context=None):
648 project_id = self._get_default_project_id(cr, uid, context)
650 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
651 if project and project.partner_id:
652 return project.partner_id.id
655 def _get_default_project_id(self, cr, uid, context=None):
656 """ Gives default section by checking if present in the context """
657 return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
659 def _get_default_stage_id(self, cr, uid, context=None):
660 """ Gives default stage_id """
661 project_id = self._get_default_project_id(cr, uid, context=context)
662 return self.stage_find(cr, uid, [], project_id, [('fold', '=', False)], context=context)
664 def _resolve_project_id_from_context(self, cr, uid, context=None):
665 """ Returns ID of project based on the value of 'default_project_id'
666 context key, or None if it cannot be resolved to a single
671 if type(context.get('default_project_id')) in (int, long):
672 return context['default_project_id']
673 if isinstance(context.get('default_project_id'), basestring):
674 project_name = context['default_project_id']
675 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
676 if len(project_ids) == 1:
677 return project_ids[0][0]
680 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
681 stage_obj = self.pool.get('project.task.type')
682 order = stage_obj._order
683 access_rights_uid = access_rights_uid or uid
684 if read_group_order == 'stage_id desc':
685 order = '%s desc' % order
687 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
689 search_domain += ['|', ('project_ids', '=', project_id)]
690 search_domain += [('id', 'in', ids)]
691 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
692 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
693 # restore order of the search
694 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
697 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
698 fold[stage.id] = stage.fold or False
701 def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
702 res_users = self.pool.get('res.users')
703 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
704 access_rights_uid = access_rights_uid or uid
706 ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
707 order = res_users._order
708 # lame way to allow reverting search, should just work in the trivial case
709 if read_group_order == 'user_id desc':
710 order = '%s desc' % order
711 # de-duplicate and apply search order
712 ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
713 result = res_users.name_get(cr, access_rights_uid, ids, context=context)
714 # restore order of the search
715 result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
719 'stage_id': _read_group_stage_ids,
720 'user_id': _read_group_user_id,
723 def _str_get(self, task, level=0, border='***', context=None):
724 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'+ \
725 border[0]+' '+(task.name or '')+'\n'+ \
726 (task.description or '')+'\n\n'
728 # Compute: effective_hours, total_hours, progress
729 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
731 cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
732 hours = dict(cr.fetchall())
733 for task in self.browse(cr, uid, ids, context=context):
734 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)}
735 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
736 res[task.id]['progress'] = 0.0
737 if (task.remaining_hours + hours.get(task.id, 0.0)):
738 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
739 # TDE CHECK: if task.state in ('done','cancelled'):
740 if task.stage_id and task.stage_id.fold:
741 res[task.id]['progress'] = 100.0
744 def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
745 if remaining and not planned:
746 return {'value': {'planned_hours': remaining}}
749 def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
750 return {'value': {'remaining_hours': planned - effective}}
752 def onchange_project(self, cr, uid, id, project_id, context=None):
754 project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
755 if project and project.partner_id:
756 return {'value': {'partner_id': project.partner_id.id}}
759 def onchange_user_id(self, cr, uid, ids, user_id, context=None):
762 vals['date_start'] = fields.datetime.now()
763 return {'value': vals}
765 def duplicate_task(self, cr, uid, map_ids, context=None):
766 mapper = lambda t: map_ids.get(t.id, t.id)
767 for task in self.browse(cr, uid, map_ids.values(), context):
768 new_child_ids = set(map(mapper, task.child_ids))
769 new_parent_ids = set(map(mapper, task.parent_ids))
770 if new_child_ids or new_parent_ids:
771 task.write({'parent_ids': [(6,0,list(new_parent_ids))],
772 'child_ids': [(6,0,list(new_child_ids))]})
774 def copy_data(self, cr, uid, id, default=None, context=None):
777 if not default.get('name'):
778 current = self.browse(cr, uid, id, context=context)
779 default['name'] = _("%s (copy)") % current.name
780 return super(task, self).copy_data(cr, uid, id, default, context)
782 def _is_template(self, cr, uid, ids, field_name, arg, context=None):
784 for task in self.browse(cr, uid, ids, context=context):
787 if task.project_id.active == False or task.project_id.state == 'template':
791 def _get_task(self, cr, uid, ids, context=None):
793 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
794 if work.task_id: result[work.task_id.id] = True
798 '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."),
799 'name': fields.char('Task Summary', track_visibility='onchange', size=128, required=True, select=True),
800 'description': fields.html('Description'),
801 'priority': fields.selection([('0','Normal'), ('1','High')], 'Priority', select=True),
802 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
803 'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange', select=True,
804 domain="[('project_ids', '=', project_id)]", copy=False),
805 'categ_ids': fields.many2many('project.category', string='Tags'),
806 'kanban_state': fields.selection([('normal', 'In Progress'),('done', 'Ready for next stage'),('blocked', 'Blocked')], 'Kanban State',
807 track_visibility='onchange',
808 help="A task's kanban state indicates special situations affecting it:\n"
809 " * Normal is the default situation\n"
810 " * Blocked indicates something is preventing the progress of this task\n"
811 " * Ready for next stage indicates the task is ready to be pulled to the next stage",
812 required=False, copy=False),
813 'create_date': fields.datetime('Create Date', readonly=True, select=True),
814 '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)
815 'date_start': fields.datetime('Starting Date', select=True, copy=False),
816 'date_end': fields.datetime('Ending Date', select=True, copy=False),
817 'date_deadline': fields.date('Deadline', select=True, copy=False),
818 'date_last_stage_update': fields.datetime('Last Stage Update', select=True, copy=False),
819 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select=True, track_visibility='onchange', change_default=True),
820 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
821 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
822 'notes': fields.text('Notes'),
823 '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.'),
824 'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
826 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
827 'project.task.work': (_get_task, ['hours'], 10),
829 'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
830 'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
832 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
833 'project.task.work': (_get_task, ['hours'], 10),
835 '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",
837 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours', 'state', 'stage_id'], 10),
838 'project.task.work': (_get_task, ['hours'], 10),
840 '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.",
842 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
843 'project.task.work': (_get_task, ['hours'], 10),
845 'user_id': fields.many2one('res.users', 'Assigned to', select=True, track_visibility='onchange'),
846 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
847 'partner_id': fields.many2one('res.partner', 'Customer'),
848 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
849 'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
850 'company_id': fields.many2one('res.company', 'Company'),
851 'id': fields.integer('ID', readonly=True),
852 'color': fields.integer('Color Index'),
853 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
856 'stage_id': _get_default_stage_id,
857 'project_id': _get_default_project_id,
858 'date_last_stage_update': fields.datetime.now,
859 'kanban_state': 'normal',
864 'user_id': lambda obj, cr, uid, ctx=None: uid,
865 'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
866 'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
867 'date_start': fields.datetime.now,
869 _order = "priority desc, sequence, date_start, name, id"
871 def _check_recursion(self, cr, uid, ids, context=None):
873 visited_branch = set()
875 res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
881 def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
882 if id in visited_branch: #Cycle
885 if id in visited_node: #Already tested don't work one more time for nothing
888 visited_branch.add(id)
891 #visit child using DFS
892 task = self.browse(cr, uid, id, context=context)
893 for child in task.child_ids:
894 res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
898 visited_branch.remove(id)
901 def _check_dates(self, cr, uid, ids, context=None):
904 obj_task = self.browse(cr, uid, ids[0], context=context)
905 start = obj_task.date_start or False
906 end = obj_task.date_end or False
913 (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
914 (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
917 # Override view according to the company definition
918 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
919 users_obj = self.pool.get('res.users')
920 if context is None: context = {}
921 # read uom as admin to avoid access rights issues, e.g. for portal/share users,
922 # this should be safe (no context passed to avoid side-effects)
923 obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
924 tm = obj_tm and obj_tm.name or 'Hours'
926 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
928 if tm in ['Hours','Hour']:
931 eview = etree.fromstring(res['arch'])
933 def _check_rec(eview):
934 if eview.attrib.get('widget','') == 'float_time':
935 eview.set('widget','float')
942 res['arch'] = etree.tostring(eview)
944 for f in res['fields']:
945 if 'Hours' in res['fields'][f]['string']:
946 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
949 def get_empty_list_help(self, cr, uid, help, context=None):
950 context = dict(context or {})
951 context['empty_list_help_id'] = context.get('default_project_id')
952 context['empty_list_help_model'] = 'project.project'
953 context['empty_list_help_document_name'] = _("tasks")
954 return super(task, self).get_empty_list_help(cr, uid, help, context=context)
956 # ----------------------------------------
958 # ----------------------------------------
960 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
961 """ Override of the base.stage method
962 Parameter of the stage search taken from the lead:
963 - section_id: if set, stages must belong to this section or
964 be a default stage; if not set, stages must be default
967 if isinstance(cases, (int, long)):
968 cases = self.browse(cr, uid, cases, context=context)
969 # collect all section_ids
972 section_ids.append(section_id)
975 section_ids.append(task.project_id.id)
978 search_domain = [('|')] * (len(section_ids) - 1)
979 for section_id in section_ids:
980 search_domain.append(('project_ids', '=', section_id))
981 search_domain += list(domain)
982 # perform search, return the first found
983 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
988 def _check_child_task(self, cr, uid, ids, context=None):
991 tasks = self.browse(cr, uid, ids, context=context)
994 for child in task.child_ids:
995 if child.stage_id and not child.stage_id.fold:
996 raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
999 def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1000 attachment = self.pool.get('ir.attachment')
1001 attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1002 new_attachment_ids = []
1003 for attachment_id in attachment_ids:
1004 new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1005 return new_attachment_ids
1007 def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1009 Delegate Task to another users.
1011 if delegate_data is None:
1013 assert delegate_data['user_id'], _("Delegated User should be specified")
1014 delegated_tasks = {}
1015 for task in self.browse(cr, uid, ids, context=context):
1016 delegated_task_id = self.copy(cr, uid, task.id, {
1017 'name': delegate_data['name'],
1018 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1019 'stage_id': delegate_data.get('stage_id') and delegate_data.get('stage_id')[0] or False,
1020 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1021 'planned_hours': delegate_data['planned_hours'] or 0.0,
1022 'parent_ids': [(6, 0, [task.id])],
1023 'description': delegate_data['new_task_description'] or '',
1027 self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1028 newname = delegate_data['prefix'] or ''
1030 'remaining_hours': delegate_data['planned_hours_me'],
1031 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1034 delegated_tasks[task.id] = delegated_task_id
1035 return delegated_tasks
1037 def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1038 for task in self.browse(cr, uid, ids, context=context):
1039 if (task.stage_id and task.stage_id.sequence <= 1) or (task.planned_hours == 0.0):
1040 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1041 self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1044 def set_remaining_time_1(self, cr, uid, ids, context=None):
1045 return self.set_remaining_time(cr, uid, ids, 1.0, context)
1047 def set_remaining_time_2(self, cr, uid, ids, context=None):
1048 return self.set_remaining_time(cr, uid, ids, 2.0, context)
1050 def set_remaining_time_5(self, cr, uid, ids, context=None):
1051 return self.set_remaining_time(cr, uid, ids, 5.0, context)
1053 def set_remaining_time_10(self, cr, uid, ids, context=None):
1054 return self.set_remaining_time(cr, uid, ids, 10.0, context)
1056 def _store_history(self, cr, uid, ids, context=None):
1057 for task in self.browse(cr, uid, ids, context=context):
1058 self.pool.get('project.task.history').create(cr, uid, {
1060 'remaining_hours': task.remaining_hours,
1061 'planned_hours': task.planned_hours,
1062 'kanban_state': task.kanban_state,
1063 'type_id': task.stage_id.id,
1064 'user_id': task.user_id.id
1069 # ------------------------------------------------
1071 # ------------------------------------------------
1073 def create(self, cr, uid, vals, context=None):
1074 context = dict(context or {})
1077 if vals.get('project_id') and not context.get('default_project_id'):
1078 context['default_project_id'] = vals.get('project_id')
1079 # user_id change: update date_start
1080 if vals.get('user_id') and not vals.get('date_start'):
1081 vals['date_start'] = fields.datetime.now()
1083 # context: no_log, because subtype already handle this
1084 create_context = dict(context, mail_create_nolog=True)
1085 task_id = super(task, self).create(cr, uid, vals, context=create_context)
1086 self._store_history(cr, uid, [task_id], context=context)
1089 def write(self, cr, uid, ids, vals, context=None):
1090 if isinstance(ids, (int, long)):
1093 # stage change: update date_last_stage_update
1094 if 'stage_id' in vals:
1095 vals['date_last_stage_update'] = fields.datetime.now()
1096 # user_id change: update date_start
1097 if vals.get('user_id') and 'date_start' not in vals:
1098 vals['date_start'] = fields.datetime.now()
1100 # Overridden to reset the kanban_state to normal whenever
1101 # the stage (stage_id) of the task changes.
1102 if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1103 new_stage = vals.get('stage_id')
1104 vals_reset_kstate = dict(vals, kanban_state='normal')
1105 for t in self.browse(cr, uid, ids, context=context):
1106 write_vals = vals_reset_kstate if t.stage_id.id != new_stage else vals
1107 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1110 result = super(task, self).write(cr, uid, ids, vals, context=context)
1112 if any(item in vals for item in ['stage_id', 'remaining_hours', 'user_id', 'kanban_state']):
1113 self._store_history(cr, uid, ids, context=context)
1116 def unlink(self, cr, uid, ids, context=None):
1119 self._check_child_task(cr, uid, ids, context=context)
1120 res = super(task, self).unlink(cr, uid, ids, context)
1123 def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1124 context = context or {}
1128 if task.stage_id and task.stage_id.fold:
1133 %s effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1135 for t2 in task.parent_ids:
1136 start.append("up.Task_%s.end" % (t2.id,))
1140 ''' % (ident,','.join(start))
1145 ''' % (ident, 'User_'+str(task.user_id.id))
1150 # ---------------------------------------------------
1152 # ---------------------------------------------------
1154 def message_get_reply_to(self, cr, uid, ids, context=None):
1155 """ Override to get the reply_to of the parent project. """
1156 tasks = self.browse(cr, SUPERUSER_ID, ids, context=context)
1157 project_ids = set([task.project_id.id for task in tasks if task.project_id])
1158 aliases = self.pool['project.project'].message_get_reply_to(cr, uid, list(project_ids), context=context)
1159 return dict((task.id, aliases.get(task.project_id and task.project_id.id or 0, False)) for task in tasks)
1161 def message_new(self, cr, uid, msg, custom_values=None, context=None):
1162 """ Override to updates the document according to the email. """
1163 if custom_values is None:
1166 'name': msg.get('subject'),
1167 'planned_hours': 0.0,
1169 defaults.update(custom_values)
1170 res = super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1171 email_list = tools.email_split(msg.get('to', '') + ',' + msg.get('cc', ''))
1172 new_task = self.browse(cr, uid, res, context=context)
1173 if new_task.project_id and new_task.project_id.alias_name: # check left-part is not already an alias
1174 email_list = filter(lambda x: x.split('@')[0] != new_task.project_id.alias_name, email_list)
1175 partner_ids = filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, email_list, context=context, check_followers=False))
1176 self.message_subscribe(cr, uid, [res], partner_ids, context=context)
1179 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1180 """ Override to update the task according to the email. """
1181 if update_vals is None:
1184 'cost': 'planned_hours',
1186 for line in msg['body'].split('\n'):
1188 res = tools.command_re.match(line)
1190 match = res.group(1).lower()
1191 field = maps.get(match)
1194 update_vals[field] = float(res.group(2).lower())
1195 except (ValueError, TypeError):
1197 return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1199 class project_work(osv.osv):
1200 _name = "project.task.work"
1201 _description = "Project Task Work"
1203 'name': fields.char('Work summary'),
1204 'date': fields.datetime('Date', select="1"),
1205 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1206 'hours': fields.float('Time Spent'),
1207 'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1208 'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1212 'user_id': lambda obj, cr, uid, context: uid,
1213 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1216 _order = "date desc"
1217 def create(self, cr, uid, vals, context=None):
1218 if 'hours' in vals and (not vals['hours']):
1219 vals['hours'] = 0.00
1220 if 'task_id' in vals:
1221 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1222 self.pool.get('project.task').invalidate_cache(cr, uid, ['remaining_hours'], [vals['task_id']], context=context)
1223 return super(project_work,self).create(cr, uid, vals, context=context)
1225 def write(self, cr, uid, ids, vals, context=None):
1226 if 'hours' in vals and (not vals['hours']):
1227 vals['hours'] = 0.00
1229 task_obj = self.pool.get('project.task')
1230 for work in self.browse(cr, uid, ids, context=context):
1231 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))
1232 task_obj.invalidate_cache(cr, uid, ['remaining_hours'], [work.task_id.id], context=context)
1233 return super(project_work,self).write(cr, uid, ids, vals, context)
1235 def unlink(self, cr, uid, ids, context=None):
1236 task_obj = self.pool.get('project.task')
1237 for work in self.browse(cr, uid, ids):
1238 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1239 task_obj.invalidate_cache(cr, uid, ['remaining_hours'], [work.task_id.id], context=context)
1240 return super(project_work,self).unlink(cr, uid, ids, context=context)
1243 class account_analytic_account(osv.osv):
1244 _inherit = 'account.analytic.account'
1245 _description = 'Analytic Account'
1247 '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"),
1248 'company_uom_id': fields.related('company_id', 'project_time_mode_id', string="Company UOM", type='many2one', relation='product.uom'),
1251 def on_change_template(self, cr, uid, ids, template_id, date_start=False, context=None):
1252 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, date_start=date_start, context=context)
1253 if template_id and 'value' in res:
1254 template = self.browse(cr, uid, template_id, context=context)
1255 res['value']['use_tasks'] = template.use_tasks
1258 def _trigger_project_creation(self, cr, uid, vals, context=None):
1260 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.
1262 if context is None: context = {}
1263 return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1265 def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1267 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.
1269 project_pool = self.pool.get('project.project')
1270 project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1271 if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1273 'name': vals.get('name'),
1274 'analytic_account_id': analytic_account_id,
1275 'type': vals.get('type','contract'),
1277 return project_pool.create(cr, uid, project_values, context=context)
1280 def create(self, cr, uid, vals, context=None):
1283 if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1284 vals['child_ids'] = []
1285 analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1286 self.project_create(cr, uid, analytic_account_id, vals, context=context)
1287 return analytic_account_id
1289 def write(self, cr, uid, ids, vals, context=None):
1290 if isinstance(ids, (int, long)):
1292 vals_for_project = vals.copy()
1293 for account in self.browse(cr, uid, ids, context=context):
1294 if not vals.get('name'):
1295 vals_for_project['name'] = account.name
1296 if not vals.get('type'):
1297 vals_for_project['type'] = account.type
1298 self.project_create(cr, uid, account.id, vals_for_project, context=context)
1299 return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1301 def unlink(self, cr, uid, ids, context=None):
1302 proj_ids = self.pool['project.project'].search(cr, uid, [('analytic_account_id', 'in', ids)])
1303 has_tasks = self.pool['project.task'].search(cr, uid, [('project_id', 'in', proj_ids)], count=True, context=context)
1305 raise osv.except_osv(_('Warning!'), _('Please remove existing tasks in the project linked to the accounts you want to delete.'))
1306 return super(account_analytic_account, self).unlink(cr, uid, ids, context=context)
1308 def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
1313 if context.get('current_model') == 'project.project':
1314 project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context)
1315 return self.name_get(cr, uid, project_ids, context=context)
1317 return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit)
1320 class project_project(osv.osv):
1321 _inherit = 'project.project'
1326 class project_task_history(osv.osv):
1328 Tasks History, used for cumulative flow charts (Lean/Agile)
1330 _name = 'project.task.history'
1331 _description = 'History of Tasks'
1332 _rec_name = 'task_id'
1335 def _get_date(self, cr, uid, ids, name, arg, context=None):
1337 for history in self.browse(cr, uid, ids, context=context):
1338 if history.type_id and history.type_id.fold:
1339 result[history.id] = history.date
1341 cr.execute('''select
1344 project_task_history
1348 order by id limit 1''', (history.task_id.id, history.id))
1350 result[history.id] = res and res[0] or False
1353 def _get_related_date(self, cr, uid, ids, context=None):
1355 for history in self.browse(cr, uid, ids, context=context):
1356 cr.execute('''select
1359 project_task_history
1363 order by id desc limit 1''', (history.task_id.id, history.id))
1366 result.append(res[0])
1370 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1371 'type_id': fields.many2one('project.task.type', 'Stage'),
1372 'kanban_state': fields.selection([('normal', 'Normal'), ('blocked', 'Blocked'), ('done', 'Ready for next stage')], 'Kanban State', required=False),
1373 'date': fields.date('Date', select=True),
1374 'end_date': fields.function(_get_date, string='End Date', type="date", store={
1375 'project.task.history': (_get_related_date, None, 20)
1377 'remaining_hours': fields.float('Remaining Time', digits=(16, 2)),
1378 'planned_hours': fields.float('Planned Time', digits=(16, 2)),
1379 'user_id': fields.many2one('res.users', 'Responsible'),
1382 'date': fields.date.context_today,
1385 class project_task_history_cumulative(osv.osv):
1386 _name = 'project.task.history.cumulative'
1387 _table = 'project_task_history_cumulative'
1388 _inherit = 'project.task.history'
1392 'end_date': fields.date('End Date'),
1393 'nbr_tasks': fields.integer('# of Tasks', readonly=True),
1394 'project_id': fields.many2one('project.project', 'Project'),
1398 tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1400 cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1402 history.date::varchar||'-'||history.history_id::varchar AS id,
1403 history.date AS end_date,
1408 h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1409 h.task_id, h.type_id, h.user_id, h.kanban_state,
1410 count(h.task_id) as nbr_tasks,
1411 greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1414 project_task_history AS h
1415 JOIN project_task AS t ON (h.task_id = t.id)
1425 class project_category(osv.osv):
1426 """ Category of project's task (or issue) """
1427 _name = "project.category"
1428 _description = "Category of project's task, issue, ..."
1430 'name': fields.char('Name', required=True, translate=True),
1432 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: