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