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