1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
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.
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.
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/>.
20 ##############################################################################
22 from lxml import etree
23 from mx import DateTime
24 from mx.DateTime import now
26 from tools.translate import _
28 from osv import fields, osv
29 from tools.translate import _
31 class project(osv.osv):
32 _name = "project.project"
33 _description = "Project"
35 def _complete_name(self, cr, uid, ids, name, args, context):
37 for m in self.browse(cr, uid, ids, context=context):
38 res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
42 def check_recursion(self, cursor, user, ids, parent=None):
43 return super(project, self).check_recursion(cursor, user, ids,
46 def onchange_partner_id(self, cr, uid, ids, part):
48 return {'value':{'contact_id': False, 'pricelist_id': False}}
49 addr = self.pool.get('res.partner').address_get(cr, uid, [part], ['contact'])
51 pricelist = self.pool.get('res.partner').browse(cr, uid, part).property_product_pricelist.id
52 return {'value':{'contact_id': addr['contact'], 'pricelist_id': pricelist}}
54 def _progress_rate(self, cr, uid, ids, names, arg, context=None):
55 res = {}.fromkeys(ids, 0.0)
59 ids2 = self.search(cr, uid, [('parent_id','child_of',ids)])
62 project_id, sum(planned_hours), sum(total_hours), sum(effective_hours)
66 project_id in ('''+','.join(map(str,ids2))+''') AND
70 progress = dict(map(lambda x: (x[0], (x[1],x[2],x[3])), cr.fetchall()))
71 for project in self.browse(cr, uid, ids, context=context):
76 tocompute += p.child_id
78 s[i] += progress.get(p.id, (0.0,0.0,0.0))[i]
80 'planned_hours': s[0],
81 'effective_hours': s[2],
83 'progress_rate': s[1] and (100.0 * s[2] / s[1]) or 0.0
87 def unlink(self, cr, uid, ids, *args, **kwargs):
88 for proj in self.browse(cr, uid, ids):
90 raise osv.except_osv(_('Operation Not Permitted !'), _('You can not delete a project with tasks. I suggest you to deactivate it.'))
91 return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
93 'name': fields.char("Project Name", size=128, required=True),
94 'complete_name': fields.function(_complete_name, method=True, string="Project Name", type='char', size=128),
95 'active': fields.boolean('Active', help="If the active field is set to true, it will allow you to hide the project without removing it."),
96 '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."),
97 'priority': fields.integer('Sequence'),
98 'manager': fields.many2one('res.users', 'Project Manager'),
99 '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."),
100 '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."),
101 'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
102 'parent_id': fields.many2one('project.project', 'Parent Project',\
103 help="If you have [?] in the name, it means there are no analytic account linked to project."),
104 'child_id': fields.one2many('project.project', 'parent_id', 'Subproject'),
105 '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."),
106 '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."),
107 '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."),
108 '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."),
109 'date_start': fields.date('Starting Date'),
110 'date_end': fields.date('Expected End'),
111 'partner_id': fields.many2one('res.partner', 'Partner'),
112 'contact_id': fields.many2one('res.partner.address', 'Contact'),
113 '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."),
114 '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."),
115 '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."),
116 'notes': fields.text('Notes', help="Internal description of the project."),
117 'timesheet_id': fields.many2one('hr.timesheet.group', 'Working Time', help="Timetable working hours to adjust the gantt diagram report"),
118 'state': fields.selection([('template', 'Template'), ('open', 'Running'), ('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', required=True, readonly=True,
119 help='The project can be in either if the states \'Template\' and \'Running\'.\n If it is template then we can make projects based on the template projects. If its in \'Running\' state it is a normal project.\
120 \n If it is to be reviewed then the state is \'Pending\'.\n When the project is completed the state is set to \'Done\'.'),
121 'company_id': fields.many2one('res.company', 'Company'),
125 'active': lambda *a: True,
126 'manager': lambda object,cr,uid,context: uid,
127 'priority': lambda *a: 1,
128 'date_start': lambda *a: time.strftime('%Y-%m-%d'),
129 'state': lambda *a: 'open',
130 'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.project', context=c)
133 _order = "parent_id,priority,name"
135 (check_recursion, 'Error ! You can not create recursive projects.', ['parent_id'])
138 # toggle activity of projects, their sub projects and their tasks
139 def set_template(self, cr, uid, ids, context={}):
140 res = self.setActive(cr, uid, ids, value=False, context=context)
143 def set_done(self, cr, uid, ids, context={}):
144 self.write(cr, uid, ids, {'state':'done'}, context=context)
147 def set_cancel(self, cr, uid, ids, context={}):
148 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
151 def set_pending(self, cr, uid, ids, context={}):
152 self.write(cr, uid, ids, {'state':'pending'}, context=context)
155 def set_open(self, cr, uid, ids, context={}):
156 self.write(cr, uid, ids, {'state':'open'}, context=context)
159 def reset_project(self, cr, uid, ids, context={}):
160 res = self.setActive(cr, uid, ids,value=True, context=context)
163 def copy(self, cr, uid, id, default={},context={}):
164 proj = self.browse(cr, uid, id, context=context)
165 default = default or {}
166 context['active_test'] = False
167 default['state'] = 'open'
168 if not default.get('name', False):
169 default['name'] = proj.name+_(' (copy)')
170 res = super(project, self).copy(cr, uid, id, default, context)
171 ids = self.search(cr, uid, [('parent_id','child_of', [res])])
172 cr.execute('update project_task set active=True where project_id in ('+','.join(map(str, ids))+')')
175 def duplicate_template(self, cr, uid, ids,context={}):
176 for proj in self.browse(cr, uid, ids):
177 parent_id=context.get('parent_id',False)
178 new_id=self.pool.get('project.project').copy(cr, uid, proj.id,default={'name':proj.name+_(' (copy)'),'state':'open','parent_id':parent_id})
179 cr.execute('select id from project_task where project_id=%s', (proj.id,))
181 for (tasks_id,) in res:
182 self.pool.get('project.task').copy(cr, uid, tasks_id,default={'project_id':new_id,'active':True}, context=context)
183 cr.execute('select id from project_project where parent_id=%s', (proj.id,))
185 project_ids = [x[0] for x in res]
186 for child in project_ids:
187 self.duplicate_template(cr, uid, [child],context={'parent_id':new_id})
189 # TODO : Improve this to open the new project (using a wizard)
192 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.'))
194 # set active value for a project, its sub projects and its tasks
195 def setActive(self, cr, uid, ids, value=True, context={}):
196 for proj in self.browse(cr, uid, ids, context):
197 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
198 cr.execute('select id from project_task where project_id=%s', (proj.id,))
199 tasks_id = [x[0] for x in cr.fetchall()]
201 self.pool.get('project.task').write(cr, uid, tasks_id, {'active': value}, context)
202 cr.execute('select id from project_project where parent_id=%s', (proj.id,))
203 project_ids = [x[0] for x in cr.fetchall()]
204 for child in project_ids:
205 self.setActive(cr, uid, [child], value, context)
209 class project_task_type(osv.osv):
210 _name = 'project.task.type'
211 _description = 'Project task type'
213 'name': fields.char('Type', required=True, size=64, translate=True),
214 'description': fields.text('Description'),
219 _name = "project.task"
220 _description = "Tasks"
221 _date_name = "date_start"
222 def _str_get(self, task, level=0, border='***', context={}):
223 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'+ \
224 border[0]+' '+(task.name or '')+'\n'+ \
225 (task.description or '')+'\n\n'
227 def _history_get(self, cr, uid, ids, name, args, context={}):
229 for task in self.browse(cr, uid, ids, context=context):
230 result[task.id] = self._str_get(task, border='===')
235 result[task.id] = self._str_get(t2, level) + result[task.id]
237 t3 = map(lambda x: (x,1), task.child_ids)
240 result[task.id] = result[task.id] + self._str_get(t2[0], t2[1])
241 t3 += map(lambda x: (x,t2[1]+1), t2[0].child_ids)
244 # Compute: effective_hours, total_hours, progress
245 def _hours_get(self, cr, uid, ids, field_names, args, context):
246 task_set = ','.join(map(str, ids))
247 cr.execute(("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id in (%s) GROUP BY task_id") % (task_set,))
248 hours = dict(cr.fetchall())
250 for task in self.browse(cr, uid, ids, context=context):
252 res[task.id]['effective_hours'] = hours.get(task.id, 0.0)
253 res[task.id]['total_hours'] = task.remaining_hours + hours.get(task.id, 0.0)
254 if (task.remaining_hours + hours.get(task.id, 0.0)):
255 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 100),2)
257 res[task.id]['progress'] = 0.0
258 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
261 def onchange_planned(self, cr, uid, ids, planned, effective=0.0):
262 return {'value':{'remaining_hours': planned-effective}}
264 def _default_project(self, cr, uid, context={}):
265 if 'project_id' in context and context['project_id']:
266 return context['project_id']
269 #_sql_constraints = [
270 # ('remaining_hours', 'CHECK (remaining_hours>=0)', 'Please increase and review remaining hours ! It can not be smaller than 0.'),
273 def copy_data(self, cr, uid, id, default={},context={}):
274 default = default or {}
275 default['work_ids'] = []
276 return super(task, self).copy_data(cr, uid, id, default, context)
279 'active': fields.boolean('Active', help="If the active field is set to true, it will allow you to hide the task without removing it."),
280 'name': fields.char('Task summary', size=128, required=True),
281 'description': fields.text('Description'),
282 'priority' : fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Urgent'), ('0','Very urgent')], 'Importance'),
283 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of tasks."),
284 'type': fields.many2one('project.task.type', 'Type'),
285 'state': fields.selection([('draft', 'Draft'),('open', 'In Progress'),('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', readonly=True, required=True,
286 help='If the task is created the state \'Draft\'.\n If the task is started, the state becomes \'In Progress\'.\n If review is needed the task is in \'Pending\' state.\
287 \n If the task is over, the states is set to \'Done\'.'),
288 'date_start': fields.datetime('Starting Date'),
289 'date_deadline': fields.datetime('Deadline'),
290 'date_close': fields.datetime('Date Closed', readonly=True),
291 'project_id': fields.many2one('project.project', 'Project', ondelete='cascade',
292 help="If you have [?] in the project name, it means there are no analytic account linked to this project."),
293 'parent_id': fields.many2one('project.task', 'Parent Task'),
294 'child_ids': fields.one2many('project.task', 'parent_id', 'Delegated Tasks'),
295 'history': fields.function(_history_get, method=True, string="Task Details", type="text"),
296 'notes': fields.text('Notes'),
298 '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.'),
299 '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."),
300 'remaining_hours': fields.float('Remaining Hours', digits=(16,4), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
301 'total_hours': fields.function(_hours_get, method=True, string='Total Hours', multi='hours', store=True, help="Computed as: Time Spent + Remaining Time."),
302 'progress': fields.function(_hours_get, method=True, string='Progress (%)', multi='hours', store=True, help="Computed as: Time Spent / Total Time."),
303 '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."),
305 'user_id': fields.many2one('res.users', 'Assigned to'),
306 'delegated_user_id': fields.related('child_ids','user_id',type='many2one', relation='res.users', string='Delegated To'),
307 'partner_id': fields.many2one('res.partner', 'Partner'),
308 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
309 'manager_id': fields.related('project_id','manager', type='many2one', relation='res.users', string='Project Manager'),
310 'company_id': fields.many2one('res.company', 'Company'),
313 'user_id': lambda obj,cr,uid,context: uid,
314 'state': lambda *a: 'draft',
315 'priority': lambda *a: '2',
316 'progress': lambda *a: 0,
317 'sequence': lambda *a: 10,
318 'active': lambda *a: True,
319 'date_start': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S'),
320 'project_id': _default_project,
321 'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
323 _order = "sequence, priority, date_deadline, id"
326 # Override view according to the company definition
328 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
329 obj_tm = self.pool.get('res.users').browse(cr, uid, uid, context).company_id.project_time_mode_id
330 tm = obj_tm and obj_tm.name or 'Hours'
332 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
334 if tm in ['Hours','Hour']:
337 eview = etree.fromstring(res['arch'])
339 def _check_rec(eview):
340 if eview.attrib.get('widget','') == 'float_time':
341 eview.set('widget','float')
348 res['arch'] = etree.tostring(eview)
350 for f in res['fields']:
351 if 'Hours' in res['fields'][f]['string']:
352 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
356 def do_close(self, cr, uid, ids, *args):
357 request = self.pool.get('res.request')
358 tasks = self.browse(cr, uid, ids)
360 project = task.project_id
362 if project.warn_manager and project.manager and (project.manager.id != uid):
363 request.create(cr, uid, {
364 'name': _("Task '%s' closed") % task.name,
367 'act_to': project.manager.id,
368 'ref_partner_id': task.partner_id.id,
369 'ref_doc1': 'project.task,%d'% (task.id,),
370 'ref_doc2': 'project.project,%d'% (project.id,),
372 self.write(cr, uid, [task.id], {'state': 'done', 'date_close':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
373 if task.parent_id and task.parent_id.state in ('pending','draft'):
375 for child in task.parent_id.child_ids:
376 if child.id != task.id and child.state not in ('done','cancelled'):
379 self.do_reopen(cr, uid, [task.parent_id.id])
382 def do_reopen(self, cr, uid, ids, *args):
383 request = self.pool.get('res.request')
384 tasks = self.browse(cr, uid, ids)
386 project = task.project_id
387 if project and project.warn_manager and project.manager.id and (project.manager.id != uid):
388 request.create(cr, uid, {
389 'name': _("Task '%s' set in progress") % task.name,
392 'act_to': project.manager.id,
393 'ref_partner_id': task.partner_id.id,
394 'ref_doc1': 'project.task,%d' % task.id,
395 'ref_doc2': 'project.project,%d' % project.id,
398 self.write(cr, uid, [task.id], {'state': 'open'})
401 def do_cancel(self, cr, uid, ids, *args):
402 request = self.pool.get('res.request')
403 tasks = self.browse(cr, uid, ids)
405 project = task.project_id
406 if project.warn_manager and project.manager and (project.manager.id != uid):
407 request.create(cr, uid, {
408 'name': _("Task '%s' cancelled") % task.name,
411 'act_to': project.manager.id,
412 'ref_partner_id': task.partner_id.id,
413 'ref_doc1': 'project.task,%d' % task.id,
414 'ref_doc2': 'project.project,%d' % project.id,
416 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0})
419 def do_open(self, cr, uid, ids, *args):
420 tasks= self.browse(cr,uid,ids)
422 self.write(cr, uid, [t.id], {'state': 'open'})
425 def do_draft(self, cr, uid, ids, *args):
426 self.write(cr, uid, ids, {'state': 'draft'})
430 def do_pending(self, cr, uid, ids, *args):
431 self.write(cr, uid, ids, {'state': 'pending'})
437 class project_work(osv.osv):
438 _name = "project.task.work"
439 _description = "Task Work"
441 'name': fields.char('Work summary', size=128),
442 'date': fields.datetime('Date'),
443 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True),
444 'hours': fields.float('Time Spent'),
445 'user_id': fields.many2one('res.users', 'Done by', required=True),
446 'company_id': fields.related('task_id','company_id',type='many2one',relation='res.company',string='Company',store=True)
449 'user_id': lambda obj,cr,uid,context: uid,
450 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
453 def create(self, cr, uid, vals, *args, **kwargs):
454 if 'hours' in vals and (not vals['hours']):
456 if 'task_id' in vals:
457 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
458 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
460 def write(self, cr, uid, ids,vals,context={}):
461 if 'hours' in vals and (not vals['hours']):
464 for work in self.browse(cr, uid, ids, context):
465 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))
466 return super(project_work,self).write(cr, uid, ids, vals, context)
468 def unlink(self, cr, uid, ids, *args, **kwargs):
469 for work in self.browse(cr, uid, ids):
470 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
471 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
474 class config_compute_remaining(osv.osv_memory):
475 _name='config.compute.remaining'
476 def _get_remaining(self,cr, uid, ctx):
477 if 'active_id' in ctx:
478 return self.pool.get('project.task').browse(cr,uid,ctx['active_id']).remaining_hours
482 'remaining_hours' : fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
486 'remaining_hours': _get_remaining
489 def compute_hours(self, cr, uid, ids, context=None):
490 if 'active_id' in context:
491 remaining_hrs=self.browse(cr,uid,ids)[0].remaining_hours
492 self.pool.get('project.task').write(cr,uid,context['active_id'],{'remaining_hours':remaining_hrs})
494 'type': 'ir.actions.act_window_close',
496 config_compute_remaining()
498 class message(osv.osv):
499 _name = "project.message"
500 _description = "Message"
502 'subject': fields.char('Subject', size=128),
503 'description': fields.char('Description', size =128),
504 'project_id': fields.many2one('project.project', 'Project', ondelete='cascade'),
505 'date': fields.date('Date'),
506 'user_id': fields.many2one('res.users', 'User'),
510 def _project_get(self, cr, uid, context={}):
512 ids = self.pool.get('project.project').search(cr, uid, [])
513 res = self.pool.get('project.project').read(cr, uid, ids, ['id','name'], context)
514 return [(str(r['id']),r['name']) for r in res]
515 cr.execute("""SELECT to_char(id, '99999'),name FROM project_project where manager=%s OR
516 id IN (SELECT project_id from project_user_rel where uid=%s)""" % (uid, uid))
519 class users(osv.osv):
520 _inherit = 'res.users'
521 _description = "Users"
523 'context_project_id': fields.selection(_project_get, 'Project'),
528 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: