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