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