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