[IMP] refectore code
[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 for next stage')], '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 for next stage 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_very_high_priority(self, cr, uid, ids, *args):
825         """Set task priority to very high
826         """
827         return self.set_priority(cr, uid, ids, '0')
828     
829     def set_high_priority(self, cr, uid, ids, *args):
830         """Set task priority to high
831         """
832         return self.set_priority(cr, uid, ids, '1')
833
834     def set_normal_priority(self, cr, uid, ids, *args):
835         """Set task priority to normal
836         """
837         return self.set_priority(cr, uid, ids, '2')
838
839     def _check_recursion(self, cr, uid, ids, context=None):
840         for id in ids:
841             visited_branch = set()
842             visited_node = set()
843             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
844             if not res:
845                 return False
846
847         return True
848
849     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
850         if id in visited_branch: #Cycle
851             return False
852
853         if id in visited_node: #Already tested don't work one more time for nothing
854             return True
855
856         visited_branch.add(id)
857         visited_node.add(id)
858
859         #visit child using DFS
860         task = self.browse(cr, uid, id, context=context)
861         for child in task.child_ids:
862             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
863             if not res:
864                 return False
865
866         visited_branch.remove(id)
867         return True
868
869     def _check_dates(self, cr, uid, ids, context=None):
870         if context == None:
871             context = {}
872         obj_task = self.browse(cr, uid, ids[0], context=context)
873         start = obj_task.date_start or False
874         end = obj_task.date_end or False
875         if start and end :
876             if start > end:
877                 return False
878         return True
879
880     _constraints = [
881         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
882         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
883     ]
884
885     # Override view according to the company definition
886     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
887         users_obj = self.pool.get('res.users')
888         if context is None: context = {}
889         # read uom as admin to avoid access rights issues, e.g. for portal/share users,
890         # this should be safe (no context passed to avoid side-effects)
891         obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
892         tm = obj_tm and obj_tm.name or 'Hours'
893
894         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
895
896         if tm in ['Hours','Hour']:
897             return res
898
899         eview = etree.fromstring(res['arch'])
900
901         def _check_rec(eview):
902             if eview.attrib.get('widget','') == 'float_time':
903                 eview.set('widget','float')
904             for child in eview:
905                 _check_rec(child)
906             return True
907
908         _check_rec(eview)
909
910         res['arch'] = etree.tostring(eview)
911
912         for f in res['fields']:
913             if 'Hours' in res['fields'][f]['string']:
914                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
915         return res
916
917     # ----------------------------------------
918     # Case management
919     # ----------------------------------------
920
921     def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
922         """ Override of the base.stage method
923             Parameter of the stage search taken from the lead:
924             - section_id: if set, stages must belong to this section or
925               be a default stage; if not set, stages must be default
926               stages
927         """
928         if isinstance(cases, (int, long)):
929             cases = self.browse(cr, uid, cases, context=context)
930         # collect all section_ids
931         section_ids = []
932         if section_id:
933             section_ids.append(section_id)
934         for task in cases:
935             if task.project_id:
936                 section_ids.append(task.project_id.id)
937         # OR all section_ids and OR with case_default
938         search_domain = []
939         if section_ids:
940             search_domain += [('|')] * len(section_ids)
941             for section_id in section_ids:
942                 search_domain.append(('project_ids', '=', section_id))
943         search_domain.append(('case_default', '=', True))
944         # AND with the domain in parameter
945         search_domain += list(domain)
946         # perform search, return the first found
947         stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
948         if stage_ids:
949             return stage_ids[0]
950         return False
951
952     def _check_child_task(self, cr, uid, ids, context=None):
953         if context == None:
954             context = {}
955         tasks = self.browse(cr, uid, ids, context=context)
956         for task in tasks:
957             if task.child_ids:
958                 for child in task.child_ids:
959                     if child.state in ['draft', 'open', 'pending']:
960                         raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
961         return True
962
963     def action_close(self, cr, uid, ids, context=None):
964         """ This action closes the task
965         """
966         task_id = len(ids) and ids[0] or False
967         self._check_child_task(cr, uid, ids, context=context)
968         if not task_id: return False
969         return self.do_close(cr, uid, [task_id], context=context)
970
971     def do_close(self, cr, uid, ids, context=None):
972         """ Compatibility when changing to case_close. """
973         return self.case_close(cr, uid, ids, context=context)
974
975     def case_close(self, cr, uid, ids, context=None):
976         """ Closes Task """
977         if not isinstance(ids, list): ids = [ids]
978         for task in self.browse(cr, uid, ids, context=context):
979             vals = {}
980             project = task.project_id
981             for parent_id in task.parent_ids:
982                 if parent_id.state in ('pending','draft'):
983                     reopen = True
984                     for child in parent_id.child_ids:
985                         if child.id != task.id and child.state not in ('done','cancelled'):
986                             reopen = False
987                     if reopen:
988                         self.do_reopen(cr, uid, [parent_id.id], context=context)
989             # close task
990             vals['remaining_hours'] = 0.0
991             if not task.date_end:
992                 vals['date_end'] = fields.datetime.now()
993             self.case_set(cr, uid, [task.id], 'done', vals, context=context)
994             self.case_close_send_note(cr, uid, [task.id], context=context)
995         return True
996
997     def do_reopen(self, cr, uid, ids, context=None):
998         for task in self.browse(cr, uid, ids, context=context):
999             project = task.project_id
1000             self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1001             self.case_open_send_note(cr, uid, [task.id], context)
1002         return True
1003
1004     def do_cancel(self, cr, uid, ids, context=None):
1005         """ Compatibility when changing to case_cancel. """
1006         return self.case_cancel(cr, uid, ids, context=context)
1007
1008     def case_cancel(self, cr, uid, ids, context=None):
1009         tasks = self.browse(cr, uid, ids, context=context)
1010         self._check_child_task(cr, uid, ids, context=context)
1011         for task in tasks:
1012             self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1013             self.case_cancel_send_note(cr, uid, [task.id], context=context)
1014         return True
1015
1016     def do_open(self, cr, uid, ids, context=None):
1017         """ Compatibility when changing to case_open. """
1018         return self.case_open(cr, uid, ids, context=context)
1019
1020     def case_open(self, cr, uid, ids, context=None):
1021         if not isinstance(ids,list): ids = [ids]
1022         self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1023         self.case_open_send_note(cr, uid, ids, context)
1024         return True
1025
1026     def do_draft(self, cr, uid, ids, context=None):
1027         """ Compatibility when changing to case_draft. """
1028         return self.case_draft(cr, uid, ids, context=context)
1029
1030     def case_draft(self, cr, uid, ids, context=None):
1031         self.case_set(cr, uid, ids, 'draft', {}, context=context)
1032         self.case_draft_send_note(cr, uid, ids, context=context)
1033         return True
1034
1035     def do_pending(self, cr, uid, ids, context=None):
1036         """ Compatibility when changing to case_pending. """
1037         return self.case_pending(cr, uid, ids, context=context)
1038
1039     def case_pending(self, cr, uid, ids, context=None):
1040         self.case_set(cr, uid, ids, 'pending', {}, context=context)
1041         return self.case_pending_send_note(cr, uid, ids, context=context)
1042
1043     def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1044         attachment = self.pool.get('ir.attachment')
1045         attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1046         new_attachment_ids = []
1047         for attachment_id in attachment_ids:
1048             new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1049         return new_attachment_ids
1050
1051     def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1052         """
1053         Delegate Task to another users.
1054         """
1055         if delegate_data is None:
1056             delegate_data = {}
1057         assert delegate_data['user_id'], _("Delegated User should be specified")
1058         delegated_tasks = {}
1059         for task in self.browse(cr, uid, ids, context=context):
1060             delegated_task_id = self.copy(cr, uid, task.id, {
1061                 'name': delegate_data['name'],
1062                 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1063                 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1064                 'planned_hours': delegate_data['planned_hours'] or 0.0,
1065                 'parent_ids': [(6, 0, [task.id])],
1066                 'description': delegate_data['new_task_description'] or '',
1067                 'child_ids': [],
1068                 'work_ids': []
1069             }, context=context)
1070             self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1071             newname = delegate_data['prefix'] or ''
1072             task.write({
1073                 'remaining_hours': delegate_data['planned_hours_me'],
1074                 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1075                 'name': newname,
1076             }, context=context)
1077             if delegate_data['state'] == 'pending':
1078                 self.do_pending(cr, uid, [task.id], context=context)
1079             elif delegate_data['state'] == 'done':
1080                 self.do_close(cr, uid, [task.id], context=context)
1081             self.do_delegation_send_note(cr, uid, [task.id], context)
1082             delegated_tasks[task.id] = delegated_task_id
1083         return delegated_tasks
1084
1085     def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1086         for task in self.browse(cr, uid, ids, context=context):
1087             if (task.state=='draft') or (task.planned_hours==0.0):
1088                 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1089         self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1090         return True
1091
1092     def set_remaining_time_1(self, cr, uid, ids, context=None):
1093         return self.set_remaining_time(cr, uid, ids, 1.0, context)
1094
1095     def set_remaining_time_2(self, cr, uid, ids, context=None):
1096         return self.set_remaining_time(cr, uid, ids, 2.0, context)
1097
1098     def set_remaining_time_5(self, cr, uid, ids, context=None):
1099         return self.set_remaining_time(cr, uid, ids, 5.0, context)
1100
1101     def set_remaining_time_10(self, cr, uid, ids, context=None):
1102         return self.set_remaining_time(cr, uid, ids, 10.0, context)
1103
1104     def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1105         self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1106         return False
1107
1108     def set_kanban_state_normal(self, cr, uid, ids, context=None):
1109         self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1110         return False
1111
1112     def set_kanban_state_done(self, cr, uid, ids, context=None):
1113         self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1114         return False
1115
1116     def _store_history(self, cr, uid, ids, context=None):
1117         for task in self.browse(cr, uid, ids, context=context):
1118             self.pool.get('project.task.history').create(cr, uid, {
1119                 'task_id': task.id,
1120                 'remaining_hours': task.remaining_hours,
1121                 'planned_hours': task.planned_hours,
1122                 'kanban_state': task.kanban_state,
1123                 'type_id': task.stage_id.id,
1124                 'state': task.state,
1125                 'user_id': task.user_id.id
1126
1127             }, context=context)
1128         return True
1129
1130     def create(self, cr, uid, vals, context=None):
1131         task_id = super(task, self).create(cr, uid, vals, context=context)
1132         task_record = self.browse(cr, uid, task_id, context=context)
1133         if task_record.project_id:
1134             project_follower_ids = [follower.id for follower in task_record.project_id.message_follower_ids]
1135             self.message_subscribe(cr, uid, [task_id], project_follower_ids,
1136                 context=context)
1137         self._store_history(cr, uid, [task_id], context=context)
1138         self.create_send_note(cr, uid, [task_id], context=context)
1139         return task_id
1140
1141     # Overridden to reset the kanban_state to normal whenever
1142     # the stage (stage_id) of the task changes.
1143     def write(self, cr, uid, ids, vals, context=None):
1144         if isinstance(ids, (int, long)):
1145             ids = [ids]
1146         if vals.get('project_id'):
1147             project_id = self.pool.get('project.project').browse(cr, uid, vals.get('project_id'), context=context)
1148             vals['message_follower_ids'] = [(4, follower.id) for follower in project_id.message_follower_ids]
1149         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1150             new_stage = vals.get('stage_id')
1151             vals_reset_kstate = dict(vals, kanban_state='normal')
1152             for t in self.browse(cr, uid, ids, context=context):
1153                 #TO FIX:Kanban view doesn't raise warning
1154                 #stages = [stage.id for stage in t.project_id.type_ids]
1155                 #if new_stage not in stages:
1156                     #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1157                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1158                 super(task,self).write(cr, uid, [t.id], write_vals, context=context)
1159                 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
1160             result = True
1161         else:
1162             result = super(task,self).write(cr, uid, ids, vals, context=context)
1163         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1164             self._store_history(cr, uid, ids, context=context)
1165         return result
1166
1167     def unlink(self, cr, uid, ids, context=None):
1168         if context == None:
1169             context = {}
1170         self._check_child_task(cr, uid, ids, context=context)
1171         res = super(task, self).unlink(cr, uid, ids, context)
1172         return res
1173
1174     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1175         context = context or {}
1176         result = ""
1177         ident = ' '*ident
1178         for task in tasks:
1179             if task.state in ('done','cancelled'):
1180                 continue
1181             result += '''
1182 %sdef Task_%s():
1183 %s  todo = \"%.2fH\"
1184 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1185             start = []
1186             for t2 in task.parent_ids:
1187                 start.append("up.Task_%s.end" % (t2.id,))
1188             if start:
1189                 result += '''
1190 %s  start = max(%s)
1191 ''' % (ident,','.join(start))
1192
1193             if task.user_id:
1194                 result += '''
1195 %s  resource = %s
1196 ''' % (ident, 'User_'+str(task.user_id.id))
1197
1198         result += "\n"
1199         return result
1200
1201     # ---------------------------------------------------
1202     # Mail gateway
1203     # ---------------------------------------------------
1204
1205     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1206         """ Override to updates the document according to the email. """
1207         if custom_values is None: custom_values = {}
1208         custom_values.update({
1209             'name': msg.get('subject'),
1210             'planned_hours': 0.0,
1211         })
1212         return super(task,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
1213
1214     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1215         """ Override to update the task according to the email. """
1216         if update_vals is None: update_vals = {}
1217         act = False
1218         maps = {
1219             'cost':'planned_hours',
1220         }
1221         for line in msg['body'].split('\n'):
1222             line = line.strip()
1223             res = tools.misc.command_re.match(line)
1224             if res:
1225                 match = res.group(1).lower()
1226                 field = maps.get(match)
1227                 if field:
1228                     try:
1229                         update_vals[field] = float(res.group(2).lower())
1230                     except (ValueError, TypeError):
1231                         pass
1232                 elif match.lower() == 'state' \
1233                         and res.group(2).lower() in ['cancel','close','draft','open','pending']:
1234                     act = 'do_%s' % res.group(2).lower()
1235         if act:
1236             getattr(self,act)(cr, uid, ids, context=context)
1237         return super(task,self).message_update(cr, uid, msg, update_vals=update_vals, context=context)
1238
1239     # ---------------------------------------------------
1240     # OpenChatter methods and notifications
1241     # ---------------------------------------------------
1242
1243     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
1244         """ Override of default prefix for notifications. """
1245         return 'Task'
1246
1247     def get_needaction_user_ids(self, cr, uid, ids, context=None):
1248         """ Returns the user_ids that have to perform an action.
1249             Add to the previous results given by super the document responsible
1250             when in draft mode.
1251             :return: dict { record_id: [user_ids], }
1252         """
1253         result = super(task, self).get_needaction_user_ids(cr, uid, ids, context=context)
1254         for obj in self.browse(cr, uid, ids, context=context):
1255             if obj.state == 'draft' and obj.user_id:
1256                 result[obj.id].append(obj.user_id.id)
1257         return result
1258
1259     def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
1260         """ Add 'user_id' and 'manager_id' to the monitored fields """
1261         res = super(task, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
1262         return res + ['user_id', 'manager_id']
1263
1264     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
1265         """ Override of the (void) default notification method. """
1266         stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
1267         return self.message_post(cr, uid, ids, body=_("Stage changed to <b>%s</b>.") % (stage_name),
1268             context=context)
1269
1270     def create_send_note(self, cr, uid, ids, context=None):
1271         return self.message_post(cr, uid, ids, body=_("Task has been <b>created</b>."), context=context)
1272
1273     def case_draft_send_note(self, cr, uid, ids, context=None):
1274         return self.message_post(cr, uid, ids, body=_('Task has been set as <b>draft</b>.'), context=context)
1275
1276     def do_delegation_send_note(self, cr, uid, ids, context=None):
1277         for task in self.browse(cr, uid, ids, context=context):
1278             msg = _('Task has been <b>delegated</b> to <em>%s</em>.') % (task.user_id.name)
1279             self.message_post(cr, uid, [task.id], body=msg, context=context)
1280         return True
1281
1282
1283 class project_work(osv.osv):
1284     _name = "project.task.work"
1285     _description = "Project Task Work"
1286     _columns = {
1287         'name': fields.char('Work summary', size=128),
1288         'date': fields.datetime('Date', select="1"),
1289         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1290         'hours': fields.float('Time Spent'),
1291         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1292         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1293     }
1294
1295     _defaults = {
1296         'user_id': lambda obj, cr, uid, context: uid,
1297         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1298     }
1299
1300     _order = "date desc"
1301     def create(self, cr, uid, vals, *args, **kwargs):
1302         if 'hours' in vals and (not vals['hours']):
1303             vals['hours'] = 0.00
1304         if 'task_id' in vals:
1305             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1306         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1307
1308     def write(self, cr, uid, ids, vals, context=None):
1309         if 'hours' in vals and (not vals['hours']):
1310             vals['hours'] = 0.00
1311         if 'hours' in vals:
1312             for work in self.browse(cr, uid, ids, context=context):
1313                 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))
1314         return super(project_work,self).write(cr, uid, ids, vals, context)
1315
1316     def unlink(self, cr, uid, ids, *args, **kwargs):
1317         for work in self.browse(cr, uid, ids):
1318             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1319         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1320
1321
1322 class account_analytic_account(osv.osv):
1323     _inherit = 'account.analytic.account'
1324     _description = 'Analytic Account'
1325     _columns = {
1326         '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"),
1327         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1328     }
1329
1330     def on_change_template(self, cr, uid, ids, template_id, context=None):
1331         res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1332         if template_id and 'value' in res:
1333             template = self.browse(cr, uid, template_id, context=context)
1334             res['value']['use_tasks'] = template.use_tasks
1335         return res
1336
1337     def _trigger_project_creation(self, cr, uid, vals, context=None):
1338         '''
1339         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.
1340         '''
1341         if context is None: context = {}
1342         return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1343
1344     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1345         '''
1346         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.
1347         '''
1348         project_pool = self.pool.get('project.project')
1349         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1350         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1351             project_values = {
1352                 'name': vals.get('name'),
1353                 'analytic_account_id': analytic_account_id,
1354             }
1355             return project_pool.create(cr, uid, project_values, context=context)
1356         return False
1357
1358     def create(self, cr, uid, vals, context=None):
1359         if context is None:
1360             context = {}
1361         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1362             vals['child_ids'] = []
1363         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1364         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1365         return analytic_account_id
1366
1367     def write(self, cr, uid, ids, vals, context=None):
1368         name = vals.get('name')
1369         for account in self.browse(cr, uid, ids, context=context):
1370             if not name:
1371                 vals['name'] = account.name
1372             self.project_create(cr, uid, account.id, vals, context=context)
1373         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1374
1375     def unlink(self, cr, uid, ids, *args, **kwargs):
1376         project_obj = self.pool.get('project.project')
1377         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1378         if analytic_ids:
1379             raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1380         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1381
1382 class project_project(osv.osv):
1383     _inherit = 'project.project'
1384     _defaults = {
1385         'use_tasks': True
1386     }
1387
1388 class project_task_history(osv.osv):
1389     """
1390     Tasks History, used for cumulative flow charts (Lean/Agile)
1391     """
1392     _name = 'project.task.history'
1393     _description = 'History of Tasks'
1394     _rec_name = 'task_id'
1395     _log_access = False
1396
1397     def _get_date(self, cr, uid, ids, name, arg, context=None):
1398         result = {}
1399         for history in self.browse(cr, uid, ids, context=context):
1400             if history.state in ('done','cancelled'):
1401                 result[history.id] = history.date
1402                 continue
1403             cr.execute('''select
1404                     date
1405                 from
1406                     project_task_history
1407                 where
1408                     task_id=%s and
1409                     id>%s
1410                 order by id limit 1''', (history.task_id.id, history.id))
1411             res = cr.fetchone()
1412             result[history.id] = res and res[0] or False
1413         return result
1414
1415     def _get_related_date(self, cr, uid, ids, context=None):
1416         result = []
1417         for history in self.browse(cr, uid, ids, context=context):
1418             cr.execute('''select
1419                     id
1420                 from
1421                     project_task_history
1422                 where
1423                     task_id=%s and
1424                     id<%s
1425                 order by id desc limit 1''', (history.task_id.id, history.id))
1426             res = cr.fetchone()
1427             if res:
1428                 result.append(res[0])
1429         return result
1430
1431     _columns = {
1432         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1433         'type_id': fields.many2one('project.task.type', 'Stage'),
1434         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1435         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1436         'date': fields.date('Date', select=True),
1437         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1438             'project.task.history': (_get_related_date, None, 20)
1439         }),
1440         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1441         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1442         'user_id': fields.many2one('res.users', 'Responsible'),
1443     }
1444     _defaults = {
1445         'date': fields.date.context_today,
1446     }
1447
1448 class project_task_history_cumulative(osv.osv):
1449     _name = 'project.task.history.cumulative'
1450     _table = 'project_task_history_cumulative'
1451     _inherit = 'project.task.history'
1452     _auto = False
1453
1454     _columns = {
1455         'end_date': fields.date('End Date'),
1456         'project_id': fields.many2one('project.project', 'Project'),
1457     }
1458
1459     def init(self, cr):
1460         tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1461
1462         cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1463             SELECT
1464                 history.date::varchar||'-'||history.history_id::varchar AS id,
1465                 history.date AS end_date,
1466                 *
1467             FROM (
1468                 SELECT
1469                     h.id AS history_id,
1470                     h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1471                     h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1472                     greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1473                     t.project_id
1474                 FROM
1475                     project_task_history AS h
1476                     JOIN project_task AS t ON (h.task_id = t.id)
1477
1478             ) AS history
1479         )
1480         """)
1481
1482 class project_category(osv.osv):
1483     """ Category of project's task (or issue) """
1484     _name = "project.category"
1485     _description = "Category of project's task, issue, ..."
1486     _columns = {
1487         'name': fields.char('Name', size=64, required=True, translate=True),
1488     }