7f56b573ebb504f5f03e5e16e21b71e6082621ce
[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         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(_get_attached_docs, string="Number of documents attached", type='int')
292      }
293
294     def _get_type_common(self, cr, uid, context):
295         ids = self.pool.get('project.task.type').search(cr, uid, [('case_default','=',1)], context=context)
296         return ids
297
298     _order = "sequence, id"
299     _defaults = {
300         'active': True,
301         'type': 'contract',
302         'state': 'open',
303         'priority': 1,
304         'sequence': 10,
305         'type_ids': _get_type_common,
306         'alias_model': 'project.task',
307         'privacy_visibility': 'employees',
308         'alias_domain': False,  # always hide alias during creation
309     }
310
311     # TODO: Why not using a SQL contraints ?
312     def _check_dates(self, cr, uid, ids, context=None):
313         for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
314             if leave['date_start'] and leave['date']:
315                 if leave['date_start'] > leave['date']:
316                     return False
317         return True
318
319     _constraints = [
320         (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
321     ]
322
323     def set_template(self, cr, uid, ids, context=None):
324         res = self.setActive(cr, uid, ids, value=False, context=context)
325         return res
326
327     def set_done(self, cr, uid, ids, context=None):
328         task_obj = self.pool.get('project.task')
329         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
330         task_obj.case_close(cr, uid, task_ids, context=context)
331         return self.write(cr, uid, ids, {'state':'close'}, context=context)
332
333     def set_cancel(self, cr, uid, ids, context=None):
334         task_obj = self.pool.get('project.task')
335         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
336         task_obj.case_cancel(cr, uid, task_ids, context=context)
337         return self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
338
339     def set_pending(self, cr, uid, ids, context=None):
340         return self.write(cr, uid, ids, {'state':'pending'}, context=context)
341
342     def set_open(self, cr, uid, ids, context=None):
343         return self.write(cr, uid, ids, {'state':'open'}, context=context)
344
345     def reset_project(self, cr, uid, ids, context=None):
346         return self.setActive(cr, uid, ids, value=True, context=context)
347
348     def map_tasks(self, cr, uid, old_project_id, new_project_id, context=None):
349         """ copy and map tasks from old to new project """
350         if context is None:
351             context = {}
352         map_task_id = {}
353         task_obj = self.pool.get('project.task')
354         proj = self.browse(cr, uid, old_project_id, context=context)
355         for task in proj.tasks:
356             map_task_id[task.id] =  task_obj.copy(cr, uid, task.id, {}, context=context)
357         self.write(cr, uid, [new_project_id], {'tasks':[(6,0, map_task_id.values())]})
358         task_obj.duplicate_task(cr, uid, map_task_id, context=context)
359         return True
360
361     def copy(self, cr, uid, id, default=None, context=None):
362         if context is None:
363             context = {}
364         if default is None:
365             default = {}
366
367         context['active_test'] = False
368         default['state'] = 'open'
369         default['line_ids'] = []
370         default['tasks'] = []
371         default.pop('alias_name', None)
372         default.pop('alias_id', None)
373         proj = self.browse(cr, uid, id, context=context)
374         if not default.get('name', False):
375             default.update(name=_("%s (copy)") % (proj.name))
376         res = super(project, self).copy(cr, uid, id, default, context)
377         self.map_tasks(cr,uid,id,res,context)
378         return res
379
380     def duplicate_template(self, cr, uid, ids, context=None):
381         if context is None:
382             context = {}
383         data_obj = self.pool.get('ir.model.data')
384         result = []
385         for proj in self.browse(cr, uid, ids, context=context):
386             parent_id = context.get('parent_id', False)
387             context.update({'analytic_project_copy': True})
388             new_date_start = time.strftime('%Y-%m-%d')
389             new_date_end = False
390             if proj.date_start and proj.date:
391                 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
392                 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
393                 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
394             context.update({'copy':True})
395             new_id = self.copy(cr, uid, proj.id, default = {
396                                     'name':_("%s (copy)") % (proj.name),
397                                     'state':'open',
398                                     'date_start':new_date_start,
399                                     'date':new_date_end,
400                                     'parent_id':parent_id}, context=context)
401             result.append(new_id)
402
403             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
404             parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
405             if child_ids:
406                 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
407
408         if result and len(result):
409             res_id = result[0]
410             form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
411             form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
412             tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
413             tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
414             search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
415             search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
416             return {
417                 'name': _('Projects'),
418                 'view_type': 'form',
419                 'view_mode': 'form,tree',
420                 'res_model': 'project.project',
421                 'view_id': False,
422                 'res_id': res_id,
423                 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
424                 'type': 'ir.actions.act_window',
425                 'search_view_id': search_view['res_id'],
426                 'nodestroy': True
427             }
428
429     # set active value for a project, its sub projects and its tasks
430     def setActive(self, cr, uid, ids, value=True, context=None):
431         task_obj = self.pool.get('project.task')
432         for proj in self.browse(cr, uid, ids, context=None):
433             self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
434             cr.execute('select id from project_task where project_id=%s', (proj.id,))
435             tasks_id = [x[0] for x in cr.fetchall()]
436             if tasks_id:
437                 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
438             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
439             if child_ids:
440                 self.setActive(cr, uid, child_ids, value, context=None)
441         return True
442
443     def _schedule_header(self, cr, uid, ids, force_members=True, context=None):
444         context = context or {}
445         if type(ids) in (long, int,):
446             ids = [ids]
447         projects = self.browse(cr, uid, ids, context=context)
448
449         for project in projects:
450             if (not project.members) and force_members:
451                 raise osv.except_osv(_('Warning!'),_("You must assign members on the project '%s'!") % (project.name,))
452
453         resource_pool = self.pool.get('resource.resource')
454
455         result = "from openerp.addons.resource.faces import *\n"
456         result += "import datetime\n"
457         for project in self.browse(cr, uid, ids, context=context):
458             u_ids = [i.id for i in project.members]
459             if project.user_id and (project.user_id.id not in u_ids):
460                 u_ids.append(project.user_id.id)
461             for task in project.tasks:
462                 if task.state in ('done','cancelled'):
463                     continue
464                 if task.user_id and (task.user_id.id not in u_ids):
465                     u_ids.append(task.user_id.id)
466             calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
467             resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
468             for key, vals in resource_objs.items():
469                 result +='''
470 class User_%s(Resource):
471     efficiency = %s
472 ''' % (key,  vals.get('efficiency', False))
473
474         result += '''
475 def Project():
476         '''
477         return result
478
479     def _schedule_project(self, cr, uid, project, context=None):
480         resource_pool = self.pool.get('resource.resource')
481         calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
482         working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
483         # TODO: check if we need working_..., default values are ok.
484         puids = [x.id for x in project.members]
485         if project.user_id:
486             puids.append(project.user_id.id)
487         result = """
488   def Project_%d():
489     start = \'%s\'
490     working_days = %s
491     resource = %s
492 """       % (
493             project.id,
494             project.date_start or time.strftime('%Y-%m-%d'), working_days,
495             '|'.join(['User_'+str(x) for x in puids]) or 'None'
496         )
497         vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
498         if vacation:
499             result+= """
500     vacation = %s
501 """ %   ( vacation, )
502         return result
503
504     #TODO: DO Resource allocation and compute availability
505     def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
506         if context ==  None:
507             context = {}
508         allocation = {}
509         return allocation
510
511     def schedule_tasks(self, cr, uid, ids, context=None):
512         context = context or {}
513         if type(ids) in (long, int,):
514             ids = [ids]
515         projects = self.browse(cr, uid, ids, context=context)
516         result = self._schedule_header(cr, uid, ids, False, context=context)
517         for project in projects:
518             result += self._schedule_project(cr, uid, project, context=context)
519             result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
520
521         local_dict = {}
522         exec result in local_dict
523         projects_gantt = Task.BalancedProject(local_dict['Project'])
524
525         for project in projects:
526             project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
527             for task in project.tasks:
528                 if task.state in ('done','cancelled'):
529                     continue
530
531                 p = getattr(project_gantt, 'Task_%d' % (task.id,))
532
533                 self.pool.get('project.task').write(cr, uid, [task.id], {
534                     'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
535                     'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
536                 }, context=context)
537                 if (not task.user_id) and (p.booked_resource):
538                     self.pool.get('project.task').write(cr, uid, [task.id], {
539                         'user_id': int(p.booked_resource[0].name[5:]),
540                     }, context=context)
541         return True
542
543     # ------------------------------------------------
544     # OpenChatter methods and notifications
545     # ------------------------------------------------
546
547     def create(self, cr, uid, vals, context=None):
548         if context is None: context = {}
549         # Prevent double project creation when 'use_tasks' is checked!
550         context = dict(context, project_creation_in_progress=True)
551         mail_alias = self.pool.get('mail.alias')
552         if not vals.get('alias_id') and vals.get('name', False):
553             vals.pop('alias_name', None) # prevent errors during copy()
554             alias_id = mail_alias.create_unique_alias(cr, uid,
555                           # Using '+' allows using subaddressing for those who don't
556                           # have a catchall domain setup.
557                           {'alias_name': "project+"+short_name(vals['name'])},
558                           model_name=vals.get('alias_model', 'project.task'),
559                           context=context)
560             vals['alias_id'] = alias_id
561         if vals.get('type', False) not in ('template','contract'):
562             vals['type'] = 'contract'
563         project_id = super(project, self).create(cr, uid, vals, context)
564         mail_alias.write(cr, uid, [vals['alias_id']], {'alias_defaults': {'project_id': project_id} }, context)
565         return project_id
566
567     def write(self, cr, uid, ids, vals, context=None):
568         # if alias_model has been changed, update alias_model_id accordingly
569         if vals.get('alias_model'):
570             model_ids = self.pool.get('ir.model').search(cr, uid, [('model', '=', vals.get('alias_model', 'project.task'))])
571             vals.update(alias_model_id=model_ids[0])
572         return super(project, self).write(cr, uid, ids, vals, context=context)
573
574 class task(base_stage, osv.osv):
575     _name = "project.task"
576     _description = "Task"
577     _date_name = "date_start"
578     _inherit = ['mail.thread', 'ir.needaction_mixin']
579
580     _track = {
581         'state': {
582             'project.mt_task_new': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
583             'project.mt_task_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
584             'project.mt_task_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
585         },
586         'stage_id': {
587             'project.mt_task_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'done', 'open'],
588         },
589         'kanban_state': {  # kanban state: tracked, but only block subtype
590             'project.mt_task_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
591         },
592     }
593
594     def _get_default_partner(self, cr, uid, context=None):
595         """ Override of base_stage to add project specific behavior """
596         project_id = self._get_default_project_id(cr, uid, context)
597         if project_id:
598             project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
599             if project and project.partner_id:
600                 return project.partner_id.id
601         return super(task, self)._get_default_partner(cr, uid, context=context)
602
603     def _get_default_project_id(self, cr, uid, context=None):
604         """ Gives default section by checking if present in the context """
605         return (self._resolve_project_id_from_context(cr, uid, context=context) or False)
606
607     def _get_default_stage_id(self, cr, uid, context=None):
608         """ Gives default stage_id """
609         project_id = self._get_default_project_id(cr, uid, context=context)
610         return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
611
612     def _resolve_project_id_from_context(self, cr, uid, context=None):
613         """ Returns ID of project based on the value of 'default_project_id'
614             context key, or None if it cannot be resolved to a single
615             project.
616         """
617         if context is None:
618             context = {}
619         if type(context.get('default_project_id')) in (int, long):
620             return context['default_project_id']
621         if isinstance(context.get('default_project_id'), basestring):
622             project_name = context['default_project_id']
623             project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
624             if len(project_ids) == 1:
625                 return project_ids[0][0]
626         return None
627
628     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
629         stage_obj = self.pool.get('project.task.type')
630         order = stage_obj._order
631         access_rights_uid = access_rights_uid or uid
632         if read_group_order == 'stage_id desc':
633             order = '%s desc' % order
634         search_domain = []
635         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
636         if project_id:
637             search_domain += ['|', ('project_ids', '=', project_id)]
638         search_domain += [('id', 'in', ids)]
639         stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
640         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
641         # restore order of the search
642         result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
643
644         fold = {}
645         for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
646             fold[stage.id] = stage.fold or False
647         return result, fold
648
649     def _read_group_user_id(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
650         res_users = self.pool.get('res.users')
651         project_id = self._resolve_project_id_from_context(cr, uid, context=context)
652         access_rights_uid = access_rights_uid or uid
653         if project_id:
654             ids += self.pool.get('project.project').read(cr, access_rights_uid, project_id, ['members'], context=context)['members']
655             order = res_users._order
656             # lame way to allow reverting search, should just work in the trivial case
657             if read_group_order == 'user_id desc':
658                 order = '%s desc' % order
659             # de-duplicate and apply search order
660             ids = res_users._search(cr, uid, [('id','in',ids)], order=order, access_rights_uid=access_rights_uid, context=context)
661         result = res_users.name_get(cr, access_rights_uid, ids, context=context)
662         # restore order of the search
663         result.sort(lambda x,y: cmp(ids.index(x[0]), ids.index(y[0])))
664         return result, {}
665
666     _group_by_full = {
667         'stage_id': _read_group_stage_ids,
668         'user_id': _read_group_user_id,
669     }
670
671     def _str_get(self, task, level=0, border='***', context=None):
672         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'+ \
673             border[0]+' '+(task.name or '')+'\n'+ \
674             (task.description or '')+'\n\n'
675
676     # Compute: effective_hours, total_hours, progress
677     def _hours_get(self, cr, uid, ids, field_names, args, context=None):
678         res = {}
679         cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
680         hours = dict(cr.fetchall())
681         for task in self.browse(cr, uid, ids, context=context):
682             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)}
683             res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
684             res[task.id]['progress'] = 0.0
685             if (task.remaining_hours + hours.get(task.id, 0.0)):
686                 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
687             if task.state in ('done','cancelled'):
688                 res[task.id]['progress'] = 100.0
689         return res
690
691     def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned=0.0):
692         if remaining and not planned:
693             return {'value':{'planned_hours': remaining}}
694         return {}
695
696     def onchange_planned(self, cr, uid, ids, planned=0.0, effective=0.0):
697         return {'value':{'remaining_hours': planned - effective}}
698
699     def onchange_project(self, cr, uid, id, project_id, context=None):
700         if project_id:
701             project = self.pool.get('project.project').browse(cr, uid, project_id, context=context)
702             if project and project.partner_id:
703                 return {'value': {'partner_id': project.partner_id.id}}
704         return {}
705
706     def duplicate_task(self, cr, uid, map_ids, context=None):
707         for new in map_ids.values():
708             task = self.browse(cr, uid, new, context)
709             child_ids = [ ch.id for ch in task.child_ids]
710             if task.child_ids:
711                 for child in task.child_ids:
712                     if child.id in map_ids.keys():
713                         child_ids.remove(child.id)
714                         child_ids.append(map_ids[child.id])
715
716             parent_ids = [ ch.id for ch in task.parent_ids]
717             if task.parent_ids:
718                 for parent in task.parent_ids:
719                     if parent.id in map_ids.keys():
720                         parent_ids.remove(parent.id)
721                         parent_ids.append(map_ids[parent.id])
722             #FIXME why there is already the copy and the old one
723             self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
724
725     def copy_data(self, cr, uid, id, default=None, context=None):
726         if default is None:
727             default = {}
728         default = default or {}
729         default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
730         if not default.get('remaining_hours', False):
731             default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
732         default['active'] = True
733         if not default.get('name', False):
734             default['name'] = self.browse(cr, uid, id, context=context).name or ''
735             if not context.get('copy',False):
736                 new_name = _("%s (copy)") % (default.get('name', ''))
737                 default.update({'name':new_name})
738         return super(task, self).copy_data(cr, uid, id, default, context)
739     
740     def copy(self, cr, uid, id, default=None, context=None):
741         if context is None:
742             context = {}
743         if default is None:
744             default = {}
745         stage = self._get_default_stage_id(cr, uid, context=context)
746         if stage:
747             default['stage_id'] = stage
748         return super(task, self).copy(cr, uid, id, default, context)
749
750     def _is_template(self, cr, uid, ids, field_name, arg, context=None):
751         res = {}
752         for task in self.browse(cr, uid, ids, context=context):
753             res[task.id] = True
754             if task.project_id:
755                 if task.project_id.active == False or task.project_id.state == 'template':
756                     res[task.id] = False
757         return res
758
759     def _get_task(self, cr, uid, ids, context=None):
760         result = {}
761         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
762             if work.task_id: result[work.task_id.id] = True
763         return result.keys()
764
765     _columns = {
766         '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."),
767         'name': fields.char('Task Summary', size=128, required=True, select=True),
768         'description': fields.text('Description'),
769         'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True),
770         'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."),
771         'stage_id': fields.many2one('project.task.type', 'Stage', track_visibility='onchange',
772                         domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
773         'state': fields.related('stage_id', 'state', type="selection", store=True,
774                 selection=_TASK_STATE, string="Status", readonly=True,
775                 help='The status is set to \'Draft\', when a case is created.\
776                       If the case is in progress the status is set to \'Open\'.\
777                       When the case is over, the status is set to \'Done\'.\
778                       If the case needs to be reviewed then the status is \
779                       set to \'Pending\'.'),
780         'categ_ids': fields.many2many('project.category', string='Tags'),
781         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
782                                          track_visibility='onchange',
783                                          help="A task's kanban state indicates special situations affecting it:\n"
784                                               " * Normal is the default situation\n"
785                                               " * Blocked indicates something is preventing the progress of this task\n"
786                                               " * Ready for next stage indicates the task is ready to be pulled to the next stage",
787                                          readonly=True, required=False),
788         'create_date': fields.datetime('Create Date', readonly=True, select=True),
789         '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)
790         'date_start': fields.datetime('Starting Date',select=True),
791         'date_end': fields.datetime('Ending Date',select=True),
792         'date_deadline': fields.date('Deadline',select=True),
793         'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", track_visibility='onchange'),
794         'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
795         'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
796         'notes': fields.text('Notes'),
797         '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.'),
798         'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
799             store = {
800                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
801                 'project.task.work': (_get_task, ['hours'], 10),
802             }),
803         'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
804         'total_hours': fields.function(_hours_get, string='Total', multi='hours', help="Computed as: Time Spent + Remaining Time.",
805             store = {
806                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
807                 'project.task.work': (_get_task, ['hours'], 10),
808             }),
809         '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",
810             store = {
811                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
812                 'project.task.work': (_get_task, ['hours'], 10),
813             }),
814         '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.",
815             store = {
816                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
817                 'project.task.work': (_get_task, ['hours'], 10),
818             }),
819         'user_id': fields.many2one('res.users', 'Assigned to', track_visibility='onchange'),
820         'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
821         'partner_id': fields.many2one('res.partner', 'Customer'),
822         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
823         'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
824         'company_id': fields.many2one('res.company', 'Company'),
825         'id': fields.integer('ID', readonly=True),
826         'color': fields.integer('Color Index'),
827         'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
828     }
829     _defaults = {
830         'stage_id': _get_default_stage_id,
831         'project_id': _get_default_project_id,
832         'kanban_state': 'normal',
833         'priority': '2',
834         'progress': 0,
835         'sequence': 10,
836         'active': True,
837         'user_id': lambda obj, cr, uid, ctx=None: uid,
838         'company_id': lambda self, cr, uid, ctx=None: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=ctx),
839         'partner_id': lambda self, cr, uid, ctx=None: self._get_default_partner(cr, uid, context=ctx),
840     }
841     _order = "priority, sequence, date_start, name, id"
842
843     def set_high_priority(self, cr, uid, ids, *args):
844         """Set task priority to high
845         """
846         return self.write(cr, uid, ids, {'priority' : '0'})
847
848     def set_normal_priority(self, cr, uid, ids, *args):
849         """Set task priority to normal
850         """
851         return self.write(cr, uid, ids, {'priority' : '2'})
852
853     def _check_recursion(self, cr, uid, ids, context=None):
854         for id in ids:
855             visited_branch = set()
856             visited_node = set()
857             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
858             if not res:
859                 return False
860
861         return True
862
863     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
864         if id in visited_branch: #Cycle
865             return False
866
867         if id in visited_node: #Already tested don't work one more time for nothing
868             return True
869
870         visited_branch.add(id)
871         visited_node.add(id)
872
873         #visit child using DFS
874         task = self.browse(cr, uid, id, context=context)
875         for child in task.child_ids:
876             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
877             if not res:
878                 return False
879
880         visited_branch.remove(id)
881         return True
882
883     def _check_dates(self, cr, uid, ids, context=None):
884         if context == None:
885             context = {}
886         obj_task = self.browse(cr, uid, ids[0], context=context)
887         start = obj_task.date_start or False
888         end = obj_task.date_end or False
889         if start and end :
890             if start > end:
891                 return False
892         return True
893
894     _constraints = [
895         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
896         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
897     ]
898
899     # Override view according to the company definition
900     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
901         users_obj = self.pool.get('res.users')
902         if context is None: context = {}
903         # read uom as admin to avoid access rights issues, e.g. for portal/share users,
904         # this should be safe (no context passed to avoid side-effects)
905         obj_tm = users_obj.browse(cr, SUPERUSER_ID, uid, context=context).company_id.project_time_mode_id
906         tm = obj_tm and obj_tm.name or 'Hours'
907
908         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
909
910         if tm in ['Hours','Hour']:
911             return res
912
913         eview = etree.fromstring(res['arch'])
914
915         def _check_rec(eview):
916             if eview.attrib.get('widget','') == 'float_time':
917                 eview.set('widget','float')
918             for child in eview:
919                 _check_rec(child)
920             return True
921
922         _check_rec(eview)
923
924         res['arch'] = etree.tostring(eview)
925
926         for f in res['fields']:
927             if 'Hours' in res['fields'][f]['string']:
928                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
929         return res
930
931     # ----------------------------------------
932     # Case management
933     # ----------------------------------------
934
935     def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
936         """ Override of the base.stage method
937             Parameter of the stage search taken from the lead:
938             - section_id: if set, stages must belong to this section or
939               be a default stage; if not set, stages must be default
940               stages
941         """
942         if isinstance(cases, (int, long)):
943             cases = self.browse(cr, uid, cases, context=context)
944         # collect all section_ids
945         section_ids = []
946         if section_id:
947             section_ids.append(section_id)
948         for task in cases:
949             if task.project_id:
950                 section_ids.append(task.project_id.id)
951         search_domain = []
952         if section_ids:
953             search_domain = [('|')] * (len(section_ids)-1)
954             for section_id in section_ids:
955                 search_domain.append(('project_ids', '=', section_id))
956         search_domain += list(domain)
957         # perform search, return the first found
958         stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
959         if stage_ids:
960             return stage_ids[0]
961         return False
962
963     def _check_child_task(self, cr, uid, ids, context=None):
964         if context == None:
965             context = {}
966         tasks = self.browse(cr, uid, ids, context=context)
967         for task in tasks:
968             if task.child_ids:
969                 for child in task.child_ids:
970                     if child.state in ['draft', 'open', 'pending']:
971                         raise osv.except_osv(_("Warning!"), _("Child task still open.\nPlease cancel or complete child task first."))
972         return True
973
974     def action_close(self, cr, uid, ids, context=None):
975         """ This action closes the task
976         """
977         task_id = len(ids) and ids[0] or False
978         self._check_child_task(cr, uid, ids, context=context)
979         if not task_id: return False
980         return self.do_close(cr, uid, [task_id], context=context)
981
982     def do_close(self, cr, uid, ids, context=None):
983         """ Compatibility when changing to case_close. """
984         return self.case_close(cr, uid, ids, context=context)
985
986     def case_close(self, cr, uid, ids, context=None):
987         """ Closes Task """
988         if not isinstance(ids, list): ids = [ids]
989         for task in self.browse(cr, uid, ids, context=context):
990             vals = {}
991             project = task.project_id
992             for parent_id in task.parent_ids:
993                 if parent_id.state in ('pending','draft'):
994                     reopen = True
995                     for child in parent_id.child_ids:
996                         if child.id != task.id and child.state not in ('done','cancelled'):
997                             reopen = False
998                     if reopen:
999                         self.do_reopen(cr, uid, [parent_id.id], context=context)
1000             # close task
1001             vals['remaining_hours'] = 0.0
1002             if not task.date_end:
1003                 vals['date_end'] = fields.datetime.now()
1004             self.case_set(cr, uid, [task.id], 'done', vals, context=context)
1005         return True
1006
1007     def do_reopen(self, cr, uid, ids, context=None):
1008         for task in self.browse(cr, uid, ids, context=context):
1009             project = task.project_id
1010             self.case_set(cr, uid, [task.id], 'open', {}, context=context)
1011         return True
1012
1013     def do_cancel(self, cr, uid, ids, context=None):
1014         """ Compatibility when changing to case_cancel. """
1015         return self.case_cancel(cr, uid, ids, context=context)
1016
1017     def case_cancel(self, cr, uid, ids, context=None):
1018         tasks = self.browse(cr, uid, ids, context=context)
1019         self._check_child_task(cr, uid, ids, context=context)
1020         for task in tasks:
1021             self.case_set(cr, uid, [task.id], 'cancelled', {'remaining_hours': 0.0}, context=context)
1022         return True
1023
1024     def do_open(self, cr, uid, ids, context=None):
1025         """ Compatibility when changing to case_open. """
1026         return self.case_open(cr, uid, ids, context=context)
1027
1028     def case_open(self, cr, uid, ids, context=None):
1029         if not isinstance(ids,list): ids = [ids]
1030         return self.case_set(cr, uid, ids, 'open', {'date_start': fields.datetime.now()}, context=context)
1031
1032     def do_draft(self, cr, uid, ids, context=None):
1033         """ Compatibility when changing to case_draft. """
1034         return self.case_draft(cr, uid, ids, context=context)
1035
1036     def case_draft(self, cr, uid, ids, context=None):
1037         return self.case_set(cr, uid, ids, 'draft', {}, context=context)
1038
1039     def do_pending(self, cr, uid, ids, context=None):
1040         """ Compatibility when changing to case_pending. """
1041         return self.case_pending(cr, uid, ids, context=context)
1042
1043     def case_pending(self, cr, uid, ids, context=None):
1044         return self.case_set(cr, uid, ids, 'pending', {}, context=context)
1045
1046     def _delegate_task_attachments(self, cr, uid, task_id, delegated_task_id, context=None):
1047         attachment = self.pool.get('ir.attachment')
1048         attachment_ids = attachment.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', task_id)], context=context)
1049         new_attachment_ids = []
1050         for attachment_id in attachment_ids:
1051             new_attachment_ids.append(attachment.copy(cr, uid, attachment_id, default={'res_id': delegated_task_id}, context=context))
1052         return new_attachment_ids
1053
1054     def do_delegate(self, cr, uid, ids, delegate_data=None, context=None):
1055         """
1056         Delegate Task to another users.
1057         """
1058         if delegate_data is None:
1059             delegate_data = {}
1060         assert delegate_data['user_id'], _("Delegated User should be specified")
1061         delegated_tasks = {}
1062         for task in self.browse(cr, uid, ids, context=context):
1063             delegated_task_id = self.copy(cr, uid, task.id, {
1064                 'name': delegate_data['name'],
1065                 'project_id': delegate_data['project_id'] and delegate_data['project_id'][0] or False,
1066                 'user_id': delegate_data['user_id'] and delegate_data['user_id'][0] or False,
1067                 'planned_hours': delegate_data['planned_hours'] or 0.0,
1068                 'parent_ids': [(6, 0, [task.id])],
1069                 'description': delegate_data['new_task_description'] or '',
1070                 'child_ids': [],
1071                 'work_ids': []
1072             }, context=context)
1073             self._delegate_task_attachments(cr, uid, task.id, delegated_task_id, context=context)
1074             newname = delegate_data['prefix'] or ''
1075             task.write({
1076                 'remaining_hours': delegate_data['planned_hours_me'],
1077                 'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
1078                 'name': newname,
1079             }, context=context)
1080             if delegate_data['state'] == 'pending':
1081                 self.do_pending(cr, uid, [task.id], context=context)
1082             elif delegate_data['state'] == 'done':
1083                 self.do_close(cr, uid, [task.id], context=context)
1084             delegated_tasks[task.id] = delegated_task_id
1085         return delegated_tasks
1086
1087     def set_remaining_time(self, cr, uid, ids, remaining_time=1.0, context=None):
1088         for task in self.browse(cr, uid, ids, context=context):
1089             if (task.state=='draft') or (task.planned_hours==0.0):
1090                 self.write(cr, uid, [task.id], {'planned_hours': remaining_time}, context=context)
1091         self.write(cr, uid, ids, {'remaining_hours': remaining_time}, context=context)
1092         return True
1093
1094     def set_remaining_time_1(self, cr, uid, ids, context=None):
1095         return self.set_remaining_time(cr, uid, ids, 1.0, context)
1096
1097     def set_remaining_time_2(self, cr, uid, ids, context=None):
1098         return self.set_remaining_time(cr, uid, ids, 2.0, context)
1099
1100     def set_remaining_time_5(self, cr, uid, ids, context=None):
1101         return self.set_remaining_time(cr, uid, ids, 5.0, context)
1102
1103     def set_remaining_time_10(self, cr, uid, ids, context=None):
1104         return self.set_remaining_time(cr, uid, ids, 10.0, context)
1105
1106     def set_kanban_state_blocked(self, cr, uid, ids, context=None):
1107         return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
1108
1109     def set_kanban_state_normal(self, cr, uid, ids, context=None):
1110         return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
1111
1112     def set_kanban_state_done(self, cr, uid, ids, context=None):
1113         self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
1114         return False
1115
1116     def _store_history(self, cr, uid, ids, context=None):
1117         for task in self.browse(cr, uid, ids, context=context):
1118             self.pool.get('project.task.history').create(cr, uid, {
1119                 'task_id': task.id,
1120                 'remaining_hours': task.remaining_hours,
1121                 'planned_hours': task.planned_hours,
1122                 'kanban_state': task.kanban_state,
1123                 'type_id': task.stage_id.id,
1124                 'state': task.state,
1125                 'user_id': task.user_id.id
1126
1127             }, context=context)
1128         return True
1129
1130     def create(self, cr, uid, vals, context=None):
1131         if context is None:
1132             context = {}
1133         if vals.get('project_id') and not context.get('default_project_id'):
1134             context['default_project_id'] = vals.get('project_id')
1135
1136         # context: no_log, because subtype already handle this
1137         create_context = dict(context, mail_create_nolog=True)
1138         task_id = super(task, self).create(cr, uid, vals, context=create_context)
1139         self._store_history(cr, uid, [task_id], context=context)
1140         return task_id
1141
1142     # Overridden to reset the kanban_state to normal whenever
1143     # the stage (stage_id) of the task changes.
1144     def write(self, cr, uid, ids, vals, context=None):
1145         if isinstance(ids, (int, long)):
1146             ids = [ids]
1147         if vals and not 'kanban_state' in vals and 'stage_id' in vals:
1148             new_stage = vals.get('stage_id')
1149             vals_reset_kstate = dict(vals, kanban_state='normal')
1150             for t in self.browse(cr, uid, ids, context=context):
1151                 #TO FIX:Kanban view doesn't raise warning
1152                 #stages = [stage.id for stage in t.project_id.type_ids]
1153                 #if new_stage not in stages:
1154                     #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.'))
1155                 write_vals = vals_reset_kstate if t.stage_id != new_stage else vals
1156                 super(task, self).write(cr, uid, [t.id], write_vals, context=context)
1157             result = True
1158         else:
1159             result = super(task, self).write(cr, uid, ids, vals, context=context)
1160         if ('stage_id' in vals) or ('remaining_hours' in vals) or ('user_id' in vals) or ('state' in vals) or ('kanban_state' in vals):
1161             self._store_history(cr, uid, ids, context=context)
1162         return result
1163
1164     def unlink(self, cr, uid, ids, context=None):
1165         if context == None:
1166             context = {}
1167         self._check_child_task(cr, uid, ids, context=context)
1168         res = super(task, self).unlink(cr, uid, ids, context)
1169         return res
1170
1171     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
1172         context = context or {}
1173         result = ""
1174         ident = ' '*ident
1175         for task in tasks:
1176             if task.state in ('done','cancelled'):
1177                 continue
1178             result += '''
1179 %sdef Task_%s():
1180 %s  todo = \"%.2fH\"
1181 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.remaining_hours, ident,task.total_hours)
1182             start = []
1183             for t2 in task.parent_ids:
1184                 start.append("up.Task_%s.end" % (t2.id,))
1185             if start:
1186                 result += '''
1187 %s  start = max(%s)
1188 ''' % (ident,','.join(start))
1189
1190             if task.user_id:
1191                 result += '''
1192 %s  resource = %s
1193 ''' % (ident, 'User_'+str(task.user_id.id))
1194
1195         result += "\n"
1196         return result
1197
1198     # ---------------------------------------------------
1199     # Mail gateway
1200     # ---------------------------------------------------
1201
1202     def message_get_reply_to(self, cr, uid, ids, context=None):
1203         """ Override to get the reply_to of the parent project. """
1204         return [task.project_id.message_get_reply_to()[0] if task.project_id else False
1205                     for task in self.browse(cr, uid, ids, context=context)]
1206
1207     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1208         """ Override to updates the document according to the email. """
1209         if custom_values is None:
1210             custom_values = {}
1211         defaults = {
1212             'name': msg.get('subject'),
1213             'planned_hours': 0.0,
1214         }
1215         defaults.update(custom_values)
1216         return super(task, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1217
1218     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1219         """ Override to update the task according to the email. """
1220         if update_vals is None:
1221             update_vals = {}
1222         maps = {
1223             'cost': 'planned_hours',
1224         }
1225         for line in msg['body'].split('\n'):
1226             line = line.strip()
1227             res = tools.command_re.match(line)
1228             if res:
1229                 match = res.group(1).lower()
1230                 field = maps.get(match)
1231                 if field:
1232                     try:
1233                         update_vals[field] = float(res.group(2).lower())
1234                     except (ValueError, TypeError):
1235                         pass
1236         return super(task, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1237
1238     def project_task_reevaluate(self, cr, uid, ids, context=None):
1239         if self.pool.get('res.users').has_group(cr, uid, 'project.group_time_work_estimation_tasks'):
1240             return {
1241                 'view_type': 'form',
1242                 "view_mode": 'form',
1243                 'res_model': 'project.task.reevaluate',
1244                 'type': 'ir.actions.act_window',
1245                 'target': 'new',
1246             }
1247         return self.do_reopen(cr, uid, ids, context=context)
1248
1249 class project_work(osv.osv):
1250     _name = "project.task.work"
1251     _description = "Project Task Work"
1252     _columns = {
1253         'name': fields.char('Work summary', size=128),
1254         'date': fields.datetime('Date', select="1"),
1255         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
1256         'hours': fields.float('Time Spent'),
1257         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
1258         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
1259     }
1260
1261     _defaults = {
1262         'user_id': lambda obj, cr, uid, context: uid,
1263         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
1264     }
1265
1266     _order = "date desc"
1267     def create(self, cr, uid, vals, *args, **kwargs):
1268         if 'hours' in vals and (not vals['hours']):
1269             vals['hours'] = 0.00
1270         if 'task_id' in vals:
1271             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
1272         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
1273
1274     def write(self, cr, uid, ids, vals, context=None):
1275         if 'hours' in vals and (not vals['hours']):
1276             vals['hours'] = 0.00
1277         if 'hours' in vals:
1278             for work in self.browse(cr, uid, ids, context=context):
1279                 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))
1280         return super(project_work,self).write(cr, uid, ids, vals, context)
1281
1282     def unlink(self, cr, uid, ids, *args, **kwargs):
1283         for work in self.browse(cr, uid, ids):
1284             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
1285         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
1286
1287
1288 class account_analytic_account(osv.osv):
1289     _inherit = 'account.analytic.account'
1290     _description = 'Analytic Account'
1291     _columns = {
1292         '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"),
1293         'company_uom_id': fields.related('company_id', 'project_time_mode_id', type='many2one', relation='product.uom'),
1294     }
1295
1296     def on_change_template(self, cr, uid, ids, template_id, context=None):
1297         res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
1298         if template_id and 'value' in res:
1299             template = self.browse(cr, uid, template_id, context=context)
1300             res['value']['use_tasks'] = template.use_tasks
1301         return res
1302
1303     def _trigger_project_creation(self, cr, uid, vals, context=None):
1304         '''
1305         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.
1306         '''
1307         if context is None: context = {}
1308         return vals.get('use_tasks') and not 'project_creation_in_progress' in context
1309
1310     def project_create(self, cr, uid, analytic_account_id, vals, context=None):
1311         '''
1312         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.
1313         '''
1314         project_pool = self.pool.get('project.project')
1315         project_id = project_pool.search(cr, uid, [('analytic_account_id','=', analytic_account_id)])
1316         if not project_id and self._trigger_project_creation(cr, uid, vals, context=context):
1317             project_values = {
1318                 'name': vals.get('name'),
1319                 'analytic_account_id': analytic_account_id,
1320                 'type': vals.get('type','contract'),
1321             }
1322             return project_pool.create(cr, uid, project_values, context=context)
1323         return False
1324
1325     def create(self, cr, uid, vals, context=None):
1326         if context is None:
1327             context = {}
1328         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
1329             vals['child_ids'] = []
1330         analytic_account_id = super(account_analytic_account, self).create(cr, uid, vals, context=context)
1331         self.project_create(cr, uid, analytic_account_id, vals, context=context)
1332         return analytic_account_id
1333
1334     def write(self, cr, uid, ids, vals, context=None):
1335         if isinstance(ids, (int, long)):
1336             ids = [ids]
1337         vals_for_project = vals.copy()
1338         for account in self.browse(cr, uid, ids, context=context):
1339             if not vals.get('name'):
1340                 vals_for_project['name'] = account.name
1341             if not vals.get('type'):
1342                 vals_for_project['type'] = account.type
1343             self.project_create(cr, uid, account.id, vals_for_project, context=context)
1344         return super(account_analytic_account, self).write(cr, uid, ids, vals, context=context)
1345
1346     def unlink(self, cr, uid, ids, *args, **kwargs):
1347         project_obj = self.pool.get('project.project')
1348         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
1349         if analytic_ids:
1350             raise osv.except_osv(_('Warning!'), _('Please delete the project linked with this account first.'))
1351         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
1352
1353     def name_search(self, cr, uid, name, args=None, operator='ilike', context=None, limit=100):
1354         if args is None:
1355             args = []
1356         if context is None:
1357             context={}
1358         if context.get('current_model') == 'project.project':
1359             project_ids = self.search(cr, uid, args + [('name', operator, name)], limit=limit, context=context)
1360             return self.name_get(cr, uid, project_ids, context=context)
1361
1362         return super(account_analytic_account, self).name_search(cr, uid, name, args=args, operator=operator, context=context, limit=limit)
1363
1364
1365 class project_project(osv.osv):
1366     _inherit = 'project.project'
1367     _defaults = {
1368         'use_tasks': True
1369     }
1370
1371 class project_task_history(osv.osv):
1372     """
1373     Tasks History, used for cumulative flow charts (Lean/Agile)
1374     """
1375     _name = 'project.task.history'
1376     _description = 'History of Tasks'
1377     _rec_name = 'task_id'
1378     _log_access = False
1379
1380     def _get_date(self, cr, uid, ids, name, arg, context=None):
1381         result = {}
1382         for history in self.browse(cr, uid, ids, context=context):
1383             if history.state in ('done','cancelled'):
1384                 result[history.id] = history.date
1385                 continue
1386             cr.execute('''select
1387                     date
1388                 from
1389                     project_task_history
1390                 where
1391                     task_id=%s and
1392                     id>%s
1393                 order by id limit 1''', (history.task_id.id, history.id))
1394             res = cr.fetchone()
1395             result[history.id] = res and res[0] or False
1396         return result
1397
1398     def _get_related_date(self, cr, uid, ids, context=None):
1399         result = []
1400         for history in self.browse(cr, uid, ids, context=context):
1401             cr.execute('''select
1402                     id
1403                 from
1404                     project_task_history
1405                 where
1406                     task_id=%s and
1407                     id<%s
1408                 order by id desc limit 1''', (history.task_id.id, history.id))
1409             res = cr.fetchone()
1410             if res:
1411                 result.append(res[0])
1412         return result
1413
1414     _columns = {
1415         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select=True),
1416         'type_id': fields.many2one('project.task.type', 'Stage'),
1417         'state': fields.selection([('draft', 'New'), ('cancelled', 'Cancelled'),('open', 'In Progress'),('pending', 'Pending'), ('done', 'Done')], 'Status'),
1418         'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State', required=False),
1419         'date': fields.date('Date', select=True),
1420         'end_date': fields.function(_get_date, string='End Date', type="date", store={
1421             'project.task.history': (_get_related_date, None, 20)
1422         }),
1423         'remaining_hours': fields.float('Remaining Time', digits=(16,2)),
1424         'planned_hours': fields.float('Planned Time', digits=(16,2)),
1425         'user_id': fields.many2one('res.users', 'Responsible'),
1426     }
1427     _defaults = {
1428         'date': fields.date.context_today,
1429     }
1430
1431 class project_task_history_cumulative(osv.osv):
1432     _name = 'project.task.history.cumulative'
1433     _table = 'project_task_history_cumulative'
1434     _inherit = 'project.task.history'
1435     _auto = False
1436
1437     _columns = {
1438         'end_date': fields.date('End Date'),
1439         'project_id': fields.many2one('project.project', 'Project'),
1440     }
1441
1442     def init(self, cr):
1443         tools.drop_view_if_exists(cr, 'project_task_history_cumulative')
1444
1445         cr.execute(""" CREATE VIEW project_task_history_cumulative AS (
1446             SELECT
1447                 history.date::varchar||'-'||history.history_id::varchar AS id,
1448                 history.date AS end_date,
1449                 *
1450             FROM (
1451                 SELECT
1452                     h.id AS history_id,
1453                     h.date+generate_series(0, CAST((coalesce(h.end_date, DATE 'tomorrow')::date - h.date) AS integer)-1) AS date,
1454                     h.task_id, h.type_id, h.user_id, h.kanban_state, h.state,
1455                     greatest(h.remaining_hours, 1) AS remaining_hours, greatest(h.planned_hours, 1) AS planned_hours,
1456                     t.project_id
1457                 FROM
1458                     project_task_history AS h
1459                     JOIN project_task AS t ON (h.task_id = t.id)
1460
1461             ) AS history
1462         )
1463         """)
1464
1465 class project_category(osv.osv):
1466     """ Category of project's task (or issue) """
1467     _name = "project.category"
1468     _description = "Category of project's task, issue, ..."
1469     _columns = {
1470         'name': fields.char('Name', size=64, required=True, translate=True),
1471     }
1472 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: