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