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