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