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