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