[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
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         self._store_history(cr, uid, [task_id], context=context)
1104         self.create_send_note(cr, uid, [task_id], context=context)
1105         return task_id
1106
1107     # Overridden to reset the kanban_state to normal whenever
1108     # the stage (stage_id) of the task changes.
1109     def write(self, cr, uid, ids, vals, context=None):
1110         if isinstance(ids, (int, long)):
1111             ids = [ids]
1112         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1113             new_stage = vals.get('stage_id')
1114             vals_reset_kstate = dict(vals, kanban_state='normal')
1115             for t in self.browse(cr, uid, ids, context=context):
1116                 #TO FIX:Kanban view doesn't raise warning
1117                 #stages = [stage.id for stage in t.project_id.type_ids]
1118                 #if new_stage not in stages:
1119                     #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1120                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1121                 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1122                 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1123             result = True
1124         else:
1125             result = super(task,self).write(cr, uid, ids, vals, context=context)
1126         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1127             self._store_history(cr, uid, ids, context=context)
1128         return result
1129
1130     def unlink(self, cr, uid, ids, context=None):
1131         if context == None:
1132             context = {}
1133         self._check_child_task(cr, uid, ids, context=context)
1134         res = super(task, self).unlink(cr, uid, ids, context)
1135         return res
1136
1137     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1138         context = context or {}
1139         result = ""
1140         ident = ' '*ident
1141         for task in tasks:
1142             if task.state in ('done','cancelled'):
1143                 continue
1144             result += '''
1145 %sdef Task_%s():
1146 %s  todo = \"%.2fH\"
1147 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1148             start = []
1149             for t2 in task.parent_ids:
1150                 start.append("up.Task_%s.end" % (t2.id,))
1151             if start:
1152                 result += '''
1153 %s  start = max(%s)
1154 ''' % (ident,','.join(start))
1155
1156             if task.user_id:
1157                 result += '''
1158 %s  resource = %s
1159 ''' % (ident, 'User_'+str(task.user_id.id))
1160
1161         result += "\n"
1162         return result
1163
1164     # ---------------------------------------------------
1165     # mail gateway
1166     # ---------------------------------------------------
1167
1168     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1169         """ Override to updates the document according to the email. """
1170         if custom_values is None: custom_values = {}
1171         custom_values.update({
1172             'name': subject,
1173             'planned_hours': 0.0,
1174             'subject': msg.get('subject'),
1175         })
1176         return super(project_tasks,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1177
1178     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1179         """ Override to update the task according to the email. """
1180         if update_vals is None: update_vals = {}
1181         act = False
1182         maps = {
1183             'cost':'planned_hours',
1184         }
1185         for line in msg['body'].split('\n'):
1186             line = line.strip()
1187             res = tools.misc.command_re.match(line)
1188             if res:
1189                 match = res.group(1).lower()
1190                 field = maps.get(match)
1191                 if field:
1192                     try:
1193                         update_vals[field] = float(res.group(2).lower())
1194                     except (ValueError, TypeError):
1195                         pass
1196                 elif match.lower() == 'state' \
1197                         and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1198                     act = 'do_%s' % res.group(2).lower()
1199         if act:
1200             getattr(self,act)(cr, uid, ids, context=context)
1201         return super(project_tasks,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1202
1203     # ---------------------------------------------------
1204     # OpenChatter methods and notifications
1205     # ---------------------------------------------------
1206
1207     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1208         """ Override of default prefix for notifications. """
1209         return 'Task'
1210
1211     def get_needaction_user_ids(self, cr, uid, ids, context=None):
1212         """ Returns the user_ids that have to perform an action.
1213             Add to the previous results given by super the document responsible
1214             when in draft mode.
1215             :return: dict { record_id: [user_ids], }
1216         """
1217         result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1218         for obj in self.browse(cr, uid, ids, context=context):
1219             if obj.state == 'draft' and obj.user_id:
1220                 result[obj.id].append(obj.user_id.id)
1221         return result
1222
1223     def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1224         """ Add 'user_id' and 'manager_id' to the monitored fields """
1225         res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1226         return res + ['user_id', 'manager_id']
1227
1228     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1229         """ Override of the (void) default notification method. """
1230         stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1231         return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
1232
1233     def create_send_note(self, cr, uid, ids, context=None):
1234         return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1235
1236     def case_draft_send_note(self, cr, uid, ids, context=None):
1237         msg = _('Task has been set as <b>draft</b>.')
1238         return self.message_post(cr, uid, ids, body=msg, context=context)
1239
1240     def do_delegation_send_note(self, cr, uid, ids, context=None):
1241         for task in self.browse(cr, uid, ids, context=context):
1242             msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1243             self.message_post(cr, uid, [task.id], body=msg, context=context)
1244         return True
1245
1246
1247 class project_work(osv.osv):
1248     _name = "project.task.work"
1249     _description = "Project Task Work"
1250     _columns = {
1251         'name': fields.char('Work summary', size=128),
1252         'date': fields.datetime('Date', select="1"),
1253         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1254         'hours': fields.float('Time Spent'),
1255         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1256         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1257     }
1258
1259     _defaults = {
1260         'user_id': lambda obj, cr, uid, context: uid,
1261         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1262     }
1263
1264     _order = "date desc"
1265     def create(self, cr, uid, vals, *args, **kwargs):
1266         if 'hours' in vals and (not vals['hours']):
1267             vals['hours'] = 0.00
1268         if 'task_id' in vals:
1269             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1270         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1271
1272     def write(self, cr, uid, ids, vals, context=None):
1273         if 'hours' in vals and (not vals['hours']):
1274             vals['hours'] = 0.00
1275         if 'hours' in vals:
1276             for work in self.browse(cr, uid, ids, context=context):
1277                 cr.execute('update project_task set remaining_hours=remaining_hours - %s + (%s) where id=%s', (vals.get('hours',0.0), work.hours, work.task_id.id))
1278         return super(project_work,self).write(cr, uid, ids, vals, context)
1279
1280     def unlink(self, cr, uid, ids, *args, **kwargs):
1281         for work in self.browse(cr, uid, ids):
1282             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1283         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1284
1285
1286 class account_analytic_account(osv.osv):
1287     _inherit = 'account.analytic.account'
1288     _description = 'Analytic Account'
1289     _columns = {
1290         'use_tasks': fields.boolean('Tasks',help="If check,this contract will be available in the project menu and you will be able to manage tasks or track issues"),
1291         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1292     }
1293
1294     def on_change_template(self, cr, uid, ids, template_id, context=None):
1295         res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1296         if template_id and 'value' in res:
1297             template = self.browse(cr, uid, template_id, context=context)
1298             res['value']['use_tasks'] = template.use_tasks
1299         return res
1300
1301     def _trigger_project_creation(self, cr, uid, vals, context=None):
1302         '''
1303         This function is used to decide if a project needs to be automatically created or not when an analytic account is created. It returns True if it needs to be so, False otherwise.
1304         '''
1305         if context is None: context = {}
1306         return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1307
1308     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1309         '''
1310         This function is called at the time of analytic account creation and is used to create a project automatically linked to it if the conditions are meet.
1311         '''
1312         project_pool = self.pool.get('project.project')
1313         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1314         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1315             project_values = {
1316                 'name': vals.get('name'),
1317                 'analytic_account_id': analytic_account_id,
1318             }
1319             return project_pool.create(cr, uid, project_values, context=context)
1320         return False
1321
1322     def create(self, cr, uid, vals, context=None):
1323         if context is None:
1324             context = {}
1325         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1326             vals['child_ids'] = []
1327         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1328         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1329         return analytic_account_id
1330
1331     def write(self, cr, uid, ids, vals, context=None):
1332         name = vals.get('name')
1333         for account in self.browse(cr, uid, ids, context=context):
1334             if not name:
1335                 vals['name'] = account.name
1336             self.project_create(cr, uid, account.id, vals, context=context)
1337         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1338
1339     def unlink(self, cr, uid, ids, *args, **kwargs):
1340         project_obj = self.pool.get('project.project')
1341         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1342         if analytic_ids:
1343             raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1344         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1345
1346 class project_project(osv.osv):
1347     _inherit = 'project.project'
1348     _defaults = {
1349         'use_tasks': True
1350     }
1351
1352
1353 #
1354 # Tasks History, used for cumulative flow charts (Lean/Agile)
1355 #
1356
1357 class project_task_history(osv.osv):
1358     _name = 'project.task.history'
1359     _description = 'History of Tasks'
1360     _rec_name = 'task_id'
1361     _log_access = False
1362     def _get_date(self, cr, uid, ids, name, arg, context=None):
1363         result = {}
1364         for history in self.browse(cr, uid, ids, context=context):
1365             if history.state in ('done','cancelled'):
1366                 result[history.id] = history.date
1367                 continue
1368             cr.execute('''select
1369                     date
1370                 from
1371                     project_task_history
1372                 where
1373                     task_id=%s and
1374                     id>%s
1375                 order by id limit 1''', (history.task_id.id, history.id))
1376             res = cr.fetchone()
1377             result[history.id] = res and res[0] or False
1378         return result
1379
1380     def _get_related_date(self, cr, uid, ids, context=None):
1381         result = []
1382         for history in self.browse(cr, uid, ids, context=context):
1383             cr.execute('''select
1384                     id
1385                 from
1386                     project_task_history
1387                 where
1388                     task_id=%s and
1389                     id<%s
1390                 order by id desc limit 1''', (history.task_id.id, history.id))
1391             res = cr.fetchone()
1392             if res:
1393                 result.append(res[0])
1394         return result
1395
1396     _columns = {
1397         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1398         'type_id': fields.many2one('project.task.type', 'Stage'),
1399         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1400         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready To Pull')], 'Kanban State', required=False),
1401         'date': fields.date('Date', select=True),
1402         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1403             'project.task.history': (_get_related_date, None, 20)
1404         }),
1405         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1406         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1407         'user_id': fields.many2one('res.users', 'Responsible'),
1408     }
1409     _defaults = {
1410         'date': fields.date.context_today,
1411     }
1412
1413
1414 class project_task_history_cumulative(osv.osv):
1415     _name = 'project.task.history.cumulative'
1416     _table = 'project_task_history_cumulative'
1417     _inherit = 'project.task.history'
1418     _auto = False
1419     _columns = {
1420         'end_date': fields.date('End Date'),
1421         'project_id': fields.related('task_id', 'project_id', string='Project', type='many2one', relation='project.project')
1422     }
1423     def init(self, cr):
1424         cr.execute(""" CREATE OR REPLACE VIEW project_task_history_cumulative AS (
1425             SELECT
1426                 history.date::varchar||'-'||history.history_id::varchar as id,
1427                 history.date as end_date,
1428                 *
1429             FROM (
1430                 SELECT
1431                     id as history_id,
1432                     date+generate_series(0, CAST((coalesce(end_date,DATE 'tomorrow')::date - date)AS integer)-1) as date,
1433                     task_id, type_id, user_id, kanban_state, state,
1434                     greatest(remaining_hours,1) as remaining_hours, greatest(planned_hours,1) as planned_hours
1435                 FROM
1436                     project_task_history
1437             ) as history
1438         )
1439         """)
1440
1441
1442 class project_category(osv.osv):
1443     """ Category of project's task (or issue) """
1444     _name = "project.category"
1445     _description = "Category of project's task, issue, ..."
1446     _columns = {
1447         'name': fields.char('Name', size=64, required=True, translate=True),
1448     }