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