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