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