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