[FIX] Project : Recustive creation of tasks should not be allowed
[odoo/odoo.git] / addons / project / project.py
1 # -*- encoding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution   
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>). All Rights Reserved
6 #    $Id$
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU General Public License as published by
10 #    the Free Software Foundation, either version 3 of the License, or
11 #    (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU General Public License for more details.
17 #
18 #    You should have received a copy of the GNU General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 from lxml import etree
24 from mx import DateTime
25 from mx.DateTime import now
26 import time
27 from tools.translate import _
28
29 from osv import fields, osv
30 from tools.translate import _
31
32 class project(osv.osv):
33     _name = "project.project"
34     _description = "Project"
35
36     def _complete_name(self, cr, uid, ids, name, args, context):
37         res = {}
38         for m in self.browse(cr, uid, ids, context=context):
39             res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
40         return res
41
42
43     def check_recursion(self, cursor, user, ids, parent=None):
44         return super(project, self).check_recursion(cursor, user, ids,
45                 parent=parent)
46
47     def onchange_partner_id(self, cr, uid, ids, part):
48         if not part:
49             return {'value':{'contact_id': False, 'pricelist_id': False}}
50         addr = self.pool.get('res.partner').address_get(cr, uid, [part], ['contact'])
51
52         pricelist = self.pool.get('res.partner').browse(cr, uid, part).property_product_pricelist.id
53         return {'value':{'contact_id': addr['contact'], 'pricelist_id': pricelist}}
54
55     def _progress_rate(self, cr, uid, ids, names, arg, context=None):
56         res = {}.fromkeys(ids, 0.0)
57         progress = {}
58         if not ids:
59             return res
60         ids2 = self.search(cr, uid, [('parent_id','child_of',ids)])
61         if ids2:
62             cr.execute('''SELECT
63                     project_id, sum(planned_hours), sum(total_hours), sum(effective_hours)
64                 FROM
65                     project_task 
66                 WHERE
67                     project_id in %s AND
68                     state<>'cancelled'
69                 GROUP BY
70                     project_id''',
71                        (tuple(ids2),))
72             progress = dict(map(lambda x: (x[0], (x[1],x[2],x[3])), cr.fetchall()))
73         for project in self.browse(cr, uid, ids, context=context):
74             s = [0.0,0.0,0.0]
75             tocompute = [project]
76             while tocompute:
77                 p = tocompute.pop()
78                 tocompute += p.child_id
79                 for i in range(3):
80                     s[i] += progress.get(p.id, (0.0,0.0,0.0))[i]
81             res[project.id] = {
82                 'planned_hours': s[0],
83                 'effective_hours': s[2],
84                 'total_hours': s[1],
85                 'progress_rate': s[1] and (100.0 * s[2] / s[1]) or 0.0
86             }
87         return res
88
89     def unlink(self, cr, uid, ids, *args, **kwargs):
90         for proj in self.browse(cr, uid, ids):
91             if proj.tasks:
92                 raise osv.except_osv(_('Operation Not Permitted !'), _('You can not delete a project with tasks. I suggest you to deactivate it.'))
93         return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
94     _columns = {
95         'name': fields.char("Project Name", size=128, required=True),
96         'complete_name': fields.function(_complete_name, method=True, string="Project Name", type='char', size=128),
97         'active': fields.boolean('Active'),
98         'category_id': fields.many2one('account.analytic.account','Analytic Account', 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."),
99         'priority': fields.integer('Sequence'),
100         'manager': fields.many2one('res.users', 'Project Manager'),
101         'warn_manager': fields.boolean('Warn Manager', help="If you check this field, the project manager will receive a request each time a task is completed by his team."),
102         'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members', help="Project's member. Not used in any computation, just for information purpose."),
103         'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
104         'parent_id': fields.many2one('project.project', 'Parent Project',\
105             help="If you have [?] in the name, it means there are no analytic account linked to project."),
106         'child_id': fields.one2many('project.project', 'parent_id', 'Subproject'),
107         'planned_hours': fields.function(_progress_rate, multi="progress", method=True, string='Planned Time', help="Sum of planned hours of all tasks related to this project."),
108         'effective_hours': fields.function(_progress_rate, multi="progress", method=True, string='Time Spent', help="Sum of spent hours of all tasks related to this project."),
109         'total_hours': fields.function(_progress_rate, multi="progress", method=True, string='Total Time', help="Sum of total hours of all tasks related to this project."),
110         'progress_rate': fields.function(_progress_rate, multi="progress", method=True, string='Progress', type='float', help="Percent of tasks closed according to the total of tasks todo."),
111         'date_start': fields.date('Starting Date'),
112         'date_end': fields.date('Expected End'),
113         'partner_id': fields.many2one('res.partner', 'Partner'),
114         'contact_id': fields.many2one('res.partner.address', 'Contact'),
115         'warn_customer': fields.boolean('Warn Partner', help="If you check this, the user will have a popup when closing a task that propose a message to send by email to the customer."),
116         'warn_header': fields.text('Mail Header', help="Header added at the beginning of the email for the warning message sent to the customer when a task is closed."),
117         'warn_footer': fields.text('Mail Footer', help="Footer added at the beginning of the email for the warning message sent to the customer when a task is closed."),
118         'notes': fields.text('Notes', help="Internal description of the project."),
119         'timesheet_id': fields.many2one('hr.timesheet.group', 'Working Time', help="Timetable working hours to adjust the gantt diagram report"),
120         'state': fields.selection([('template', 'Template'), ('open', 'Running'), ('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', required=True, readonly=True),
121      }
122
123     _defaults = {
124         'active': lambda *a: True,
125         'manager': lambda object,cr,uid,context: uid,
126         'priority': lambda *a: 1,
127         'date_start': lambda *a: time.strftime('%Y-%m-%d'),
128         'state': lambda *a: 'open'
129     }
130
131     _order = "parent_id,priority,name"
132     _constraints = [
133         (check_recursion, 'Error ! You can not create recursive projects.', ['parent_id'])
134     ]
135
136     # toggle activity of projects, their sub projects and their tasks
137     def set_template(self, cr, uid, ids, context={}):
138         res = self.setActive(cr, uid, ids, value=False, context=context) 
139         return res
140
141     def set_done(self, cr, uid, ids, context={}):
142         self.write(cr, uid, ids, {'state':'done'}, context=context)
143         return True
144
145     def set_cancel(self, cr, uid, ids, context={}):
146         self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
147         return True
148
149     def set_pending(self, cr, uid, ids, context={}):
150         self.write(cr, uid, ids, {'state':'pending'}, context=context)
151         return True
152
153     def set_open(self, cr, uid, ids, context={}):
154         self.write(cr, uid, ids, {'state':'open'}, context=context)
155         return True
156
157     def reset_project(self, cr, uid, ids, context={}):
158         res = self.setActive(cr, uid, ids,value=True, context=context)
159         return res
160
161     def copy(self, cr, uid, id, default={},context={}):
162         proj = self.browse(cr, uid, id, context=context)
163         default = default or {}
164         context['active_test'] = False
165         default['state'] = 'open'
166         if not default.get('name', False):
167             default['name'] = proj.name+_(' (copy)')
168         res = super(project, self).copy(cr, uid, id, default, context)
169         ids = self.search(cr, uid, [('parent_id','child_of', [res])])
170         cr.execute('update project_task set active=True where project_id in %s', (tuple(ids),))
171         return res
172
173     def duplicate_template(self, cr, uid, ids,context={}):
174         default = {'parent_id': context.get('parent_id',False)}
175         for id in ids:
176             self.copy(cr, uid, id, default=default)
177         cr.commit()
178         raise osv.except_osv(_('Operation Done'), _('A new project has been created !\nWe suggest you to close this one and work on this new project.'))
179
180     # set active value for a project, its sub projects and its tasks
181     def setActive(self, cr, uid, ids, value=True, context={}):   
182         for proj in self.browse(cr, uid, ids, context):            
183             self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
184             cr.execute('select id from project_task where project_id=%s', (proj.id,))
185             tasks_id = [x[0] for x in cr.fetchall()]
186             if tasks_id:
187                 self.pool.get('project.task').write(cr, uid, tasks_id, {'active': value}, context)
188             cr.execute('select id from project_project where parent_id=%s', (proj.id,))            
189             project_ids = [x[0] for x in cr.fetchall()]            
190             for child in project_ids:
191                 self.setActive(cr, uid, [child], value, context)                
192         return True
193 project()
194
195 class project_task_type(osv.osv):
196     _name = 'project.task.type'
197     _description = 'Project task type'
198     _columns = {
199         'name': fields.char('Type', required=True, size=64, translate=True),
200         'description': fields.text('Description'),
201     }
202 project_task_type()
203
204 class task(osv.osv):
205     _name = "project.task"
206     _description = "Tasks"
207     _date_name = "date_start"
208     def _str_get(self, task, level=0, border='***', context={}):
209         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'+ \
210             border[0]+' '+(task.name or '')+'\n'+ \
211             (task.description or '')+'\n\n'
212
213     def _history_get(self, cr, uid, ids, name, args, context={}):
214         result = {}
215         for task in self.browse(cr, uid, ids, context=context):
216             result[task.id] = self._str_get(task, border='===')
217             t2 = task.parent_id
218             level = 0
219             while t2:
220                 level -= 1
221                 result[task.id] = self._str_get(t2, level) + result[task.id]
222                 t2 = t2.parent_id
223             t3 = map(lambda x: (x,1), task.child_ids)
224             while t3:
225                 t2 = t3.pop(0)
226                 result[task.id] = result[task.id] + self._str_get(t2[0], t2[1])
227                 t3 += map(lambda x: (x,t2[1]+1), t2[0].child_ids)
228         return result
229
230 # Compute: effective_hours, total_hours, progress
231     def _hours_get(self, cr, uid, ids, field_names, args, context):
232         cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id in %s GROUP BY task_id", (tuple(ids),))
233         hours = dict(cr.fetchall())
234         res = {}
235         for task in self.browse(cr, uid, ids, context=context):
236             res[task.id] = {}
237             res[task.id]['effective_hours'] = hours.get(task.id, 0.0)
238             res[task.id]['total_hours'] = task.remaining_hours + hours.get(task.id, 0.0)
239             if (task.remaining_hours + hours.get(task.id, 0.0)):
240                 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 100),2)
241             else:
242                 res[task.id]['progress'] = 0.0
243             res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
244         return res
245
246     def onchange_planned(self, cr, uid, ids, planned, effective=0.0):
247         return {'value':{'remaining_hours': planned-effective}}
248
249     def _default_project(self, cr, uid, context={}):
250         if 'project_id' in context and context['project_id']:
251             return context['project_id']
252         return False
253
254     #_sql_constraints = [
255     #    ('remaining_hours', 'CHECK (remaining_hours>=0)', 'Please increase and review remaining hours ! It can not be smaller than 0.'),
256     #]
257     
258     def copy_data(self, cr, uid, id, default={},context={}):
259         default = default or {}
260         default['work_ids'] = []
261         return super(task, self).copy_data(cr, uid, id, default, context)
262
263     _columns = {
264         'active': fields.boolean('Active'),
265         'name': fields.char('Task summary', size=128, required=True),
266         'description': fields.text('Description'),
267         'priority' : fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Urgent'), ('0','Very urgent')], 'Importance'),
268         'sequence': fields.integer('Sequence'),
269         'type': fields.many2one('project.task.type', 'Type'),
270         'state': fields.selection([('draft', 'Draft'),('open', 'In Progress'),('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'Status', readonly=True, required=True),
271         'date_start': fields.datetime('Starting Date'),
272         'date_deadline': fields.datetime('Deadline'),
273         'date_close': fields.datetime('Date Closed', readonly=True),
274         'project_id': fields.many2one('project.project', 'Project', ondelete='cascade',
275             help="If you have [?] in the project name, it means there are no analytic account linked to this project."),
276         'parent_id': fields.many2one('project.task', 'Parent Task'),
277         'child_ids': fields.one2many('project.task', 'parent_id', 'Delegated Tasks'),
278         'history': fields.function(_history_get, method=True, string="Task Details", type="text"),
279         'notes': fields.text('Notes'),
280
281         'planned_hours': fields.float('Planned Hours', required=True, help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
282         'effective_hours': fields.function(_hours_get, method=True, string='Hours Spent', multi='hours', store=True, help="Computed using the sum of the task work done."),
283         'remaining_hours': fields.float('Remaining Hours', digits=(16,4), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
284         'total_hours': fields.function(_hours_get, method=True, string='Total Hours', multi='hours', store=True, help="Computed as: Time Spent + Remaining Time."),
285         'progress': fields.function(_hours_get, method=True, string='Progress (%)', multi='hours', store=True, help="Computed as: Time Spent / Total Time."),
286         'delay_hours': fields.function(_hours_get, method=True, string='Delay Hours', multi='hours', store=True, help="Computed as: Total Time - Estimated Time. It gives the difference of the time estimated by the project manager and the real time to close the task."),
287
288         'user_id': fields.many2one('res.users', 'Assigned to'),
289         'delegated_user_id': fields.related('child_ids','user_id',type='many2one', relation='res.users', string='Delegated To'),
290         'partner_id': fields.many2one('res.partner', 'Partner'),
291         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
292     }
293     _defaults = {
294         'user_id': lambda obj,cr,uid,context: uid,
295         'state': lambda *a: 'draft',
296         'priority': lambda *a: '2',
297         'progress': lambda *a: 0,
298         'sequence': lambda *a: 10,
299         'active': lambda *a: True,
300         'date_start': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S'),
301         'project_id': _default_project,
302     }
303     _order = "sequence, priority, date_deadline, id"
304
305     def _check_recursion(self, cr, uid, ids):
306         level = 100
307         while len(ids):
308             cr.execute('select distinct parent_id from project_task where id in %s', (tuple(ids),))
309             ids = filter(None, map(lambda x:x[0], cr.fetchall()))
310             if not level:
311                 return False
312             level -= 1
313         return True
314
315     _constraints = [
316         (_check_recursion, _('Error ! You cannot create recursive tasks.'), ['parent_id'])
317     ]
318     #
319     # Override view according to the company definition
320     #
321     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False):
322         tm = self.pool.get('res.users').browse(cr, uid, uid, context).company_id.project_time_mode or False
323         f = self.pool.get('res.company').fields_get(cr, uid, ['project_time_mode'], context)
324         word = 'Hours'
325         if tm:
326             word = dict(f['project_time_mode']['selection'])[tm]
327
328         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar)
329         if (not tm) or (tm=='hours'):
330             return res
331         eview = etree.fromstring(res['arch'])
332         def _check_rec(eview, tm):
333             if eview.attrib.get('widget',False) == 'float_time':
334                 eview.set('widget','float')
335             for child in eview:
336                 _check_rec(child, tm)
337             return True
338         _check_rec(eview, tm)
339         res['arch'] = etree.tostring(eview)
340         for f in res['fields']:
341             if 'Hours' in res['fields'][f]['string']:
342                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',word)
343         return res
344
345     def do_close(self, cr, uid, ids, *args):
346         request = self.pool.get('res.request')
347         tasks = self.browse(cr, uid, ids)
348         for task in tasks:
349             project = task.project_id
350             if project:
351                 if project.warn_manager and project.manager and (project.manager.id != uid):
352                     request.create(cr, uid, {
353                         'name': _("Task '%s' closed") % task.name,
354                         'state': 'waiting',
355                         'act_from': uid,
356                         'act_to': project.manager.id,
357                         'ref_partner_id': task.partner_id.id,
358                         'ref_doc1': 'project.task,%d'% (task.id,),
359                         'ref_doc2': 'project.project,%d'% (project.id,),
360                     })
361             self.write(cr, uid, [task.id], {'state': 'done', 'date_close':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
362             if task.parent_id and task.parent_id.state in ('pending','draft'):
363                 reopen = True
364                 for child in task.parent_id.child_ids:
365                     if child.id != task.id and child.state not in ('done','cancelled'):
366                         reopen = False
367                 if reopen:
368                     self.do_reopen(cr, uid, [task.parent_id.id])
369         return True
370
371     def do_reopen(self, cr, uid, ids, *args):
372         request = self.pool.get('res.request')
373         tasks = self.browse(cr, uid, ids)
374         for task in tasks:
375             project = task.project_id
376             if project and project.warn_manager and project.manager.id and (project.manager.id != uid):
377                 request.create(cr, uid, {
378                     'name': _("Task '%s' set in progress") % task.name,
379                     'state': 'waiting',
380                     'act_from': uid,
381                     'act_to': project.manager.id,
382                     'ref_partner_id': task.partner_id.id,
383                     'ref_doc1': 'project.task,%d' % task.id,
384                     'ref_doc2': 'project.project,%d' % project.id,
385                 })
386
387             self.write(cr, uid, [task.id], {'state': 'open'})
388         return True
389
390     def do_cancel(self, cr, uid, ids, *args):
391         request = self.pool.get('res.request')
392         tasks = self.browse(cr, uid, ids)
393         for task in tasks:
394             project = task.project_id
395             if project.warn_manager and project.manager and (project.manager.id != uid):
396                 request.create(cr, uid, {
397                     'name': _("Task '%s' cancelled") % task.name,
398                     'state': 'waiting',
399                     'act_from': uid,
400                     'act_to': project.manager.id,
401                     'ref_partner_id': task.partner_id.id,
402                     'ref_doc1': 'project.task,%d' % task.id,
403                     'ref_doc2': 'project.project,%d' % project.id,
404                 })
405             self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0})
406         return True
407
408     def do_open(self, cr, uid, ids, *args):
409         tasks= self.browse(cr,uid,ids)
410         for t in tasks:
411             self.write(cr, uid, [t.id], {'state': 'open'})
412         return True
413
414     def do_draft(self, cr, uid, ids, *args):
415         self.write(cr, uid, ids, {'state': 'draft'})
416         return True
417
418
419     def do_pending(self, cr, uid, ids, *args):
420         self.write(cr, uid, ids, {'state': 'pending'})
421         return True
422
423
424 task()
425
426 class project_work(osv.osv):
427     _name = "project.task.work"
428     _description = "Task Work"
429     _columns = {
430         'name': fields.char('Work summary', size=128),
431         'date': fields.datetime('Date'),
432         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True),
433         'hours': fields.float('Time Spent'),
434         'user_id': fields.many2one('res.users', 'Done by', required=True),
435     }
436     _defaults = {
437         'user_id': lambda obj,cr,uid,context: uid,
438         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
439     }
440     _order = "date desc"
441     def create(self, cr, uid, vals, *args, **kwargs):
442         if 'hours' in vals and (not vals['hours']):
443             vals['hours'] = 0.00
444         if 'task_id' in vals:
445             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
446         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
447
448     def write(self, cr, uid, ids,vals,context={}):
449         if 'hours' in vals and (not vals['hours']):
450             vals['hours'] = 0.00
451         if 'hours' in vals:
452             for work in self.browse(cr, uid, ids, context):
453                 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))
454         return super(project_work,self).write(cr, uid, ids, vals, context)
455
456     def unlink(self, cr, uid, ids, *args, **kwargs):
457         for work in self.browse(cr, uid, ids):
458             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
459         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
460 project_work()
461
462 class config_compute_remaining(osv.osv_memory):
463     _name='config.compute.remaining'
464     def _get_remaining(self,cr, uid, ctx):
465         if 'active_id' in ctx:
466             return self.pool.get('project.task').browse(cr,uid,ctx['active_id']).remaining_hours
467         return False
468
469     _columns = {
470         'remaining_hours' : fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
471             }
472
473     _defaults = {
474         'remaining_hours': _get_remaining
475         }
476     
477     def compute_hours(self, cr, uid, ids, context=None):
478         if 'active_id' in context:
479             remaining_hrs=self.browse(cr,uid,ids)[0].remaining_hours
480             self.pool.get('project.task').write(cr,uid,context['active_id'],{'remaining_hours':remaining_hrs})
481         return {
482                 'type': 'ir.actions.act_window_close',
483          }
484 config_compute_remaining()
485
486 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
487