[MERGE]:trunk-mail-cleaning-fp
[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="cancel", 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="close", 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), ('fold', '=', False)]
586         search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
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         return result
592
593     def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
594         res_users = self.pool.get('res.users')
595         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
596         access_rights_uid = access_rights_uid or uid
597         if project_id:
598             ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
599             order = res_users._order
600             # lame way to allow reverting search, should just work in the trivial case
601             if read_group_order == 'user_id desc':
602                 order = '%s desc' % order
603             # de-duplicate and apply search order
604             ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
605         result = res_users.name_get(cr, access_rights_uid, ids, context=context)
606         # restore order of the search
607         result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
608         return result
609
610     _group_by_full = {
611         'stage_id': _read_group_stage_ids,
612         'user_id': _read_group_user_id,
613     }
614
615     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
616         obj_project = self.pool.get('project.project')
617         for domain in args:
618             if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
619                 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
620                 if id and isinstance(id, (long, int)):
621                     if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
622                         args.append(('active', '=', False))
623         return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
624
625     def _str_get(self, task, level=0, border='***', context=None):
626         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'+ \
627             border[0]+' '+(task.name or '')+'\n'+ \
628             (task.description or '')+'\n\n'
629
630     # Compute: effective_hours, total_hours, progress
631     def _hours_get(self, cr, uid, ids, field_names, args, context=None):
632         res = {}
633         cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
634         hours = dict(cr.fetchall())
635         for task in self.browse(cr, uid, ids, context=context):
636             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)}
637             res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
638             res[task.id]['progress'] = 0.0
639             if (task.remaining_hours + hours.get(task.id, 0.0)):
640                 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
641             if task.state in ('done','cancelled'):
642                 res[task.id]['progress'] = 100.0
643         return res
644
645     def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
646         if remaining and not planned:
647             return {'value':{'planned_hours': remaining}}
648         return {}
649
650     def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
651         return {'value':{'remaining_hours': planned - effective}}
652
653     def onchange_project(self, cr, uid, id, project_id):
654         if not project_id:
655             return {}
656         data = self.pool.get('project.project').browse(cr, uid, [project_id])
657         partner_id=data and data[0].partner_id
658         if partner_id:
659             return {'value':{'partner_id':partner_id.id}}
660         return {}
661
662     def duplicate_task(self, cr, uid, map_ids, context=None):
663         for new in map_ids.values():
664             task = self.browse(cr, uid, new, context)
665             child_ids = [ ch.id for ch in task.child_ids]
666             if task.child_ids:
667                 for child in task.child_ids:
668                     if child.id in map_ids.keys():
669                         child_ids.remove(child.id)
670                         child_ids.append(map_ids[child.id])
671
672             parent_ids = [ ch.id for ch in task.parent_ids]
673             if task.parent_ids:
674                 for parent in task.parent_ids:
675                     if parent.id in map_ids.keys():
676                         parent_ids.remove(parent.id)
677                         parent_ids.append(map_ids[parent.id])
678             #FIXME why there is already the copy and the old one
679             self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
680
681     def copy_data(self, cr, uid, id, default={}, context=None):
682         default = default or {}
683         default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
684         if not default.get('remaining_hours', False):
685             default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
686         default['active'] = True
687         default['stage_id'] = False
688         if not default.get('name', False):
689             default['name'] = self.browse(cr, uid, id, context=context).name or ''
690             if not context.get('copy',False):
691                 new_name = _("%s (copy)")%default.get('name','')
692                 default.update({'name':new_name})
693         return super(task, self).copy_data(cr, uid, id, default, context)
694
695
696     def _is_template(self, cr, uid, ids, field_name, arg, context=None):
697         res = {}
698         for task in self.browse(cr, uid, ids, context=context):
699             res[task.id] = True
700             if task.project_id:
701                 if task.project_id.active == False or task.project_id.state == 'template':
702                     res[task.id] = False
703         return res
704
705     def _get_task(self, cr, uid, ids, context=None):
706         result = {}
707         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
708             if work.task_id: result[work.task_id.id] = True
709         return result.keys()
710
711     _columns = {
712         '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."),
713         'name': fields.char('Task Summary', size=128, required=True, select=True),
714         'description': fields.text('Description'),
715         'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
716         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
717         'stage_id': fields.many2one('project.task.type', 'Stage',
718                         domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
719         'state': fields.related('stage_id', 'state', type="selection", store=True,
720                 selection=_TASK_STATE, string="State", readonly=True,
721                 help='The state is set to \'Draft\', when a case is created.\
722                       If the case is in progress the state is set to \'Open\'.\
723                       When the case is over, the state is set to \'Done\'.\
724                       If the case needs to be reviewed then the state is \
725                       set to \'Pending\'.'),
726         'categ_ids': fields.many2many('project.category', string='Categories'),
727         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
728                                          help="A task's kanban state indicates special situations affecting it:\n"
729                                               " * Normal is the default situation\n"
730                                               " * Blocked indicates something is preventing the progress of this task\n"
731                                               " * Ready To Pull indicates the task is ready to be pulled to the next stage",
732                                          readonly=True, required=False),
733         'create_date': fields.datetime('Create Date', readonly=True,select=True),
734         'date_start': fields.datetime('Starting Date',select=True),
735         'date_end': fields.datetime('Ending Date',select=True),
736         'date_deadline': fields.date('Deadline',select=True),
737         'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
738         'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
739         'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
740         'notes': fields.text('Notes'),
741         '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.'),
742         'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
743             store = {
744                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
745                 'project.task.work': (_get_task, ['hours'], 10),
746             }),
747         'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
748         'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
749             store = {
750                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
751                 'project.task.work': (_get_task, ['hours'], 10),
752             }),
753         '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",
754             store = {
755                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
756                 'project.task.work': (_get_task, ['hours'], 10),
757             }),
758         '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.",
759             store = {
760                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
761                 'project.task.work': (_get_task, ['hours'], 10),
762             }),
763         'user_id': fields.many2one('res.users', 'Assigned to'),
764         'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
765         'partner_id': fields.many2one('res.partner', 'Contact'),
766         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
767         'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
768         'company_id': fields.many2one('res.company', 'Company'),
769         'id': fields.integer('ID', readonly=True),
770         'color': fields.integer('Color Index'),
771         'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
772     }
773
774     _defaults = {
775         'stage_id': _get_default_stage_id,
776         'project_id': _get_default_project_id,
777         'state': 'draft',
778         'kanban_state': 'normal',
779         'priority': '2',
780         'progress': 0,
781         'sequence': 10,
782         'active': True,
783         'user_id': lambda obj, cr, uid, context: uid,
784         'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
785     }
786
787     _order = "priority, sequence, date_start, name, id"
788
789     def set_priority(self, cr, uid, ids, priority, *args):
790         """Set task priority
791         """
792         return self.write(cr, uid, ids, {'priority' : priority})
793
794     def set_high_priority(self, cr, uid, ids, *args):
795         """Set task priority to high
796         """
797         return self.set_priority(cr, uid, ids, '1')
798
799     def set_normal_priority(self, cr, uid, ids, *args):
800         """Set task priority to normal
801         """
802         return self.set_priority(cr, uid, ids, '2')
803
804     def _check_recursion(self, cr, uid, ids, context=None):
805         for id in ids:
806             visited_branch = set()
807             visited_node = set()
808             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
809             if not res:
810                 return False
811
812         return True
813
814     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
815         if id in visited_branch: #Cycle
816             return False
817
818         if id in visited_node: #Already tested don't work one more time for nothing
819             return True
820
821         visited_branch.add(id)
822         visited_node.add(id)
823
824         #visit child using DFS
825         task = self.browse(cr, uid, id, context=context)
826         for child in task.child_ids:
827             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
828             if not res:
829                 return False
830
831         visited_branch.remove(id)
832         return True
833
834     def _check_dates(self, cr, uid, ids, context=None):
835         if context == None:
836             context = {}
837         obj_task = self.browse(cr, uid, ids[0], context=context)
838         start = obj_task.date_start or False
839         end = obj_task.date_end or False
840         if start and end :
841             if start > end:
842                 return False
843         return True
844
845     _constraints = [
846         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
847         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
848     ]
849     #
850     # Override view according to the company definition
851     #
852     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
853         users_obj = self.pool.get('res.users')
854         if context is None: context = {}
855         # read uom as admin to avoid access rights issues, e.g. for portal/share users,
856         # this should be safe (no context passed to avoid side-effects)
857         obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
858         tm = obj_tm and obj_tm.name or 'Hours'
859
860         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
861
862         if tm in ['Hours','Hour']:
863             return res
864
865         eview = etree.fromstring(res['arch'])
866
867         def _check_rec(eview):
868             if eview.attrib.get('widget','') == 'float_time':
869                 eview.set('widget','float')
870             for child in eview:
871                 _check_rec(child)
872             return True
873
874         _check_rec(eview)
875
876         res['arch'] = etree.tostring(eview)
877
878         for f in res['fields']:
879             if 'Hours' in res['fields'][f]['string']:
880                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
881         return res
882
883     # ****************************************
884     # Case management
885     # ****************************************
886
887     def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
888         """ Override of the base.stage method
889             Parameter of the stage search taken from the lead:
890             - section_id: if set, stages must belong to this section or
891               be a default stage; if not set, stages must be default
892               stages
893         """
894         if isinstance(cases, (int, long)):
895             cases = self.browse(cr, uid, cases, context=context)
896         # collect all section_ids
897         section_ids = []
898         if section_id:
899             section_ids.append(section_id)
900         for task in cases:
901             if task.project_id:
902                 section_ids.append(task.project_id.id)
903         # OR all section_ids and OR with case_default
904         search_domain = []
905         if section_ids:
906             search_domain += [('|')] * len(section_ids)
907             for section_id in section_ids:
908                 search_domain.append(('project_ids', '=', section_id))
909         search_domain.append(('case_default', '=', True))
910         # AND with the domain in parameter
911         search_domain += list(domain)
912         # perform search, return the first found
913         stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
914         if stage_ids:
915             return stage_ids[0]
916         return False
917
918     def _check_child_task(self, cr, uid, ids, context=None):
919         if context == None:
920             context = {}
921         tasks = self.browse(cr, uid, ids, context=context)
922         for task in tasks:
923             if task.child_ids:
924                 for child in task.child_ids:
925                     if child.state in ['draft', 'open', 'pending']:
926                         raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
927         return True
928
929     def action_close(self, cr, uid, ids, context=None):
930         """ This action closes the task
931         """
932         task_id = len(ids) and ids[0] or False
933         self._check_child_task(cr, uid, ids, context=context)
934         if not task_id: return False
935         return self.do_close(cr, uid, [task_id], context=context)
936
937     def do_close(self, cr, uid, ids, context=None):
938         """ Compatibility when changing to case_close. """
939         return self.case_close(cr, uid, ids, context=context)
940
941     def case_close(self, cr, uid, ids, context=None):
942         """ Closes Task """
943         if not isinstance(ids, list): ids = [ids]
944         for task in self.browse(cr, uid, ids, context=context):
945             vals = {}
946             project = task.project_id
947             for parent_id in task.parent_ids:
948                 if parent_id.state in ('pending','draft'):
949                     reopen = True
950                     for child in parent_id.child_ids:
951                         if child.id != task.id and child.state not in ('done','cancelled'):
952                             reopen = False
953                     if reopen:
954                         self.do_reopen(cr, uid, [parent_id.id], context=context)
955             # close task
956             vals['remaining_hours'] = 0.0
957             if not task.date_end:
958                 vals['date_end'] = fields.datetime.now()
959             self.case_set(cr, uid, [task.id], 'done', vals, context=context)
960             self.case_close_send_note(cr, uid, [task.id], context=context)
961         return True
962
963     def do_reopen(self, cr, uid, ids, context=None):
964         for task in self.browse(cr, uid, ids, context=context):
965             project = task.project_id
966             self.case_set(cr, uid, [task.id], 'open', {}, context=context)
967             self.case_open_send_note(cr, uid, [task.id], context)
968         return True
969
970     def do_cancel(self, cr, uid, ids, context=None):
971         """ Compatibility when changing to case_cancel. """
972         return self.case_cancel(cr, uid, ids, context=context)
973
974     def case_cancel(self, cr, uid, ids, context=None):
975         tasks = self.browse(cr, uid, ids, context=context)
976         self._check_child_task(cr, uid, ids, context=context)
977         for task in tasks:
978             self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
979             self.case_cancel_send_note(cr, uid, [task.id], context=context)
980         return True
981
982     def do_open(self, cr, uid, ids, context=None):
983         """ Compatibility when changing to case_open. """
984         return self.case_open(cr, uid, ids, context=context)
985
986     def case_open(self, cr, uid, ids, context=None):
987         if not isinstance(ids,list): ids = [ids]
988         self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
989         self.case_open_send_note(cr, uid, ids, context)
990         return True
991
992     def do_draft(self, cr, uid, ids, context=None):
993         """ Compatibility when changing to case_draft. """
994         return self.case_draft(cr, uid, ids, context=context)
995
996     def case_draft(self, cr, uid, ids, context=None):
997         self.case_set(cr, uid, ids, 'draft', {}, context=context)
998         self.case_draft_send_note(cr, uid, ids, context=context)
999         return True
1000
1001     def do_pending(self, cr, uid, ids, context=None):
1002         """ Compatibility when changing to case_pending. """
1003         return self.case_pending(cr, uid, ids, context=context)
1004
1005     def case_pending(self, cr, uid, ids, context=None):
1006         self.case_set(cr, uid, ids, 'pending', {}, context=context)
1007         return self.case_pending_send_note(cr, uid, ids, context=context)
1008
1009     def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1010         attachment = self.pool.get('ir.attachment')
1011         attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1012         new_attachment_ids = []
1013         for attachment_id in attachment_ids:
1014             new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1015         return new_attachment_ids
1016
1017     def do_delegate(self, cr, uid, ids, delegate_data={}, context=None):
1018         """
1019         Delegate Task to another users.
1020         """
1021         assert delegate_data['user_id'], _("Delegated User should be specified")
1022         delegated_tasks = {}
1023         for task in self.browse(cr, uid, ids, context=context):
1024             delegated_task_id = self.copy(cr, uid, task.id, {
1025                 'name': delegate_data['name'],
1026                 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1027                 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1028                 'planned_hours': delegate_data['planned_hours'] or 0.0,
1029                 'parent_ids': [(6, 0, [task.id])],
1030                 'state': 'draft',
1031                 'description': delegate_data['new_task_description'] or '',
1032                 'child_ids': [],
1033                 'work_ids': []
1034             }, context=context)
1035             self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1036             newname = delegate_data['prefix'] or ''
1037             task.write({
1038                 'remaining_hours': delegate_data['planned_hours_me'],
1039                 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1040                 'name': newname,
1041             }, context=context)
1042             if delegate_data['state'] == 'pending':
1043                 self.do_pending(cr, uid, [task.id], context=context)
1044             elif delegate_data['state'] == 'done':
1045                 self.do_close(cr, uid, [task.id], context=context)
1046             self.do_delegation_send_note(cr, uid, [task.id], context)
1047             delegated_tasks[task.id] = delegated_task_id
1048         return delegated_tasks
1049
1050     def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1051         for task in self.browse(cr, uid, ids, context=context):
1052             if (task.state=='draft') or (task.planned_hours==0.0):
1053                 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1054         self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1055         return True
1056
1057     def set_remaining_time_1(self, cr, uid, ids, context=None):
1058         return self.set_remaining_time(cr, uid, ids, 1.0, context)
1059
1060     def set_remaining_time_2(self, cr, uid, ids, context=None):
1061         return self.set_remaining_time(cr, uid, ids, 2.0, context)
1062
1063     def set_remaining_time_5(self, cr, uid, ids, context=None):
1064         return self.set_remaining_time(cr, uid, ids, 5.0, context)
1065
1066     def set_remaining_time_10(self, cr, uid, ids, context=None):
1067         return self.set_remaining_time(cr, uid, ids, 10.0, context)
1068
1069     def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1070         self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1071         return False
1072
1073     def set_kanban_state_normal(self, cr, uid, ids, context=None):
1074         self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1075         return False
1076
1077     def set_kanban_state_done(self, cr, uid, ids, context=None):
1078         self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1079         return False
1080
1081     def _store_history(self, cr, uid, ids, context=None):
1082         for task in self.browse(cr, uid, ids, context=context):
1083             self.pool.get('project.task.history').create(cr, uid, {
1084                 'task_id': task.id,
1085                 'remaining_hours': task.remaining_hours,
1086                 'planned_hours': task.planned_hours,
1087                 'kanban_state': task.kanban_state,
1088                 'type_id': task.stage_id.id,
1089                 'state': task.state,
1090                 'user_id': task.user_id.id
1091
1092             }, context=context)
1093         return True
1094
1095     def create(self, cr, uid, vals, context=None):
1096         task_id = super(task, self).create(cr, uid, vals, context=context)
1097         task_record = self.browse(cr, uid, task_id, context=context)
1098         if task_record.project_id:
1099             project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1100             self.message_subscribe(cr, uid, [task_id], project_follower_ids, context=context)
1101         self._store_history(cr, uid, [task_id], context=context)
1102         self.create_send_note(cr, uid, [task_id], context=context)
1103         return task_id
1104
1105     # Overridden to reset the kanban_state to normal whenever
1106     # the stage (stage_id) of the task changes.
1107     def write(self, cr, uid, ids, vals, context=None):
1108         if isinstance(ids, (int, long)):
1109             ids = [ids]
1110         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1111             new_stage = vals.get('stage_id')
1112             vals_reset_kstate = dict(vals, kanban_state='normal')
1113             for t in self.browse(cr, uid, ids, context=context):
1114                 #TO FIX:Kanban view doesn't raise warning
1115                 #stages = [stage.id for stage in t.project_id.type_ids]
1116                 #if new_stage not in stages:
1117                     #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1118                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1119                 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1120                 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1121             result = True
1122         else:
1123             result = super(task,self).write(cr, uid, ids, vals, context=context)
1124         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1125             self._store_history(cr, uid, ids, context=context)
1126         return result
1127
1128     def unlink(self, cr, uid, ids, context=None):
1129         if context == None:
1130             context = {}
1131         self._check_child_task(cr, uid, ids, context=context)
1132         res = super(task, self).unlink(cr, uid, ids, context)
1133         return res
1134
1135     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1136         context = context or {}
1137         result = ""
1138         ident = ' '*ident
1139         for task in tasks:
1140             if task.state in ('done','cancelled'):
1141                 continue
1142             result += '''
1143 %sdef Task_%s():
1144 %s  todo = \"%.2fH\"
1145 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1146             start = []
1147             for t2 in task.parent_ids:
1148                 start.append("up.Task_%s.end" % (t2.id,))
1149             if start:
1150                 result += '''
1151 %s  start = max(%s)
1152 ''' % (ident,','.join(start))
1153
1154             if task.user_id:
1155                 result += '''
1156 %s  resource = %s
1157 ''' % (ident, 'User_'+str(task.user_id.id))
1158
1159         result += "\n"
1160         return result
1161
1162     # ---------------------------------------------------
1163     # mail gateway
1164     # ---------------------------------------------------
1165
1166     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1167         """ Override to updates the document according to the email. """
1168         if custom_values is None: custom_values = {}
1169         custom_values.update({
1170             'name': subject,
1171             'planned_hours': 0.0,
1172             'subject': msg.get('subject'),
1173         })
1174         return super(project_tasks,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1175
1176     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1177         """ Override to update the task according to the email. """
1178         if update_vals is None: update_vals = {}
1179         act = False
1180         maps = {
1181             'cost':'planned_hours',
1182         }
1183         for line in msg['body'].split('\n'):
1184             line = line.strip()
1185             res = tools.misc.command_re.match(line)
1186             if res:
1187                 match = res.group(1).lower()
1188                 field = maps.get(match)
1189                 if field:
1190                     try:
1191                         update_vals[field] = float(res.group(2).lower())
1192                     except (ValueError, TypeError):
1193                         pass
1194                 elif match.lower() == 'state' \
1195                         and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1196                     act = 'do_%s' % res.group(2).lower()
1197         if act:
1198             getattr(self,act)(cr, uid, ids, context=context)
1199         return super(project_tasks,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1200
1201     # ---------------------------------------------------
1202     # OpenChatter methods and notifications
1203     # ---------------------------------------------------
1204
1205     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1206         """ Override of default prefix for notifications. """
1207         return 'Task'
1208
1209     def get_needaction_user_ids(self, cr, uid, ids, context=None):
1210         """ Returns the user_ids that have to perform an action.
1211             Add to the previous results given by super the document responsible
1212             when in draft mode.
1213             :return: dict { record_id: [user_ids], }
1214         """
1215         result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1216         for obj in self.browse(cr, uid, ids, context=context):
1217             if obj.state == 'draft' and obj.user_id:
1218                 result[obj.id].append(obj.user_id.id)
1219         return result
1220
1221     def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1222         """ Add 'user_id' and 'manager_id' to the monitored fields """
1223         res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1224         return res + ['user_id', 'manager_id']
1225
1226     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1227         """ Override of the (void) default notification method. """
1228         stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1229         return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), subtype="stage change", context=context)
1230
1231     def create_send_note(self, cr, uid, ids, context=None):
1232         return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), subtype="new", context=context)
1233
1234     def case_draft_send_note(self, cr, uid, ids, context=None):
1235         msg = _('Task has been set as <b>draft</b>.')
1236         return self.message_post(cr, uid, ids, body=msg, context=context)
1237
1238     def do_delegation_send_note(self, cr, uid, ids, context=None):
1239         for task in self.browse(cr, uid, ids, context=context):
1240             msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1241             self.message_post(cr, uid, [task.id], body=msg, context=context)
1242         return True
1243
1244
1245 class project_work(osv.osv):
1246     _name = "project.task.work"
1247     _description = "Project Task Work"
1248     _columns = {
1249         'name': fields.char('Work summary', size=128),
1250         'date': fields.datetime('Date', select="1"),
1251         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1252         'hours': fields.float('Time Spent'),
1253         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1254         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1255     }
1256
1257     _defaults = {
1258         'user_id': lambda obj, cr, uid, context: uid,
1259         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1260     }
1261
1262     _order = "date desc"
1263     def create(self, cr, uid, vals, *args, **kwargs):
1264         if 'hours' in vals and (not vals['hours']):
1265             vals['hours'] = 0.00
1266         if 'task_id' in vals:
1267             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1268         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1269
1270     def write(self, cr, uid, ids, vals, context=None):
1271         if 'hours' in vals and (not vals['hours']):
1272             vals['hours'] = 0.00
1273         if 'hours' in vals:
1274             for work in self.browse(cr, uid, ids, context=context):
1275                 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))
1276         return super(project_work,self).write(cr, uid, ids, vals, context)
1277
1278     def unlink(self, cr, uid, ids, *args, **kwargs):
1279         for work in self.browse(cr, uid, ids):
1280             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1281         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1282
1283
1284 class account_analytic_account(osv.osv):
1285     _inherit = 'account.analytic.account'
1286     _description = 'Analytic Account'
1287     _columns = {
1288         '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"),
1289         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1290     }
1291
1292     def on_change_template(self, cr, uid, ids, template_id, context=None):
1293         res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1294         if template_id and 'value' in res:
1295             template = self.browse(cr, uid, template_id, context=context)
1296             res['value']['use_tasks'] = template.use_tasks
1297         return res
1298
1299     def _trigger_project_creation(self, cr, uid, vals, context=None):
1300         '''
1301         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.
1302         '''
1303         if context is None: context = {}
1304         return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1305
1306     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1307         '''
1308         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.
1309         '''
1310         project_pool = self.pool.get('project.project')
1311         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1312         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1313             project_values = {
1314                 'name': vals.get('name'),
1315                 'analytic_account_id': analytic_account_id,
1316             }
1317             return project_pool.create(cr, uid, project_values, context=context)
1318         return False
1319
1320     def create(self, cr, uid, vals, context=None):
1321         if context is None:
1322             context = {}
1323         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1324             vals['child_ids'] = []
1325         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1326         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1327         return analytic_account_id
1328
1329     def write(self, cr, uid, ids, vals, context=None):
1330         name = vals.get('name')
1331         for account in self.browse(cr, uid, ids, context=context):
1332             if not name:
1333                 vals['name'] = account.name
1334             self.project_create(cr, uid, account.id, vals, context=context)
1335         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1336
1337     def unlink(self, cr, uid, ids, *args, **kwargs):
1338         project_obj = self.pool.get('project.project')
1339         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1340         if analytic_ids:
1341             raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1342         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1343
1344 class project_project(osv.osv):
1345     _inherit = 'project.project'
1346     _defaults = {
1347         'use_tasks': True
1348     }
1349
1350
1351 #
1352 # Tasks History, used for cumulative flow charts (Lean/Agile)
1353 #
1354
1355 class project_task_history(osv.osv):
1356     _name = 'project.task.history'
1357     _description = 'History of Tasks'
1358     _rec_name = 'task_id'
1359     _log_access = False
1360     def _get_date(self, cr, uid, ids, name, arg, context=None):
1361         result = {}
1362         for history in self.browse(cr, uid, ids, context=context):
1363             if history.state in ('done','cancelled'):
1364                 result[history.id] = history.date
1365                 continue
1366             cr.execute('''select
1367                     date
1368                 from
1369                     project_task_history
1370                 where
1371                     task_id=%s and
1372                     id>%s
1373                 order by id limit 1''', (history.task_id.id, history.id))
1374             res = cr.fetchone()
1375             result[history.id] = res and res[0] or False
1376         return result
1377
1378     def _get_related_date(self, cr, uid, ids, context=None):
1379         result = []
1380         for history in self.browse(cr, uid, ids, context=context):
1381             cr.execute('''select
1382                     id
1383                 from
1384                     project_task_history
1385                 where
1386                     task_id=%s and
1387                     id<%s
1388                 order by id desc limit 1''', (history.task_id.id, history.id))
1389             res = cr.fetchone()
1390             if res:
1391                 result.append(res[0])
1392         return result
1393
1394     _columns = {
1395         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1396         'type_id': fields.many2one('project.task.type', 'Stage'),
1397         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1398         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1399         'date': fields.date('Date', select=True),
1400         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1401             'project.task.history': (_get_related_date, None, 20)
1402         }),
1403         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1404         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1405         'user_id': fields.many2one('res.users', 'Responsible'),
1406     }
1407     _defaults = {
1408         'date': fields.date.context_today,
1409     }
1410
1411
1412 class project_task_history_cumulative(osv.osv):
1413     _name = 'project.task.history.cumulative'
1414     _table = 'project_task_history_cumulative'
1415     _inherit = 'project.task.history'
1416     _auto = False
1417     _columns = {
1418         'end_date': fields.date('End Date'),
1419         'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1420     }
1421     def init(self, cr):
1422         cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1423             SELECT
1424                 history.date::varchar||'-'||history.history_id::varchar as id,
1425                 history.date as end_date,
1426                 *
1427             FROM (
1428                 SELECT
1429                     id as history_id,
1430                     date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1431                     task_id, type_id, user_id, kanban_state, state,
1432                     greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1433                 FROM
1434                     project_task_history
1435             ) as history
1436         )
1437         """)
1438
1439
1440 class project_category(osv.osv):
1441     """ Category of project's task (or issue) """
1442     _name = "project.category"
1443     _description = "Category of project's task, issue, ..."
1444     _columns = {
1445         'name': fields.char('Name', size=64, required=True, translate=True),
1446     }