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