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