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