[FIX] crm: at lead creation, take into account the type given in vals to find the...
[odoo/odoo.git] / addons / project / project.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
6 #
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.
11 #
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.
16 #
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/>.
19 #
20 ##############################################################################
21
22 from datetime import datetime, date
23 from lxml import etree
24 import time
25
26 from openerp import SUPERUSER_ID
27 from openerp import tools
28 from openerp.osv import fields, osv
29 from openerp.tools.translate import _
30
31 from openerp.addons.base_status.base_stage import base_stage
32 from openerp.addons.resource.faces import task as Task
33
34 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
35
36 class project_task_type(osv.osv):
37     _name = 'project.task.type'
38     _description = 'Task Stage'
39     _order = 'sequence'
40     _columns = {
41         'name': fields.char('Stage Name', required=True, size=64, translate=True),
42         'description': fields.text('Description'),
43         'sequence': fields.integer('Sequence'),
44         'case_default': fields.boolean('Default for New Projects',
45                         help="If you check this field, this stage will be proposed by default on each new project. It will not assign this stage to existing projects."),
46         'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
47         'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
48                         help="The status of your document is automatically changed regarding the selected stage. " \
49                             "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
50         'fold': fields.boolean('Folded by Default',
51                         help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
52     }
53     def _get_default_project_id(self, cr, uid, ctx={}):
54         proj = ctx.get('default_project_id', False)
55         if type(proj) is int:
56             return [proj]
57         return proj
58     _defaults = {
59         'sequence': 1,
60         'state': 'open',
61         'fold': False,
62         'case_default': False,
63         'project_ids': _get_default_project_id
64     }
65     _order = 'sequence'
66
67 def short_name(name):
68         """Keep first word(s) of name to make it small enough
69            but distinctive"""
70         if not name: return name
71         # keep 7 chars + end of the last word
72         keep_words = name[:7].strip().split()
73         return ' '.join(name.split()[:len(keep_words)])
74
75 class project(osv.osv):
76     _name = "project.project"
77     _description = "Project"
78     _inherits = {'account.analytic.account': "analytic_account_id",
79                  "mail.alias": "alias_id"}
80     _inherit = ['mail.thread', 'ir.needaction_mixin']
81
82     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
83         if user == 1:
84             return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
85         if context and context.get('user_preference'):
86                 cr.execute("""SELECT project.id FROM project_project project
87                            LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
88                            LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
89                            WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
90                 return [(r[0]) for r in cr.fetchall()]
91         return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
92             context=context, count=count)
93
94     def _complete_name(self, cr, uid, ids, name, args, context=None):
95         res = {}
96         for m in self.browse(cr, uid, ids, context=context):
97             res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
98         return res
99
100     def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
101         partner_obj = self.pool.get('res.partner')
102         if not part:
103             return {'value':{}}
104         val = {}
105         if 'pricelist_id' in self.fields_get(cr, uid, context=context):
106             pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
107             pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
108             val['pricelist_id'] = pricelist_id
109         return {'value': val}
110
111     def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
112         tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
113         project_ids = [task.project_id.id for task in tasks if task.project_id]
114         return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
115
116     def _get_project_and_parents(self, cr, uid, ids, context=None):
117         """ return the project ids and all their parent projects """
118         res = set(ids)
119         while ids:
120             cr.execute("""
121                 SELECT DISTINCT parent.id
122                 FROM project_project project, project_project parent, account_analytic_account account
123                 WHERE project.analytic_account_id = account.id
124                 AND parent.analytic_account_id = account.parent_id
125                 AND project.id IN %s
126                 """, (tuple(ids),))
127             ids = [t[0] for t in cr.fetchall()]
128             res.update(ids)
129         return list(res)
130
131     def _get_project_and_children(self, cr, uid, ids, context=None):
132         """ retrieve all children projects of project ids;
133             return a dictionary mapping each project to its parent project (or None)
134         """
135         res = dict.fromkeys(ids, None)
136         while ids:
137             cr.execute("""
138                 SELECT project.id, parent.id
139                 FROM project_project project, project_project parent, account_analytic_account account
140                 WHERE project.analytic_account_id = account.id
141                 AND parent.analytic_account_id = account.parent_id
142                 AND parent.id IN %s
143                 """, (tuple(ids),))
144             dic = dict(cr.fetchall())
145             res.update(dic)
146             ids = dic.keys()
147         return res
148
149     def _progress_rate(self, cr, uid, ids, names, arg, context=None):
150         child_parent = self._get_project_and_children(cr, uid, ids, context)
151         # compute planned_hours, total_hours, effective_hours specific to each project
152         cr.execute("""
153             SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
154                 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
155             FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
156             GROUP BY project_id
157             """, (tuple(child_parent.keys()),))
158         # aggregate results into res
159         res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
160         for id, planned, total, effective in cr.fetchall():
161             # add the values specific to id to all parent projects of id in the result
162             while id:
163                 if id in ids:
164                     res[id]['planned_hours'] += planned
165                     res[id]['total_hours'] += total
166                     res[id]['effective_hours'] += effective
167                 id = child_parent[id]
168         # compute progress rates
169         for id in ids:
170             if res[id]['total_hours']:
171                 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
172             else:
173                 res[id]['progress_rate'] = 0.0
174         return res
175
176     def unlink(self, cr, uid, ids, context=None):
177         alias_ids = []
178         mail_alias = self.pool.get('mail.alias')
179         for proj in self.browse(cr, uid, ids, context=context):
180             if proj.tasks:
181                 raise osv.except_osv(_('Invalid Action!'),
182                                      _('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.'))
183             elif proj.alias_id:
184                 alias_ids.append(proj.alias_id.id)
185         res =  super(project, self).unlink(cr, uid, ids, context=context)
186         mail_alias.unlink(cr, uid, alias_ids, context=context)
187         return res
188     
189     def _get_attached_docs(self, cr, uid, ids, field_name, arg, context):
190         res = {}
191         attachment = self.pool.get('ir.attachment')
192         task = self.pool.get('project.task')
193         for id in ids:
194             project_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', id)], context=context, count=True)
195             task_ids = task.search(cr, uid, [('project_id', '=', id)], context=context)
196             task_attachments = attachment.search(cr, uid, [('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)], context=context, count=True)
197             res[id] = (project_attachments or 0) + (task_attachments or 0)
198         return res
199         
200     def _task_count(self, cr, uid, ids, field_name, arg, context=None):
201         res = dict.fromkeys(ids, 0)
202         task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
203         for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
204             res[task.project_id.id] += 1
205         return res
206
207     def _get_alias_models(self, cr, uid, context=None):
208         """Overriden in project_issue to offer more options"""
209         return [('project.task', "Tasks")]
210
211     def attachment_tree_view(self, cr, uid, ids, context):
212         task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
213         domain = [
214              '|', 
215              '&', ('res_model', '=', 'project.project'), ('res_id', 'in', ids),
216              '&', ('res_model', '=', 'project.task'), ('res_id', 'in', task_ids)
217                 ]
218         res_id = ids and ids[0] or False
219         return {
220             'name': _('Attachments'),
221             'domain': domain,
222             'res_model': 'ir.attachment',
223             'type': 'ir.actions.act_window',
224             'view_id': False,
225             'view_mode': 'tree,form',
226             'view_type': 'form',
227             'limit': 80,
228             'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, res_id)
229         }
230     # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
231     _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
232     _columns = {
233         'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
234         'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
235         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
236         '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),
237         'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
238         'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
239             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)]}),
240         'tasks': fields.one2many('project.task', 'project_id', "Task Activities"),
241         '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.",
242             store = {
243                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
244                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
245             }),
246         '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.",
247             store = {
248                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
249                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
250             }),
251         '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.",
252             store = {
253                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
254                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
255             }),
256         '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.",
257             store = {
258                 'project.project': (_get_project_and_parents, ['tasks', 'parent_id', 'child_ids'], 10),
259                 'project.task': (_get_projects_from_tasks, ['planned_hours', 'remaining_hours', 'work_ids', 'state'], 20),
260             }),
261         'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
262         'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
263         'task_count': fields.function(_task_count, type='integer', string="Open Tasks"),
264         'color': fields.integer('Color Index'),
265         'alias_id': fields.many2one('mail.alias', 'Alias', ondelete="cascade", required=True,
266                                     help="Internal email associated with this project. Incoming emails are automatically synchronized"
267                                          "with Tasks (or optionally Issues if the Issue Tracker module is installed)."),
268         'alias_model': fields.selection(_alias_models, "Alias Model", select=True, required=True,
269                                         help="The kind of document created when an email is received on this project's email alias"),
270         'privacy_visibility': fields.selection([('public','Public'), ('followers','Followers Only')], 'Privacy / Visibility', required=True),
271         'state': fields.selection([('template', 'Template'),('draft','New'),('open','In Progress'), ('cancelled', 'Cancelled'),('pending','Pending'),('close','Closed')], 'Status', required=True,),
272         'doc_count':fields.function(_get_attached_docs, string="Number of documents attached", type='int')
273      }
274
275     def _get_type_common(self, cr, uid, context):
276         ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
277         return ids
278
279     _order = "sequence, id"
280     _defaults = {
281         'active': True,
282         'type': 'contract',
283         'state': 'open',
284         'priority': 1,
285         'sequence': 10,
286         'type_ids': _get_type_common,
287         'alias_model': 'project.task',
288         'privacy_visibility': 'public',
289         'alias_domain': False, # always hide alias during creation
290     }
291
292     # TODO: Why not using a SQL contraints ?
293     def _check_dates(self, cr, uid, ids, context=None):
294         for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
295             if leave['date_start'] and leave['date']:
296                 if leave['date_start'] > leave['date']:
297                     return False
298         return True
299
300     _constraints = [
301         (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
302     ]
303
304     def set_template(self, cr, uid, ids, context=None):
305         res = self.setActive(cr, uid, ids, value=False, context=context)
306         return res
307
308     def set_done(self, cr, uid, ids, context=None):
309         task_obj = self.pool.get('project.task')
310         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
311         task_obj.case_close(cr, uid, task_ids, context=context)
312         return self.write(cr, uid, ids, {'state':'close'}, context=context)
313
314     def set_cancel(self, cr, uid, ids, context=None):
315         task_obj = self.pool.get('project.task')
316         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
317         task_obj.case_cancel(cr, uid, task_ids, context=context)
318         return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
319
320     def set_pending(self, cr, uid, ids, context=None):
321         return self.write(cr, uid, ids, {'state':'pending'}, context=context)
322
323     def set_open(self, cr, uid, ids, context=None):
324         return self.write(cr, uid, ids, {'state':'open'}, context=context)
325
326     def reset_project(self, cr, uid, ids, context=None):
327         return self.setActive(cr, uid, ids, value=True, context=context)
328
329     def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
330         """ copy and map tasks from old to new project """
331         if context is None:
332             context = {}
333         map_task_id = {}
334         task_obj = self.pool.get('project.task')
335         proj = self.browse(cr, uid, old_project_id, context=context)
336         for task in proj.tasks:
337             map_task_id[task.id] =  task_obj.copy(cr, uid, task.id, {}, context=context)
338         self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
339         task_obj.duplicate_task(cr, uid, map_task_id, context=context)
340         return True
341
342     def copy(self, cr, uid, id, default=None, context=None):
343         if context is None:
344             context = {}
345         if default is None:
346             default = {}
347
348         context['active_test'] = False
349         default['state'] = 'open'
350         default['line_ids'] = []
351         default['tasks'] = []
352         default.pop('alias_name', None)
353         default.pop('alias_id', None)
354         proj = self.browse(cr, uid, id, context=context)
355         if not default.get('name', False):
356             default.update(name=_("%s (copy)") % (proj.name))
357         res = super(project, self).copy(cr, uid, id, default, context)
358         self.map_tasks(cr,uid,id,res,context)
359         return res
360
361     def duplicate_template(self, cr, uid, ids, context=None):
362         if context is None:
363             context = {}
364         data_obj = self.pool.get('ir.model.data')
365         result = []
366         for proj in self.browse(cr, uid, ids, context=context):
367             parent_id = context.get('parent_id', False)
368             context.update({'analytic_project_copy': True})
369             new_date_start = time.strftime('%Y-%m-%d')
370             new_date_end = False
371             if proj.date_start and proj.date:
372                 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
373                 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
374                 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
375             context.update({'copy':True})
376             new_id = self.copy(cr, uid, proj.id, default = {
377                                     'name':_("%s (copy)") % (proj.name),
378                                     'state':'open',
379                                     'date_start':new_date_start,
380                                     'date':new_date_end,
381                                     'parent_id':parent_id}, context=context)
382             result.append(new_id)
383
384             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
385             parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
386             if child_ids:
387                 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
388
389         if result and len(result):
390             res_id = result[0]
391             form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
392             form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
393             tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
394             tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
395             search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
396             search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
397             return {
398                 'name': _('Projects'),
399                 'view_type': 'form',
400                 'view_mode': 'form,tree',
401                 'res_model': 'project.project',
402                 'view_id': False,
403                 'res_id': res_id,
404                 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
405                 'type': 'ir.actions.act_window',
406                 'search_view_id': search_view['res_id'],
407                 'nodestroy': True
408             }
409
410     # set active value for a project, its sub projects and its tasks
411     def setActive(self, cr, uid, ids, value=True, context=None):
412         task_obj = self.pool.get('project.task')
413         for proj in self.browse(cr, uid, ids, context=None):
414             self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
415             cr.execute('select id from project_task where project_id=%s', (proj.id,))
416             tasks_id = [x[0] for x in cr.fetchall()]
417             if tasks_id:
418                 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
419             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
420             if child_ids:
421                 self.setActive(cr, uid, child_ids, value, context=None)
422         return True
423
424     def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
425         context = context or {}
426         if type(ids) in (long, int,):
427             ids = [ids]
428         projects = self.browse(cr, uid, ids, context=context)
429
430         for project in projects:
431             if (not project.members) and force_members:
432                 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
433
434         resource_pool = self.pool.get('resource.resource')
435
436         result = "from openerp.addons.resource.faces import *\n"
437         result += "import datetime\n"
438         for project in self.browse(cr, uid, ids, context=context):
439             u_ids = [i.id for i in project.members]
440             if project.user_id and (project.user_id.id not in u_ids):
441                 u_ids.append(project.user_id.id)
442             for task in project.tasks:
443                 if task.state in ('done','cancelled'):
444                     continue
445                 if task.user_id and (task.user_id.id not in u_ids):
446                     u_ids.append(task.user_id.id)
447             calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
448             resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
449             for key, vals in resource_objs.items():
450                 result +='''
451 class User_%s(Resource):
452     efficiency = %s
453 ''' % (key,  vals.get('efficiency', False))
454
455         result += '''
456 def Project():
457         '''
458         return result
459
460     def _schedule_project(self, cr, uid, project, context=None):
461         resource_pool = self.pool.get('resource.resource')
462         calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
463         working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
464         # TODO: check if we need working_..., default values are ok.
465         puids = [x.id for x in project.members]
466         if project.user_id:
467             puids.append(project.user_id.id)
468         result = """
469   def Project_%d():
470     start = \'%s\'
471     working_days = %s
472     resource = %s
473 """       % (
474             project.id,
475             project.date_start or time.strftime('%Y-%m-%d'), working_days,
476             '|'.join(['User_'+str(x) for x in puids])
477         )
478         vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
479         if vacation:
480             result+= """
481     vacation = %s
482 """ %   ( vacation, )
483         return result
484
485     #TODO: DO Resource allocation and compute availability
486     def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
487         if context ==  None:
488             context = {}
489         allocation = {}
490         return allocation
491
492     def schedule_tasks(self, cr, uid, ids, context=None):
493         context = context or {}
494         if type(ids) in (long, int,):
495             ids = [ids]
496         projects = self.browse(cr, uid, ids, context=context)
497         result = self._schedule_header(cr, uid, ids, False, context=context)
498         for project in projects:
499             result += self._schedule_project(cr, uid, project, context=context)
500             result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
501
502         local_dict = {}
503         exec result in local_dict
504         projects_gantt = Task.BalancedProject(local_dict['Project'])
505
506         for project in projects:
507             project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
508             for task in project.tasks:
509                 if task.state in ('done','cancelled'):
510                     continue
511
512                 p = getattr(project_gantt, 'Task_%d' % (task.id,))
513
514                 self.pool.get('project.task').write(cr, uid, [task.id], {
515                     'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
516                     'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
517                 }, context=context)
518                 if (not task.user_id) and (p.booked_resource):
519                     self.pool.get('project.task').write(cr, uid, [task.id], {
520                         'user_id': int(p.booked_resource[0].name[5:]),
521                     }, context=context)
522         return True
523
524     # ------------------------------------------------
525     # OpenChatter methods and notifications
526     # ------------------------------------------------
527
528     def create(self, cr, uid, vals, context=None):
529         if context is None: context = {}
530         # Prevent double project creation when 'use_tasks' is checked!
531         context = dict(context, project_creation_in_progress=True)
532         mail_alias = self.pool.get('mail.alias')
533         if not vals.get('alias_id') and vals.get('name', False):
534             vals.pop('alias_name', None) # prevent errors during copy()
535             alias_id = mail_alias.create_unique_alias(cr, uid,
536                           # Using '+' allows using subaddressing for those who don't
537                           # have a catchall domain setup.
538                           {'alias_name': "project+"+short_name(vals['name'])},
539                           model_name=vals.get('alias_model', 'project.task'),
540                           context=context)
541             vals['alias_id'] = alias_id
542         if vals.get('type', False) not in ('template','contract'):
543             vals['type'] = 'contract'
544         project_id = super(project, self).create(cr, uid, vals, context)
545         mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
546         return project_id
547
548     def write(self, cr, uid, ids, vals, context=None):
549         # if alias_model has been changed, update alias_model_id accordingly
550         if vals.get('alias_model'):
551             model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
552             vals.update(alias_model_id=model_ids[0])
553         return super(project, self).write(cr, uid, ids, vals, context=context)
554
555 class task(base_stage, osv.osv):
556     _name = "project.task"
557     _description = "Task"
558     _date_name = "date_start"
559     _inherit = ['mail.thread', 'ir.needaction_mixin']
560
561     _track = {
562         'state': {
563             'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'new',
564             'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
565             'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
566         },
567         'stage_id': {
568             'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'done', 'open'],
569         },
570         'kanban_state': {  # kanban state: tracked, but only block subtype
571             'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
572         },
573     }
574
575     def _get_default_project_id(self, cr, uid, context=None):
576         """ Gives default section by checking if present in the context """
577         return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
578
579     def _get_default_stage_id(self, cr, uid, context=None):
580         """ Gives default stage_id """
581         project_id = self._get_default_project_id(cr, uid, context=context)
582         return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
583
584     def _resolve_project_id_from_context(self, cr, uid, context=None):
585         """ Returns ID of project based on the value of 'default_project_id'
586             context key, or None if it cannot be resolved to a single
587             project.
588         """
589         if context is None: context = {}
590         if type(context.get('default_project_id')) in (int, long):
591             return context['default_project_id']
592         if isinstance(context.get('default_project_id'), basestring):
593             project_name = context['default_project_id']
594             project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
595             if len(project_ids) == 1:
596                 return project_ids[0][0]
597         return None
598
599     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
600         stage_obj = self.pool.get('project.task.type')
601         order = stage_obj._order
602         access_rights_uid = access_rights_uid or uid
603         if read_group_order == 'stage_id desc':
604             order = '%s desc' % order
605         search_domain = []
606         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
607         if project_id:
608             search_domain += ['|', ('project_ids', '=', project_id)]
609         search_domain += [('id', 'in', ids)]
610         stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
611         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
612         # restore order of the search
613         result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
614
615         fold = {}
616         for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
617             fold[stage.id] = stage.fold or False
618         return result, fold
619
620     def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
621         res_users = self.pool.get('res.users')
622         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
623         access_rights_uid = access_rights_uid or uid
624         if project_id:
625             ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
626             order = res_users._order
627             # lame way to allow reverting search, should just work in the trivial case
628             if read_group_order == 'user_id desc':
629                 order = '%s desc' % order
630             # de-duplicate and apply search order
631             ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
632         result = res_users.name_get(cr, access_rights_uid, ids, context=context)
633         # restore order of the search
634         result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
635         return result, {}
636
637     _group_by_full = {
638         'stage_id': _read_group_stage_ids,
639         'user_id': _read_group_user_id,
640     }
641
642     def _str_get(self, task, level=0, border='***', context=None):
643         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'+ \
644             border[0]+' '+(task.name or '')+'\n'+ \
645             (task.description or '')+'\n\n'
646
647     # Compute: effective_hours, total_hours, progress
648     def _hours_get(self, cr, uid, ids, field_names, args, context=None):
649         res = {}
650         cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
651         hours = dict(cr.fetchall())
652         for task in self.browse(cr, uid, ids, context=context):
653             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)}
654             res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
655             res[task.id]['progress'] = 0.0
656             if (task.remaining_hours + hours.get(task.id, 0.0)):
657                 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
658             if task.state in ('done','cancelled'):
659                 res[task.id]['progress'] = 100.0
660         return res
661
662     def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
663         if remaining and not planned:
664             return {'value':{'planned_hours': remaining}}
665         return {}
666
667     def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
668         return {'value':{'remaining_hours': planned - effective}}
669
670     def onchange_project(self, cr, uid, id, project_id):
671         if not project_id:
672             return {}
673         data = self.pool.get('project.project').browse(cr, uid, [project_id])
674         partner_id=data and data[0].partner_id
675         if partner_id:
676             return {'value':{'partner_id':partner_id.id}}
677         return {}
678
679     def duplicate_task(self, cr, uid, map_ids, context=None):
680         for new in map_ids.values():
681             task = self.browse(cr, uid, new, context)
682             child_ids = [ ch.id for ch in task.child_ids]
683             if task.child_ids:
684                 for child in task.child_ids:
685                     if child.id in map_ids.keys():
686                         child_ids.remove(child.id)
687                         child_ids.append(map_ids[child.id])
688
689             parent_ids = [ ch.id for ch in task.parent_ids]
690             if task.parent_ids:
691                 for parent in task.parent_ids:
692                     if parent.id in map_ids.keys():
693                         parent_ids.remove(parent.id)
694                         parent_ids.append(map_ids[parent.id])
695             #FIXME why there is already the copy and the old one
696             self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
697
698     def copy_data(self, cr, uid, id, default=None, context=None):
699         if default is None:
700             default = {}
701         default = default or {}
702         default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
703         if not default.get('remaining_hours', False):
704             default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
705         default['active'] = True
706         if not default.get('name', False):
707             default['name'] = self.browse(cr, uid, id, context=context).name or ''
708             if not context.get('copy',False):
709                 new_name = _("%s (copy)") % (default.get('name', ''))
710                 default.update({'name':new_name})
711         return super(task, self).copy_data(cr, uid, id, default, context)
712
713     def _is_template(self, cr, uid, ids, field_name, arg, context=None):
714         res = {}
715         for task in self.browse(cr, uid, ids, context=context):
716             res[task.id] = True
717             if task.project_id:
718                 if task.project_id.active == False or task.project_id.state == 'template':
719                     res[task.id] = False
720         return res
721
722     def _get_task(self, cr, uid, ids, context=None):
723         result = {}
724         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
725             if work.task_id: result[work.task_id.id] = True
726         return result.keys()
727
728     _columns = {
729         '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."),
730         'name': fields.char('Task Summary', size=128, required=True, select=True),
731         'description': fields.text('Description'),
732         'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
733         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
734         'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange',
735                         domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
736         'state': fields.related('stage_id', 'state', type="selection", store=True,
737                 selection=_TASK_STATE, string="Status", readonly=True,
738                 help='The status is set to \'Draft\', when a case is created.\
739                       If the case is in progress the status is set to \'Open\'.\
740                       When the case is over, the status is set to \'Done\'.\
741                       If the case needs to be reviewed then the status is \
742                       set to \'Pending\'.'),
743         'categ_ids': fields.many2many('project.category', string='Tags'),
744         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
745                                          track_visibility='onchange',
746                                          help="A task's kanban state indicates special situations affecting it:\n"
747                                               " * Normal is the default situation\n"
748                                               " * Blocked indicates something is preventing the progress of this task\n"
749                                               " * Ready for next stage indicates the task is ready to be pulled to the next stage",
750                                          readonly=True, required=False),
751         'create_date': fields.datetime('Create Date', readonly=True,select=True),
752         'date_start': fields.datetime('Starting Date',select=True),
753         'date_end': fields.datetime('Ending Date',select=True),
754         'date_deadline': fields.date('Deadline',select=True),
755         'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", track_visibility='onchange'),
756         'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
757         'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
758         'notes': fields.text('Notes'),
759         '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.'),
760         'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
761             store = {
762                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
763                 'project.task.work': (_get_task, ['hours'], 10),
764             }),
765         'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
766         'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
767             store = {
768                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
769                 'project.task.work': (_get_task, ['hours'], 10),
770             }),
771         'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="If the task has a progress of 99.99% you should close the task if it's finished or reevaluate the time",
772             store = {
773                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
774                 'project.task.work': (_get_task, ['hours'], 10),
775             }),
776         '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.",
777             store = {
778                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
779                 'project.task.work': (_get_task, ['hours'], 10),
780             }),
781         'user_id': fields.many2one('res.users', 'Assigned to', track_visibility='onchange'),
782         'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
783         'partner_id': fields.many2one('res.partner', 'Customer'),
784         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
785         'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
786         'company_id': fields.many2one('res.company', 'Company'),
787         'id': fields.integer('ID', readonly=True),
788         'color': fields.integer('Color Index'),
789         'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
790     }
791     _defaults = {
792         'stage_id': _get_default_stage_id,
793         'project_id': _get_default_project_id,
794         'kanban_state': 'normal',
795         'priority': '2',
796         'progress': 0,
797         'sequence': 10,
798         'active': True,
799         'user_id': lambda obj, cr, uid, context: uid,
800         'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
801     }
802     _order = "priority, sequence, date_start, name, id"
803
804     def set_high_priority(self, cr, uid, ids, *args):
805         """Set task priority to high
806         """
807         return self.write(cr, uid, ids, {'priority' : '0'})
808
809     def set_normal_priority(self, cr, uid, ids, *args):
810         """Set task priority to normal
811         """
812         return self.write(cr, uid, ids, {'priority' : '2'})
813
814     def _check_recursion(self, cr, uid, ids, context=None):
815         for id in ids:
816             visited_branch = set()
817             visited_node = set()
818             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
819             if not res:
820                 return False
821
822         return True
823
824     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
825         if id in visited_branch: #Cycle
826             return False
827
828         if id in visited_node: #Already tested don't work one more time for nothing
829             return True
830
831         visited_branch.add(id)
832         visited_node.add(id)
833
834         #visit child using DFS
835         task = self.browse(cr, uid, id, context=context)
836         for child in task.child_ids:
837             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
838             if not res:
839                 return False
840
841         visited_branch.remove(id)
842         return True
843
844     def _check_dates(self, cr, uid, ids, context=None):
845         if context == None:
846             context = {}
847         obj_task = self.browse(cr, uid, ids[0], context=context)
848         start = obj_task.date_start or False
849         end = obj_task.date_end or False
850         if start and end :
851             if start > end:
852                 return False
853         return True
854
855     _constraints = [
856         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
857         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
858     ]
859
860     # Override view according to the company definition
861     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
862         users_obj = self.pool.get('res.users')
863         if context is None: context = {}
864         # read uom as admin to avoid access rights issues, e.g. for portal/share users,
865         # this should be safe (no context passed to avoid side-effects)
866         obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
867         tm = obj_tm and obj_tm.name or 'Hours'
868
869         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
870
871         if tm in ['Hours','Hour']:
872             return res
873
874         eview = etree.fromstring(res['arch'])
875
876         def _check_rec(eview):
877             if eview.attrib.get('widget','') == 'float_time':
878                 eview.set('widget','float')
879             for child in eview:
880                 _check_rec(child)
881             return True
882
883         _check_rec(eview)
884
885         res['arch'] = etree.tostring(eview)
886
887         for f in res['fields']:
888             if 'Hours' in res['fields'][f]['string']:
889                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
890         return res
891
892     # ----------------------------------------
893     # Case management
894     # ----------------------------------------
895
896     def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
897         """ Override of the base.stage method
898             Parameter of the stage search taken from the lead:
899             - section_id: if set, stages must belong to this section or
900               be a default stage; if not set, stages must be default
901               stages
902         """
903         if isinstance(cases, (int, long)):
904             cases = self.browse(cr, uid, cases, context=context)
905         # collect all section_ids
906         section_ids = []
907         if section_id:
908             section_ids.append(section_id)
909         for task in cases:
910             if task.project_id:
911                 section_ids.append(task.project_id.id)
912         search_domain = []
913         if section_ids:
914             search_domain = [('|')] * (len(section_ids)-1)
915             for section_id in section_ids:
916                 search_domain.append(('project_ids', '=', section_id))
917         search_domain += list(domain)
918         # perform search, return the first found
919         stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
920         if stage_ids:
921             return stage_ids[0]
922         return False
923
924     def _check_child_task(self, cr, uid, ids, context=None):
925         if context == None:
926             context = {}
927         tasks = self.browse(cr, uid, ids, context=context)
928         for task in tasks:
929             if task.child_ids:
930                 for child in task.child_ids:
931                     if child.state in ['draft', 'open', 'pending']:
932                         raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
933         return True
934
935     def action_close(self, cr, uid, ids, context=None):
936         """ This action closes the task
937         """
938         task_id = len(ids) and ids[0] or False
939         self._check_child_task(cr, uid, ids, context=context)
940         if not task_id: return False
941         return self.do_close(cr, uid, [task_id], context=context)
942
943     def do_close(self, cr, uid, ids, context=None):
944         """ Compatibility when changing to case_close. """
945         return self.case_close(cr, uid, ids, context=context)
946
947     def case_close(self, cr, uid, ids, context=None):
948         """ Closes Task """
949         if not isinstance(ids, list): ids = [ids]
950         for task in self.browse(cr, uid, ids, context=context):
951             vals = {}
952             project = task.project_id
953             for parent_id in task.parent_ids:
954                 if parent_id.state in ('pending','draft'):
955                     reopen = True
956                     for child in parent_id.child_ids:
957                         if child.id != task.id and child.state not in ('done','cancelled'):
958                             reopen = False
959                     if reopen:
960                         self.do_reopen(cr, uid, [parent_id.id], context=context)
961             # close task
962             vals['remaining_hours'] = 0.0
963             if not task.date_end:
964                 vals['date_end'] = fields.datetime.now()
965             self.case_set(cr, uid, [task.id], 'done', vals, context=context)
966         return True
967
968     def do_reopen(self, cr, uid, ids, context=None):
969         for task in self.browse(cr, uid, ids, context=context):
970             project = task.project_id
971             self.case_set(cr, uid, [task.id], 'open', {}, context=context)
972         return True
973
974     def do_cancel(self, cr, uid, ids, context=None):
975         """ Compatibility when changing to case_cancel. """
976         return self.case_cancel(cr, uid, ids, context=context)
977
978     def case_cancel(self, cr, uid, ids, context=None):
979         tasks = self.browse(cr, uid, ids, context=context)
980         self._check_child_task(cr, uid, ids, context=context)
981         for task in tasks:
982             self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
983         return True
984
985     def do_open(self, cr, uid, ids, context=None):
986         """ Compatibility when changing to case_open. """
987         return self.case_open(cr, uid, ids, context=context)
988
989     def case_open(self, cr, uid, ids, context=None):
990         if not isinstance(ids,list): ids = [ids]
991         return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
992
993     def do_draft(self, cr, uid, ids, context=None):
994         """ Compatibility when changing to case_draft. """
995         return self.case_draft(cr, uid, ids, context=context)
996
997     def case_draft(self, cr, uid, ids, context=None):
998         return self.case_set(cr, uid, ids, 'draft', {}, context=context)
999
1000     def do_pending(self, cr, uid, ids, context=None):
1001         """ Compatibility when changing to case_pending. """
1002         return self.case_pending(cr, uid, ids, context=context)
1003
1004     def case_pending(self, cr, uid, ids, context=None):
1005         return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1006
1007     def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1008         attachment = self.pool.get('ir.attachment')
1009         attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1010         new_attachment_ids = []
1011         for attachment_id in attachment_ids:
1012             new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1013         return new_attachment_ids
1014
1015     def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1016         """
1017         Delegate Task to another users.
1018         """
1019         if delegate_data is None:
1020             delegate_data = {}
1021         assert delegate_data['user_id'], _("Delegated User should be specified")
1022         delegated_tasks = {}
1023         for task in self.browse(cr, uid, ids, context=context):
1024             delegated_task_id = self.copy(cr, uid, task.id, {
1025                 'name': delegate_data['name'],
1026                 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1027                 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1028                 'planned_hours': delegate_data['planned_hours'] or 0.0,
1029                 'parent_ids': [(6, 0, [task.id])],
1030                 'description': delegate_data['new_task_description'] or '',
1031                 'child_ids': [],
1032                 'work_ids': []
1033             }, context=context)
1034             self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1035             newname = delegate_data['prefix'] or ''
1036             task.write({
1037                 'remaining_hours': delegate_data['planned_hours_me'],
1038                 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1039                 'name': newname,
1040             }, context=context)
1041             if delegate_data['state'] == 'pending':
1042                 self.do_pending(cr, uid, [task.id], context=context)
1043             elif delegate_data['state'] == 'done':
1044                 self.do_close(cr, uid, [task.id], context=context)
1045             delegated_tasks[task.id] = delegated_task_id
1046         return delegated_tasks
1047
1048     def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1049         for task in self.browse(cr, uid, ids, context=context):
1050             if (task.state=='draft') or (task.planned_hours==0.0):
1051                 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1052         self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1053         return True
1054
1055     def set_remaining_time_1(self, cr, uid, ids, context=None):
1056         return self.set_remaining_time(cr, uid, ids, 1.0, context)
1057
1058     def set_remaining_time_2(self, cr, uid, ids, context=None):
1059         return self.set_remaining_time(cr, uid, ids, 2.0, context)
1060
1061     def set_remaining_time_5(self, cr, uid, ids, context=None):
1062         return self.set_remaining_time(cr, uid, ids, 5.0, context)
1063
1064     def set_remaining_time_10(self, cr, uid, ids, context=None):
1065         return self.set_remaining_time(cr, uid, ids, 10.0, context)
1066
1067     def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1068         return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1069
1070     def set_kanban_state_normal(self, cr, uid, ids, context=None):
1071         return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1072
1073     def set_kanban_state_done(self, cr, uid, ids, context=None):
1074         self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1075         return False
1076
1077     def _store_history(self, cr, uid, ids, context=None):
1078         for task in self.browse(cr, uid, ids, context=context):
1079             self.pool.get('project.task.history').create(cr, uid, {
1080                 'task_id': task.id,
1081                 'remaining_hours': task.remaining_hours,
1082                 'planned_hours': task.planned_hours,
1083                 'kanban_state': task.kanban_state,
1084                 'type_id': task.stage_id.id,
1085                 'state': task.state,
1086                 'user_id': task.user_id.id
1087
1088             }, context=context)
1089         return True
1090
1091     def create(self, cr, uid, vals, context=None):
1092         if context is None:
1093             context = {}
1094         if not vals.get('stage_id'):
1095             ctx = context.copy()
1096             if  vals.get('project_id'):
1097                 ctx['default_project_id'] = vals['project_id']
1098             vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
1099         task_id = super(task, self).create(cr, uid, vals, context=context)
1100         self._store_history(cr, uid, [task_id], context=context)
1101         return task_id
1102
1103     # Overridden to reset the kanban_state to normal whenever
1104     # the stage (stage_id) of the task changes.
1105     def write(self, cr, uid, ids, vals, context=None):
1106         if isinstance(ids, (int, long)):
1107             ids = [ids]
1108         if vals.get('project_id'):
1109             project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1110             if project_id:
1111                 vals.setdefault('message_follower_ids', [])
1112                 vals['message_follower_ids'] += [(6, 0,[follower.id]) for follower in project_id.message_follower_ids]
1113         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1114             new_stage = vals.get('stage_id')
1115             vals_reset_kstate = dict(vals, kanban_state='normal')
1116             for t in self.browse(cr, uid, ids, context=context):
1117                 #TO FIX:Kanban view doesn't raise warning
1118                 #stages = [stage.id for stage in t.project_id.type_ids]
1119                 #if new_stage not in stages:
1120                     #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1121                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1122                 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1123             result = True
1124         else:
1125             result = super(task, self).write(cr, uid, ids, vals, context=context)
1126         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1127             self._store_history(cr, uid, ids, context=context)
1128         return result
1129
1130     def unlink(self, cr, uid, ids, context=None):
1131         if context == None:
1132             context = {}
1133         self._check_child_task(cr, uid, ids, context=context)
1134         res = super(task, self).unlink(cr, uid, ids, context)
1135         return res
1136
1137     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1138         context = context or {}
1139         result = ""
1140         ident = ' '*ident
1141         for task in tasks:
1142             if task.state in ('done','cancelled'):
1143                 continue
1144             result += '''
1145 %sdef Task_%s():
1146 %s  todo = \"%.2fH\"
1147 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1148             start = []
1149             for t2 in task.parent_ids:
1150                 start.append("up.Task_%s.end" % (t2.id,))
1151             if start:
1152                 result += '''
1153 %s  start = max(%s)
1154 ''' % (ident,','.join(start))
1155
1156             if task.user_id:
1157                 result += '''
1158 %s  resource = %s
1159 ''' % (ident, 'User_'+str(task.user_id.id))
1160
1161         result += "\n"
1162         return result
1163
1164     # ---------------------------------------------------
1165     # Mail gateway
1166     # ---------------------------------------------------
1167
1168     def message_get_reply_to(self, cr, uid, ids, context=None):
1169         """ Override to get the reply_to of the parent project. """
1170         return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1171                     for task in self.browse(cr, uid, ids, context=context)]
1172
1173     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1174         """ Override to updates the document according to the email. """
1175         if custom_values is None: custom_values = {}
1176         defaults = {
1177             'name': msg.get('subject'),
1178             'planned_hours': 0.0,
1179         }
1180         defaults.update(custom_values)
1181         return super(task,self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1182
1183     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1184         """ Override to update the task according to the email. """
1185         if update_vals is None: update_vals = {}
1186         act = False
1187         maps = {
1188             'cost':'planned_hours',
1189         }
1190         for line in msg['body'].split('\n'):
1191             line = line.strip()
1192             res = tools.command_re.match(line)
1193             if res:
1194                 match = res.group(1).lower()
1195                 field = maps.get(match)
1196                 if field:
1197                     try:
1198                         update_vals[field] = float(res.group(2).lower())
1199                     except (ValueError, TypeError):
1200                         pass
1201                 elif match.lower() == 'state' \
1202                         and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1203                     act = 'do_%s' % res.group(2).lower()
1204         if act:
1205             getattr(self,act)(cr, uid, ids, context=context)
1206         return super(task,self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1207
1208     def project_task_reevaluate(self, cr, uid, ids, context=None):
1209         if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1210             return {
1211                 'view_type': 'form',
1212                 "view_mode": 'form',
1213                 'res_model': 'project.task.reevaluate',
1214                 'type': 'ir.actions.act_window',
1215                 'target': 'new',
1216             }
1217         return self.do_reopen(cr, uid, ids, context=context)
1218
1219 class project_work(osv.osv):
1220     _name = "project.task.work"
1221     _description = "Project Task Work"
1222     _columns = {
1223         'name': fields.char('Work summary', size=128),
1224         'date': fields.datetime('Date', select="1"),
1225         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1226         'hours': fields.float('Time Spent'),
1227         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1228         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1229     }
1230
1231     _defaults = {
1232         'user_id': lambda obj, cr, uid, context: uid,
1233         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1234     }
1235
1236     _order = "date desc"
1237     def create(self, cr, uid, vals, *args, **kwargs):
1238         if 'hours' in vals and (not vals['hours']):
1239             vals['hours'] = 0.00
1240         if 'task_id' in vals:
1241             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1242         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1243
1244     def write(self, cr, uid, ids, vals, context=None):
1245         if 'hours' in vals and (not vals['hours']):
1246             vals['hours'] = 0.00
1247         if 'hours' in vals:
1248             for work in self.browse(cr, uid, ids, context=context):
1249                 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))
1250         return super(project_work,self).write(cr, uid, ids, vals, context)
1251
1252     def unlink(self, cr, uid, ids, *args, **kwargs):
1253         for work in self.browse(cr, uid, ids):
1254             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1255         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1256
1257
1258 class account_analytic_account(osv.osv):
1259     _inherit = 'account.analytic.account'
1260     _description = 'Analytic Account'
1261     _columns = {
1262         '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"),
1263         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1264     }
1265
1266     def on_change_template(self, cr, uid, ids, template_id, context=None):
1267         res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1268         if template_id and 'value' in res:
1269             template = self.browse(cr, uid, template_id, context=context)
1270             res['value']['use_tasks'] = template.use_tasks
1271         return res
1272
1273     def _trigger_project_creation(self, cr, uid, vals, context=None):
1274         '''
1275         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.
1276         '''
1277         if context is None: context = {}
1278         return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1279
1280     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1281         '''
1282         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.
1283         '''
1284         project_pool = self.pool.get('project.project')
1285         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1286         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1287             project_values = {
1288                 'name': vals.get('name'),
1289                 'analytic_account_id': analytic_account_id,
1290                 'type': vals.get('type','contract'),
1291             }
1292             return project_pool.create(cr, uid, project_values, context=context)
1293         return False
1294
1295     def create(self, cr, uid, vals, context=None):
1296         if context is None:
1297             context = {}
1298         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1299             vals['child_ids'] = []
1300         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1301         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1302         return analytic_account_id
1303
1304     def write(self, cr, uid, ids, vals, context=None):
1305         name = vals.get('name')
1306         for account in self.browse(cr, uid, ids, context=context):
1307             if not name:
1308                 vals['name'] = account.name
1309             if not vals.get('type'):
1310                 vals['type'] = account.type
1311             self.project_create(cr, uid, account.id, vals, context=context)
1312         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1313
1314     def unlink(self, cr, uid, ids, *args, **kwargs):
1315         project_obj = self.pool.get('project.project')
1316         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1317         if analytic_ids:
1318             raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1319         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1320
1321 class project_project(osv.osv):
1322     _inherit = 'project.project'
1323     _defaults = {
1324         'use_tasks': True
1325     }
1326
1327 class project_task_history(osv.osv):
1328     """
1329     Tasks History, used for cumulative flow charts (Lean/Agile)
1330     """
1331     _name = 'project.task.history'
1332     _description = 'History of Tasks'
1333     _rec_name = 'task_id'
1334     _log_access = False
1335
1336     def _get_date(self, cr, uid, ids, name, arg, context=None):
1337         result = {}
1338         for history in self.browse(cr, uid, ids, context=context):
1339             if history.state in ('done','cancelled'):
1340                 result[history.id] = history.date
1341                 continue
1342             cr.execute('''select
1343                     date
1344                 from
1345                     project_task_history
1346                 where
1347                     task_id=%s and
1348                     id>%s
1349                 order by id limit 1''', (history.task_id.id, history.id))
1350             res = cr.fetchone()
1351             result[history.id] = res and res[0] or False
1352         return result
1353
1354     def _get_related_date(self, cr, uid, ids, context=None):
1355         result = []
1356         for history in self.browse(cr, uid, ids, context=context):
1357             cr.execute('''select
1358                     id
1359                 from
1360                     project_task_history
1361                 where
1362                     task_id=%s and
1363                     id<%s
1364                 order by id desc limit 1''', (history.task_id.id, history.id))
1365             res = cr.fetchone()
1366             if res:
1367                 result.append(res[0])
1368         return result
1369
1370     _columns = {
1371         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1372         'type_id': fields.many2one('project.task.type', 'Stage'),
1373         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1374         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1375         'date': fields.date('Date', select=True),
1376         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1377             'project.task.history': (_get_related_date, None, 20)
1378         }),
1379         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1380         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1381         'user_id': fields.many2one('res.users', 'Responsible'),
1382     }
1383     _defaults = {
1384         'date': fields.date.context_today,
1385     }
1386
1387 class project_task_history_cumulative(osv.osv):
1388     _name = 'project.task.history.cumulative'
1389     _table = 'project_task_history_cumulative'
1390     _inherit = 'project.task.history'
1391     _auto = False
1392
1393     _columns = {
1394         'end_date': fields.date('End Date'),
1395         'project_id': fields.many2one('project.project', 'Project'),
1396     }
1397
1398     def init(self, cr):
1399         tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1400
1401         cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1402             SELECT
1403                 history.date::varchar||'-'||history.history_id::varchar AS id,
1404                 history.date AS end_date,
1405                 *
1406             FROM (
1407                 SELECT
1408                     h.id AS history_id,
1409                     h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1410                     h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1411                     greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1412                     t.project_id
1413                 FROM
1414                     project_task_history AS h
1415                     JOIN project_task AS t ON (h.task_id = t.id)
1416
1417             ) AS history
1418         )
1419         """)
1420
1421 class project_category(osv.osv):
1422     """ Category of project's task (or issue) """
1423     _name = "project.category"
1424     _description = "Category of project's task, issue, ..."
1425     _columns = {
1426         'name': fields.char('Name', size=64, required=True, translate=True),
1427     }