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