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