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