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