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