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