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