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