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