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