[FIX]project: Error when no date given in the selected project in Scheduling Task...
[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 import time
23 from lxml import etree
24 from datetime import datetime, date
25
26 import tools
27 from base_status.base_stage import base_stage
28 from osv import fields, osv
29 from openerp.addons.resource.faces import task as Task
30 from tools.translate import _
31 from openerp import SUPERUSER_ID
32
33 _TASK_STATE = [('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done'), ('cancelled', 'Cancelled')]
34
35 class project_task_type(osv.osv):
36     _name = 'project.task.type'
37     _description = 'Task Stage'
38     _order = 'sequence'
39     _columns = {
40         'name': fields.char('Stage Name', required=True, size=64, translate=True),
41         'description': fields.text('Description'),
42         'sequence': fields.integer('Sequence'),
43         'case_default': fields.boolean('Common to All Projects',
44                         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."),
45         'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
46         'state': fields.selection(_TASK_STATE, 'Related Status', required=True,
47                         help="The status of your document is automatically changed regarding the selected stage. " \
48                             "For example, if a stage is related to the status 'Close', when your document reaches this stage, it is automatically closed."),
49         'fold': fields.boolean('Hide in views if empty',
50                         help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
51     }
52     _defaults = {
53         'sequence': 1,
54         'state': 'open',
55         'fold': False,
56         'case_default': True,
57     }
58     _order = 'sequence'
59
60 def short_name(name):
61         """Keep first word(s) of name to make it small enough
62            but distinctive"""
63         if not name: return name
64         # keep 7 chars + end of the last word
65         keep_words = name[:7].strip().split()
66         return ' '.join(name.split()[:len(keep_words)])
67
68 class project(osv.osv):
69     _name = "project.project"
70     _description = "Project"
71     _inherits = {'account.analytic.account': "analytic_account_id",
72                  "mail.alias": "alias_id"}
73     _inherit = ['mail.thread', 'ir.needaction_mixin']
74
75     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
76         if user == 1:
77             return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
78         if context and context.get('user_preference'):
79                 cr.execute("""SELECT project.id FROM project_project project
80                            LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
81                            LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
82                            WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
83                 return [(r[0]) for r in cr.fetchall()]
84         return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
85             context=context, count=count)
86
87     def _complete_name(self, cr, uid, ids, name, args, context=None):
88         res = {}
89         for m in self.browse(cr, uid, ids, context=context):
90             res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
91         return res
92
93     def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
94         partner_obj = self.pool.get('res.partner')
95         if not part:
96             return {'value':{}}
97         val = {}
98         if 'pricelist_id' in self.fields_get(cr, uid, context=context):
99             pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
100             pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
101             val['pricelist_id'] = pricelist_id
102         return {'value': val}
103
104     def _get_projects_from_tasks(self, cr, uid, task_ids, context=None):
105         tasks = self.pool.get('project.task').browse(cr, uid, task_ids, context=context)
106         project_ids = [task.project_id.id for task in tasks if task.project_id]
107         return self.pool.get('project.project')._get_project_and_parents(cr, uid, project_ids, context)
108
109     def _get_project_and_parents(self, cr, uid, ids, context=None):
110         """ return the project ids and all their parent projects """
111         res = set(ids)
112         while ids:
113             cr.execute("""
114                 SELECT DISTINCT parent.id
115                 FROM project_project project, project_project parent, account_analytic_account account
116                 WHERE project.analytic_account_id = account.id
117                 AND parent.analytic_account_id = account.parent_id
118                 AND project.id IN %s
119                 """, (tuple(ids),))
120             ids = [t[0] for t in cr.fetchall()]
121             res.update(ids)
122         return list(res)
123
124     def _get_project_and_children(self, cr, uid, ids, context=None):
125         """ retrieve all children projects of project ids;
126             return a dictionary mapping each project to its parent project (or None)
127         """
128         res = dict.fromkeys(ids, None)
129         while ids:
130             cr.execute("""
131                 SELECT project.id, parent.id
132                 FROM project_project project, project_project parent, account_analytic_account account
133                 WHERE project.analytic_account_id = account.id
134                 AND parent.analytic_account_id = account.parent_id
135                 AND parent.id IN %s
136                 """, (tuple(ids),))
137             dic = dict(cr.fetchall())
138             res.update(dic)
139             ids = dic.keys()
140         return res
141
142     def _progress_rate(self, cr, uid, ids, names, arg, context=None):
143         child_parent = self._get_project_and_children(cr, uid, ids, context)
144         # compute planned_hours, total_hours, effective_hours specific to each project
145         cr.execute("""
146             SELECT project_id, COALESCE(SUM(planned_hours), 0.0),
147                 COALESCE(SUM(total_hours), 0.0), COALESCE(SUM(effective_hours), 0.0)
148             FROM project_task WHERE project_id IN %s AND state <> 'cancelled'
149             GROUP BY project_id
150             """, (tuple(child_parent.keys()),))
151         # aggregate results into res
152         res = dict([(id, {'planned_hours':0.0,'total_hours':0.0,'effective_hours':0.0}) for id in ids])
153         for id, planned, total, effective in cr.fetchall():
154             # add the values specific to id to all parent projects of id in the result
155             while id:
156                 if id in ids:
157                     res[id]['planned_hours'] += planned
158                     res[id]['total_hours'] += total
159                     res[id]['effective_hours'] += effective
160                 id = child_parent[id]
161         # compute progress rates
162         for id in ids:
163             if res[id]['total_hours']:
164                 res[id]['progress_rate'] = round(100.0 * res[id]['effective_hours'] / res[id]['total_hours'], 2)
165             else:
166                 res[id]['progress_rate'] = 0.0
167         return res
168
169     def unlink(self, cr, uid, ids, *args, **kwargs):
170         alias_ids = []
171         mail_alias = self.pool.get('mail.alias')
172         for proj in self.browse(cr, uid, ids):
173             if proj.tasks:
174                 raise osv.except_osv(_('Invalid Action!'),
175                                      _('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.'))
176             elif proj.alias_id:
177                 alias_ids.append(proj.alias_id.id)
178         res =  super(project, self).unlink(cr, uid, ids, *args, **kwargs)
179         mail_alias.unlink(cr, uid, alias_ids, *args, **kwargs)
180         return res
181
182     def _task_count(self, cr, uid, ids, field_name, arg, context=None):
183         res = dict.fromkeys(ids, 0)
184         task_ids = self.pool.get('project.task').search(cr, uid, [('project_id', 'in', ids)])
185         for task in self.pool.get('project.task').browse(cr, uid, task_ids, context):
186             res[task.project_id.id] += 1
187         return res
188
189     def _get_alias_models(self, cr, uid, context=None):
190         """Overriden in project_issue to offer more options"""
191         return [('project.task', "Tasks")]
192
193     # Lambda indirection method to avoid passing a copy of the overridable method when declaring the field
194     _alias_models = lambda self, *args, **kwargs: self._get_alias_models(*args, **kwargs)
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=None, context=None):
315         if context is None:
316             context = {}
317         if default is None:
318             default = {}
319
320         context['active_test'] = False
321         default['state'] = 'open'
322         default['tasks'] = []
323         default.pop('alias_name', None)
324         default.pop('alias_id', None)
325         proj = self.browse(cr, uid, id, context=context)
326         if not default.get('name', False):
327             default.update(name=_("%s (copy)") % (proj.name))
328         res = super(project, self).copy(cr, uid, id, default, context)
329         self.map_tasks(cr,uid,id,res,context)
330         return res
331
332     def duplicate_template(self, cr, uid, ids, context=None):
333         if context is None:
334             context = {}
335         data_obj = self.pool.get('ir.model.data')
336         result = []
337         for proj in self.browse(cr, uid, ids, context=context):
338             parent_id = context.get('parent_id', False)
339             context.update({'analytic_project_copy': True})
340             new_date_start = time.strftime('%Y-%m-%d')
341             new_date_end = False
342             if proj.date_start and proj.date:
343                 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
344                 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
345                 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
346             context.update({'copy':True})
347             new_id = self.copy(cr, uid, proj.id, default = {
348                                     'name':_("%s (copy)") % (proj.name),
349                                     'state':'open',
350                                     'date_start':new_date_start,
351                                     'date':new_date_end,
352                                     'parent_id':parent_id}, context=context)
353             result.append(new_id)
354
355             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
356             parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
357             if child_ids:
358                 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
359
360         if result and len(result):
361             res_id = result[0]
362             form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
363             form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
364             tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
365             tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
366             search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
367             search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
368             return {
369                 'name': _('Projects'),
370                 'view_type': 'form',
371                 'view_mode': 'form,tree',
372                 'res_model': 'project.project',
373                 'view_id': False,
374                 'res_id': res_id,
375                 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
376                 'type': 'ir.actions.act_window',
377                 'search_view_id': search_view['res_id'],
378                 'nodestroy': True
379             }
380
381     # set active value for a project, its sub projects and its tasks
382     def setActive(self, cr, uid, ids, value=True, context=None):
383         task_obj = self.pool.get('project.task')
384         for proj in self.browse(cr, uid, ids, context=None):
385             self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
386             cr.execute('select id from project_task where project_id=%s', (proj.id,))
387             tasks_id = [x[0] for x in cr.fetchall()]
388             if tasks_id:
389                 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
390             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
391             if child_ids:
392                 self.setActive(cr, uid, child_ids, value, context=None)
393         return True
394
395     def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
396         context = context or {}
397         if type(ids) in (long, int,):
398             ids = [ids]
399         projects = self.browse(cr, uid, ids, context=context)
400
401         for project in projects:
402             if (not project.members) and force_members:
403                 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s' !") % (project.name,))
404
405         resource_pool = self.pool.get('resource.resource')
406
407         result = "from openerp.addons.resource.faces import *\n"
408         result += "import datetime\n"
409         for project in self.browse(cr, uid, ids, context=context):
410             u_ids = [i.id for i in project.members]
411             if project.user_id and (project.user_id.id not in u_ids):
412                 u_ids.append(project.user_id.id)
413             for task in project.tasks:
414                 if task.state in ('done','cancelled'):
415                     continue
416                 if task.user_id and (task.user_id.id not in u_ids):
417                     u_ids.append(task.user_id.id)
418             calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
419             resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
420             for key, vals in resource_objs.items():
421                 result +='''
422 class User_%s(Resource):
423     efficiency = %s
424 ''' % (key,  vals.get('efficiency', False))
425
426         result += '''
427 def Project():
428         '''
429         return result
430
431     def _schedule_project(self, cr, uid, project, context=None):
432         if not project.date_start:
433             raise osv.except_osv(_('Warning !'),_("You must assign Start Date on the Project '%s' !") % (project.name,)) 
434         resource_pool = self.pool.get('resource.resource')
435         calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
436         working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
437         # TODO: check if we need working_..., default values are ok.
438         puids = [x.id for x in project.members]
439         if project.user_id:
440             puids.append(project.user_id.id)
441         result = """
442   def Project_%d():
443     start = \'%s\'
444     working_days = %s
445     resource = %s
446 """       % (
447             project.id,
448             project.date_start, working_days,
449             '|'.join(['User_'+str(x) for x in puids])
450         )
451         vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
452         if vacation:
453             result+= """
454     vacation = %s
455 """ %   ( vacation, )
456         return result
457
458     #TODO: DO Resource allocation and compute availability
459     def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
460         if context ==  None:
461             context = {}
462         allocation = {}
463         return allocation
464
465     def schedule_tasks(self, cr, uid, ids, context=None):
466         context = context or {}
467         if type(ids) in (long, int,):
468             ids = [ids]
469         projects = self.browse(cr, uid, ids, context=context)
470         result = self._schedule_header(cr, uid, ids, False, context=context)
471         for project in projects:
472             result += self._schedule_project(cr, uid, project, context=context)
473             result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
474
475         local_dict = {}
476         exec result in local_dict
477         projects_gantt = Task.BalancedProject(local_dict['Project'])
478
479         for project in projects:
480             project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
481             for task in project.tasks:
482                 if task.state in ('done','cancelled'):
483                     continue
484
485                 p = getattr(project_gantt, 'Task_%d' % (task.id,))
486
487                 self.pool.get('project.task').write(cr, uid, [task.id], {
488                     'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
489                     'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
490                 }, context=context)
491                 if (not task.user_id) and (p.booked_resource):
492                     self.pool.get('project.task').write(cr, uid, [task.id], {
493                         'user_id': int(p.booked_resource[0].name[5:]),
494                     }, context=context)
495         return True
496
497     # ------------------------------------------------
498     # OpenChatter methods and notifications
499     # ------------------------------------------------
500
501     def create(self, cr, uid, vals, context=None):
502         if context is None: context = {}
503         # Prevent double project creation when 'use_tasks' is checked!
504         context = dict(context, project_creation_in_progress=True)
505         mail_alias = self.pool.get('mail.alias')
506         if not vals.get('alias_id'):
507             vals.pop('alias_name', None) # prevent errors during copy()
508             alias_id = mail_alias.create_unique_alias(cr, uid,
509                           # Using '+' allows using subaddressing for those who don't
510                           # have a catchall domain setup.
511                           {'alias_name': "project+"+short_name(vals['name'])},
512                           model_name=vals.get('alias_model', 'project.task'),
513                           context=context)
514             vals['alias_id'] = alias_id
515         vals['type'] = 'contract'
516         project_id = super(project, self).create(cr, uid, vals, context)
517         mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
518         self.create_send_note(cr, uid, [project_id], context=context)
519         return project_id
520
521     def create_send_note(self, cr, uid, ids, context=None):
522         return self.message_post(cr, uid, ids, body=_("Project has been <b>created</b>."), context=context)
523
524     def set_open_send_note(self, cr, uid, ids, context=None):
525         return self.message_post(cr, uid, ids, body=_("Project has been <b>opened</b>."), context=context)
526
527     def set_pending_send_note(self, cr, uid, ids, context=None):
528         return self.message_post(cr, uid, ids, body=_("Project is now <b>pending</b>."), context=context)
529
530     def set_cancel_send_note(self, cr, uid, ids, context=None):
531         return self.message_post(cr, uid, ids, body=_("Project has been <b>canceled</b>."), context=context)
532
533     def set_close_send_note(self, cr, uid, ids, context=None):
534         return self.message_post(cr, uid, ids, body=_("Project has been <b>closed</b>."), 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=None, context=None):
688         if default is None:
689             default = {}
690         default = default or {}
691         default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
692         if not default.get('remaining_hours', False):
693             default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
694         default['active'] = True
695         if not default.get('name', False):
696             default['name'] = self.browse(cr, uid, id, context=context).name or ''
697             if not context.get('copy',False):
698                 new_name = _("%s (copy)") % (default.get('name', ''))
699                 default.update({'name':new_name})
700         return super(task, self).copy_data(cr, uid, id, default, context)
701
702     def _is_template(self, cr, uid, ids, field_name, arg, context=None):
703         res = {}
704         for task in self.browse(cr, uid, ids, context=context):
705             res[task.id] = True
706             if task.project_id:
707                 if task.project_id.active == False or task.project_id.state == 'template':
708                     res[task.id] = False
709         return res
710
711     def _get_task(self, cr, uid, ids, context=None):
712         result = {}
713         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
714             if work.task_id: result[work.task_id.id] = True
715         return result.keys()
716
717     _columns = {
718         '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."),
719         'name': fields.char('Task Summary', size=128, required=True, select=True),
720         'description': fields.text('Description'),
721         'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
722         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
723         'stage_id': fields.many2one('project.task.type', 'Stage',
724                         domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
725         'state': fields.related('stage_id', 'state', type="selection", store=True,
726                 selection=_TASK_STATE, string="Status", readonly=True,
727                 help='The status is set to \'Draft\', when a case is created.\
728                       If the case is in progress the status is set to \'Open\'.\
729                       When the case is over, the status is set to \'Done\'.\
730                       If the case needs to be reviewed then the status is \
731                       set to \'Pending\'.'),
732         'categ_ids': fields.many2many('project.category', string='Tags'),
733         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State',
734                                          help="A task's kanban state indicates special situations affecting it:\n"
735                                               " * Normal is the default situation\n"
736                                               " * Blocked indicates something is preventing the progress of this task\n"
737                                               " * Ready To Pull indicates the task is ready to be pulled to the next stage",
738                                          readonly=True, required=False),
739         'create_date': fields.datetime('Create Date', readonly=True,select=True),
740         'date_start': fields.datetime('Starting Date',select=True),
741         'date_end': fields.datetime('Ending Date',select=True),
742         'date_deadline': fields.date('Deadline',select=True),
743         'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
744         'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
745         'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
746         'notes': fields.text('Notes'),
747         '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.'),
748         'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
749             store = {
750                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
751                 'project.task.work': (_get_task, ['hours'], 10),
752             }),
753         'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
754         'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
755             store = {
756                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
757                 'project.task.work': (_get_task, ['hours'], 10),
758             }),
759         '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",
760             store = {
761                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
762                 'project.task.work': (_get_task, ['hours'], 10),
763             }),
764         '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.",
765             store = {
766                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
767                 'project.task.work': (_get_task, ['hours'], 10),
768             }),
769         'user_id': fields.many2one('res.users', 'Assigned to'),
770         'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
771         'partner_id': fields.many2one('res.partner', 'Customer'),
772         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
773         'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
774         'company_id': fields.many2one('res.company', 'Company'),
775         'id': fields.integer('ID', readonly=True),
776         'color': fields.integer('Color Index'),
777         'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
778     }
779     _defaults = {
780         'stage_id': _get_default_stage_id,
781         'project_id': _get_default_project_id,
782         'kanban_state': 'normal',
783         'priority': '2',
784         'progress': 0,
785         'sequence': 10,
786         'active': True,
787         'user_id': lambda obj, cr, uid, context: uid,
788         'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c),
789     }
790     _order = "priority, sequence, date_start, name, id"
791
792     def set_priority(self, cr, uid, ids, priority, *args):
793         """Set task priority
794         """
795         return self.write(cr, uid, ids, {'priority' : priority})
796
797     def set_high_priority(self, cr, uid, ids, *args):
798         """Set task priority to high
799         """
800         return self.set_priority(cr, uid, ids, '1')
801
802     def set_normal_priority(self, cr, uid, ids, *args):
803         """Set task priority to normal
804         """
805         return self.set_priority(cr, uid, ids, '2')
806
807     def _check_recursion(self, cr, uid, ids, context=None):
808         for id in ids:
809             visited_branch = set()
810             visited_node = set()
811             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
812             if not res:
813                 return False
814
815         return True
816
817     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
818         if id in visited_branch: #Cycle
819             return False
820
821         if id in visited_node: #Already tested don't work one more time for nothing
822             return True
823
824         visited_branch.add(id)
825         visited_node.add(id)
826
827         #visit child using DFS
828         task = self.browse(cr, uid, id, context=context)
829         for child in task.child_ids:
830             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
831             if not res:
832                 return False
833
834         visited_branch.remove(id)
835         return True
836
837     def _check_dates(self, cr, uid, ids, context=None):
838         if context == None:
839             context = {}
840         obj_task = self.browse(cr, uid, ids[0], context=context)
841         start = obj_task.date_start or False
842         end = obj_task.date_end or False
843         if start and end :
844             if start > end:
845                 return False
846         return True
847
848     _constraints = [
849         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
850         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
851     ]
852
853     # Override view according to the company definition
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=None, context=None):
1020         """
1021         Delegate Task to another users.
1022         """
1023         if delegate_data is None:
1024             delegate_data = {}
1025         assert delegate_data['user_id'], _("Delegated User should be specified")
1026         delegated_tasks = {}
1027         for task in self.browse(cr, uid, ids, context=context):
1028             delegated_task_id = self.copy(cr, uid, task.id, {
1029                 'name': delegate_data['name'],
1030                 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1031                 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1032                 'planned_hours': delegate_data['planned_hours'] or 0.0,
1033                 'parent_ids': [(6, 0, [task.id])],
1034                 'description': delegate_data['new_task_description'] or '',
1035                 'child_ids': [],
1036                 'work_ids': []
1037             }, context=context)
1038             self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1039             newname = delegate_data['prefix'] or ''
1040             task.write({
1041                 'remaining_hours': delegate_data['planned_hours_me'],
1042                 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1043                 'name': newname,
1044             }, context=context)
1045             if delegate_data['state'] == 'pending':
1046                 self.do_pending(cr, uid, [task.id], context=context)
1047             elif delegate_data['state'] == 'done':
1048                 self.do_close(cr, uid, [task.id], context=context)
1049             self.do_delegation_send_note(cr, uid, [task.id], context)
1050             delegated_tasks[task.id] = delegated_task_id
1051         return delegated_tasks
1052
1053     def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1054         for task in self.browse(cr, uid, ids, context=context):
1055             if (task.state=='draft') or (task.planned_hours==0.0):
1056                 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1057         self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1058         return True
1059
1060     def set_remaining_time_1(self, cr, uid, ids, context=None):
1061         return self.set_remaining_time(cr, uid, ids, 1.0, context)
1062
1063     def set_remaining_time_2(self, cr, uid, ids, context=None):
1064         return self.set_remaining_time(cr, uid, ids, 2.0, context)
1065
1066     def set_remaining_time_5(self, cr, uid, ids, context=None):
1067         return self.set_remaining_time(cr, uid, ids, 5.0, context)
1068
1069     def set_remaining_time_10(self, cr, uid, ids, context=None):
1070         return self.set_remaining_time(cr, uid, ids, 10.0, context)
1071
1072     def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1073         self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1074         return False
1075
1076     def set_kanban_state_normal(self, cr, uid, ids, context=None):
1077         self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1078         return False
1079
1080     def set_kanban_state_done(self, cr, uid, ids, context=None):
1081         self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1082         return False
1083
1084     def _store_history(self, cr, uid, ids, context=None):
1085         for task in self.browse(cr, uid, ids, context=context):
1086             self.pool.get('project.task.history').create(cr, uid, {
1087                 'task_id': task.id,
1088                 'remaining_hours': task.remaining_hours,
1089                 'planned_hours': task.planned_hours,
1090                 'kanban_state': task.kanban_state,
1091                 'type_id': task.stage_id.id,
1092                 'state': task.state,
1093                 'user_id': task.user_id.id
1094
1095             }, context=context)
1096         return True
1097
1098     def create(self, cr, uid, vals, context=None):
1099         task_id = super(task, self).create(cr, uid, vals, context=context)
1100         task_record = self.browse(cr, uid, task_id, context=context)
1101         if task_record.project_id:
1102             project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1103             self.message_subscribe(cr, uid, [task_id], project_follower_ids,
1104                 context=context)
1105         self._store_history(cr, uid, [task_id], context=context)
1106         self.create_send_note(cr, uid, [task_id], context=context)
1107         return task_id
1108
1109     # Overridden to reset the kanban_state to normal whenever
1110     # the stage (stage_id) of the task changes.
1111     def write(self, cr, uid, ids, vals, context=None):
1112         if isinstance(ids, (int, long)):
1113             ids = [ids]
1114         if vals.get('project_id'):
1115             project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1116             vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1117         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1118             new_stage = vals.get('stage_id')
1119             vals_reset_kstate = dict(vals, kanban_state='normal')
1120             for t in self.browse(cr, uid, ids, context=context):
1121                 #TO FIX:Kanban view doesn't raise warning
1122                 #stages = [stage.id for stage in t.project_id.type_ids]
1123                 #if new_stage not in stages:
1124                     #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1125                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1126                 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1127                 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1128             result = True
1129         else:
1130             result = super(task,self).write(cr, uid, ids, vals, context=context)
1131         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1132             self._store_history(cr, uid, ids, context=context)
1133         return result
1134
1135     def unlink(self, cr, uid, ids, context=None):
1136         if context == None:
1137             context = {}
1138         self._check_child_task(cr, uid, ids, context=context)
1139         res = super(task, self).unlink(cr, uid, ids, context)
1140         return res
1141
1142     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1143         context = context or {}
1144         result = ""
1145         ident = ' '*ident
1146         for task in tasks:
1147             if task.state in ('done','cancelled'):
1148                 continue
1149             result += '''
1150 %sdef Task_%s():
1151 %s  todo = \"%.2fH\"
1152 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1153             start = []
1154             for t2 in task.parent_ids:
1155                 start.append("up.Task_%s.end" % (t2.id,))
1156             if start:
1157                 result += '''
1158 %s  start = max(%s)
1159 ''' % (ident,','.join(start))
1160
1161             if task.user_id:
1162                 result += '''
1163 %s  resource = %s
1164 ''' % (ident, 'User_'+str(task.user_id.id))
1165
1166         result += "\n"
1167         return result
1168
1169     # ---------------------------------------------------
1170     # Mail gateway
1171     # ---------------------------------------------------
1172
1173     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1174         """ Override to updates the document according to the email. """
1175         if custom_values is None: custom_values = {}
1176         custom_values.update({
1177             'name': msg.get('subject'),
1178             'planned_hours': 0.0,
1179         })
1180         return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1181
1182     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1183         """ Override to update the task according to the email. """
1184         if update_vals is None: update_vals = {}
1185         act = False
1186         maps = {
1187             'cost':'planned_hours',
1188         }
1189         for line in msg['body'].split('\n'):
1190             line = line.strip()
1191             res = tools.misc.command_re.match(line)
1192             if res:
1193                 match = res.group(1).lower()
1194                 field = maps.get(match)
1195                 if field:
1196                     try:
1197                         update_vals[field] = float(res.group(2).lower())
1198                     except (ValueError, TypeError):
1199                         pass
1200                 elif match.lower() == 'state' \
1201                         and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1202                     act = 'do_%s' % res.group(2).lower()
1203         if act:
1204             getattr(self,act)(cr, uid, ids, context=context)
1205         return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1206
1207     # ---------------------------------------------------
1208     # OpenChatter methods and notifications
1209     # ---------------------------------------------------
1210
1211     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1212         """ Override of default prefix for notifications. """
1213         return 'Task'
1214
1215     def get_needaction_user_ids(self, cr, uid, ids, context=None):
1216         """ Returns the user_ids that have to perform an action.
1217             Add to the previous results given by super the document responsible
1218             when in draft mode.
1219             :return: dict { record_id: [user_ids], }
1220         """
1221         result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1222         for obj in self.browse(cr, uid, ids, context=context):
1223             if obj.state == 'draft' and obj.user_id:
1224                 result[obj.id].append(obj.user_id.id)
1225         return result
1226
1227     def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1228         """ Add 'user_id' and 'manager_id' to the monitored fields """
1229         res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1230         return res + ['user_id', 'manager_id']
1231
1232     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1233         """ Override of the (void) default notification method. """
1234         stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1235         return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1236             context=context)
1237
1238     def create_send_note(self, cr, uid, ids, context=None):
1239         return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1240
1241     def case_draft_send_note(self, cr, uid, ids, context=None):
1242         return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1243
1244     def do_delegation_send_note(self, cr, uid, ids, context=None):
1245         for task in self.browse(cr, uid, ids, context=context):
1246             msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1247             self.message_post(cr, uid, [task.id], body=msg, context=context)
1248         return True
1249
1250
1251 class project_work(osv.osv):
1252     _name = "project.task.work"
1253     _description = "Project Task Work"
1254     _columns = {
1255         'name': fields.char('Work summary', size=128),
1256         'date': fields.datetime('Date', select="1"),
1257         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1258         'hours': fields.float('Time Spent'),
1259         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1260         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1261     }
1262
1263     _defaults = {
1264         'user_id': lambda obj, cr, uid, context: uid,
1265         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1266     }
1267
1268     _order = "date desc"
1269     def create(self, cr, uid, vals, *args, **kwargs):
1270         if 'hours' in vals and (not vals['hours']):
1271             vals['hours'] = 0.00
1272         if 'task_id' in vals:
1273             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1274         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1275
1276     def write(self, cr, uid, ids, vals, context=None):
1277         if 'hours' in vals and (not vals['hours']):
1278             vals['hours'] = 0.00
1279         if 'hours' in vals:
1280             for work in self.browse(cr, uid, ids, context=context):
1281                 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))
1282         return super(project_work,self).write(cr, uid, ids, vals, context)
1283
1284     def unlink(self, cr, uid, ids, *args, **kwargs):
1285         for work in self.browse(cr, uid, ids):
1286             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1287         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1288
1289
1290 class account_analytic_account(osv.osv):
1291     _inherit = 'account.analytic.account'
1292     _description = 'Analytic Account'
1293     _columns = {
1294         '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"),
1295         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1296     }
1297
1298     def on_change_template(self, cr, uid, ids, template_id, context=None):
1299         res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1300         if template_id and 'value' in res:
1301             template = self.browse(cr, uid, template_id, context=context)
1302             res['value']['use_tasks'] = template.use_tasks
1303         return res
1304
1305     def _trigger_project_creation(self, cr, uid, vals, context=None):
1306         '''
1307         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.
1308         '''
1309         if context is None: context = {}
1310         return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1311
1312     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1313         '''
1314         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.
1315         '''
1316         project_pool = self.pool.get('project.project')
1317         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1318         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1319             project_values = {
1320                 'name': vals.get('name'),
1321                 'analytic_account_id': analytic_account_id,
1322             }
1323             return project_pool.create(cr, uid, project_values, context=context)
1324         return False
1325
1326     def create(self, cr, uid, vals, context=None):
1327         if context is None:
1328             context = {}
1329         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1330             vals['child_ids'] = []
1331         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1332         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1333         return analytic_account_id
1334
1335     def write(self, cr, uid, ids, vals, context=None):
1336         name = vals.get('name')
1337         for account in self.browse(cr, uid, ids, context=context):
1338             if not name:
1339                 vals['name'] = account.name
1340             self.project_create(cr, uid, account.id, vals, context=context)
1341         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1342
1343     def unlink(self, cr, uid, ids, *args, **kwargs):
1344         project_obj = self.pool.get('project.project')
1345         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1346         if analytic_ids:
1347             raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1348         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1349
1350 class project_project(osv.osv):
1351     _inherit = 'project.project'
1352     _defaults = {
1353         'use_tasks': True
1354     }
1355
1356 class project_task_history(osv.osv):
1357     """
1358     Tasks History, used for cumulative flow charts (Lean/Agile)
1359     """
1360     _name = 'project.task.history'
1361     _description = 'History of Tasks'
1362     _rec_name = 'task_id'
1363     _log_access = False
1364
1365     def _get_date(self, cr, uid, ids, name, arg, context=None):
1366         result = {}
1367         for history in self.browse(cr, uid, ids, context=context):
1368             if history.state in ('done','cancelled'):
1369                 result[history.id] = history.date
1370                 continue
1371             cr.execute('''select
1372                     date
1373                 from
1374                     project_task_history
1375                 where
1376                     task_id=%s and
1377                     id>%s
1378                 order by id limit 1''', (history.task_id.id, history.id))
1379             res = cr.fetchone()
1380             result[history.id] = res and res[0] or False
1381         return result
1382
1383     def _get_related_date(self, cr, uid, ids, context=None):
1384         result = []
1385         for history in self.browse(cr, uid, ids, context=context):
1386             cr.execute('''select
1387                     id
1388                 from
1389                     project_task_history
1390                 where
1391                     task_id=%s and
1392                     id<%s
1393                 order by id desc limit 1''', (history.task_id.id, history.id))
1394             res = cr.fetchone()
1395             if res:
1396                 result.append(res[0])
1397         return result
1398
1399     _columns = {
1400         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1401         'type_id': fields.many2one('project.task.type', 'Stage'),
1402         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1403         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1404         'date': fields.date('Date', select=True),
1405         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1406             'project.task.history': (_get_related_date, None, 20)
1407         }),
1408         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1409         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1410         'user_id': fields.many2one('res.users', 'Responsible'),
1411     }
1412     _defaults = {
1413         'date': fields.date.context_today,
1414     }
1415
1416 class project_task_history_cumulative(osv.osv):
1417     _name = 'project.task.history.cumulative'
1418     _table = 'project_task_history_cumulative'
1419     _inherit = 'project.task.history'
1420     _auto = False
1421
1422     _columns = {
1423         'end_date': fields.date('End Date'),
1424         'project_id': fields.many2one('project.project', 'Project'),
1425     }
1426
1427     def init(self, cr):
1428         tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1429
1430         cr.execute(""" CREATE 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                     h.id AS history_id,
1438                     h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1439                     h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1440                     greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1441                     t.project_id
1442                 FROM
1443                     project_task_history AS h
1444                     JOIN project_task AS t ON (h.task_id = t.id)
1445
1446             ) AS history
1447         )
1448         """)
1449
1450 class project_category(osv.osv):
1451     """ Category of project's task (or issue) """
1452     _name = "project.category"
1453     _description = "Category of project's task, issue, ..."
1454     _columns = {
1455         'name': fields.char('Name', size=64, required=True, translate=True),
1456     }