1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2009 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"
34 _inherits = {'account.analytic.account':"category_id"}
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_ids
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 'resource_ids': fields.many2many('resource.resource', 'project_resource_rel', 'project_id', 'resource_id', '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 # 'partner_id': fields.many2one('res.partner', 'Partner'),
110 # 'contact_id': fields.many2one('res.partner.address', 'Contact'),
111 '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."),
112 '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."),
113 '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."),
114 # 'notes': fields.text('Notes', help="Internal description of the project."),
115 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report"),
116 # 'state': fields.selection([('template', 'Template'), ('open', 'Running'), ('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', required=True, readonly=True,
117 # 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.\
118 # \n If it is to be reviewed then the state is \'Pending\'.\n When the project is completed the state is set to \'Done\'.'),
119 # 'company_id': fields.many2one('res.company', 'Company'),
123 'active': lambda *a: True,
124 # 'manager': lambda object,cr,uid,context: uid,
125 'priority': lambda *a: 1,
126 # 'state': lambda *a: 'open',
127 # 'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.project', context=c)
130 # _order = "parent_id,priority,name"
132 # (check_recursion, 'Error ! You can not create recursive projects.', ['parent_id'])
135 # toggle activity of projects, their sub projects and their tasks
136 def set_template(self, cr, uid, ids, context={}):
137 res = self.setActive(cr, uid, ids, value=False, context=context)
140 def set_done(self, cr, uid, ids, context={}):
141 self.write(cr, uid, ids, {'state':'done'}, context=context)
144 def set_cancel(self, cr, uid, ids, context={}):
145 self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
148 def set_pending(self, cr, uid, ids, context={}):
149 self.write(cr, uid, ids, {'state':'pending'}, context=context)
152 def set_open(self, cr, uid, ids, context={}):
153 self.write(cr, uid, ids, {'state':'open'}, context=context)
156 def reset_project(self, cr, uid, ids, context={}):
157 res = self.setActive(cr, uid, ids,value=True, context=context)
160 def copy(self, cr, uid, id, default={},context={}):
161 proj = self.browse(cr, uid, id, context=context)
162 default = default or {}
163 context['active_test'] = False
164 default['state'] = 'open'
165 if not default.get('name', False):
166 default['name'] = proj.name+_(' (copy)')
167 res = super(project, self).copy(cr, uid, id, default, context)
168 ids = self.search(cr, uid, [('parent_id','child_of', [res])])
169 cr.execute('update project_task set active=True where project_id in ('+','.join(map(str, ids))+')')
172 def duplicate_template(self, cr, uid, ids,context={}):
173 for proj in self.browse(cr, uid, ids):
174 parent_id=context.get('parent_id',False)
175 new_id=self.pool.get('project.project').copy(cr, uid, proj.id,default={'name':proj.name+_(' (copy)'),'state':'open','parent_id':parent_id})
176 cr.execute('select id from project_task where project_id=%s', (proj.id,))
178 for (tasks_id,) in res:
179 self.pool.get('project.task').copy(cr, uid, tasks_id,default={'project_id':new_id,'active':True}, context=context)
180 cr.execute('select id from project_project where parent_id=%s', (proj.id,))
182 project_ids = [x[0] for x in res]
183 for child in project_ids:
184 self.duplicate_template(cr, uid, [child],context={'parent_id':new_id})
186 # TODO : Improve this to open the new project (using a wizard)
189 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.'))
191 # set active value for a project, its sub projects and its tasks
192 def setActive(self, cr, uid, ids, value=True, context={}):
193 for proj in self.browse(cr, uid, ids, context):
194 self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
195 cr.execute('select id from project_task where project_id=%s', (proj.id,))
196 tasks_id = [x[0] for x in cr.fetchall()]
198 self.pool.get('project.task').write(cr, uid, tasks_id, {'active': value}, context)
199 cr.execute('select id from project_project where parent_id=%s', (proj.id,))
200 project_ids = [x[0] for x in cr.fetchall()]
201 for child in project_ids:
202 self.setActive(cr, uid, [child], value, context)
206 class project_task_type(osv.osv):
207 _name = 'project.task.type'
208 _description = 'Project task type'
210 'name': fields.char('Type', required=True, size=64, translate=True),
211 'description': fields.text('Description'),
216 _name = "project.task"
217 _description = "Tasks"
218 _date_name = "date_start"
220 def _str_get(self, task, level=0, border='***', context={}):
221 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'+ \
222 border[0]+' '+(task.name or '')+'\n'+ \
223 (task.description or '')+'\n\n'
225 def _history_get(self, cr, uid, ids, name, args, context={}):
227 for task in self.browse(cr, uid, ids, context=context):
228 result[task.id] = self._str_get(task, border='===')
233 result[task.id] = self._str_get(t2, level) + result[task.id]
235 t3 = map(lambda x: (x,1), task.child_ids)
238 result[task.id] = result[task.id] + self._str_get(t2[0], t2[1])
239 t3 += map(lambda x: (x,t2[1]+1), t2[0].child_ids)
242 # Compute: effective_hours, total_hours, progress
243 def _hours_get(self, cr, uid, ids, field_names, args, context):
244 task_set = ','.join(map(str, ids))
245 cr.execute(("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id in (%s) GROUP BY task_id") % (task_set,))
246 hours = dict(cr.fetchall())
248 for task in self.browse(cr, uid, ids, context=context):
250 res[task.id]['effective_hours'] = hours.get(task.id, 0.0)
251 res[task.id]['total_hours'] = task.remaining_hours + hours.get(task.id, 0.0)
252 if (task.remaining_hours + hours.get(task.id, 0.0)):
253 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 100),2)
255 res[task.id]['progress'] = 0.0
256 res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
259 def onchange_planned(self, cr, uid, ids, planned, effective, date_start,occupation_rate=0.0):
261 for res in self.browse(cr, uid, ids):
262 if date_start and planned:
263 resource_id = self.pool.get('resource.resource').search(cr,uid,[('user_id','=',res.user_id.id)])
264 resource_obj = self.pool.get('resource.resource').browse(cr,uid,resource_id)[0]
265 print 'Resource Calendar::::',resource_obj.calendar_id.id
266 d = mx.DateTime.strptime(date_start,'%Y-%m-%d %H:%M:%S')
267 hrs = (planned)/(occupation_rate)
268 print 'Hours::::',hrs
269 work_times = self.pool.get('resource.calendar').interval_get(cr, uid, resource_obj.calendar_id.id or False, d, hrs or 0.0, resource_obj.id)
270 print 'Date_end',work_times
271 result['date_end'] = work_times[-1][1].strftime('%Y-%m-%d %H:%M:%S')
272 print 'Date End',result['date_end']
273 result['remaining_hours'] = planned-effective
274 return {'value':result}
277 def _default_project(self, cr, uid, context={}):
278 if 'project_id' in context and context['project_id']:
279 return context['project_id']
282 #_sql_constraints = [
283 # ('remaining_hours', 'CHECK (remaining_hours>=0)', 'Please increase and review remaining hours ! It can not be smaller than 0.'),
286 def copy_data(self, cr, uid, id, default={},context={}):
287 default = default or {}
288 default['work_ids'] = []
289 return super(task, self).copy_data(cr, uid, id, default, context)
291 def _check_date(self,cr,uid,ids):
292 for res in self.browse(cr,uid,ids):
293 if res.date_start and res.date_end:
294 if res.date_start > res.date_end:
299 'active': fields.boolean('Active', help="If the active field is set to true, it will allow you to hide the task without removing it."),
300 'name': fields.char('Task summary', size=128, required=True),
301 'description': fields.text('Description'),
302 'priority' : fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Urgent'), ('0','Very urgent')], 'Importance'),
303 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of tasks."),
304 'type': fields.many2one('project.task.type', 'Type'),
305 'state': fields.selection([('draft', 'Draft'),('open', 'In Progress'),('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', readonly=True, required=True,
306 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.\
307 \n If the task is over, the states is set to \'Done\'.'),
308 'date_start': fields.datetime('Starting Date'),
309 'date_end': fields.datetime('Ending Date'),
310 'date_deadline': fields.datetime('Deadline'),
311 'date_close': fields.datetime('Date Closed', readonly=True),
312 'project_id': fields.many2one('project.project', 'Project', ondelete='cascade',
313 help="If you have [?] in the project name, it means there are no analytic account linked to this project."),
314 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
315 'child_ids': fields.many2many('project.task', 'project_task_child_rel', 'task_id', 'child_id', 'Delegated Tasks'),
316 'history': fields.function(_history_get, method=True, string="Task Details", type="text"),
317 'notes': fields.text('Notes'),
318 'occupation_rate': fields.float('Occupation Rate', help='The occupation rate fields indicates how much of his time a user is working on a task. A 100% occupation rate means the user works full time on the tasks. The ending date of a task is computed like this: Starting Date + Duration / Occupation Rate.'),
319 '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.'),
320 '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."),
321 'remaining_hours': fields.float('Remaining Hours', digits=(16,4), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
322 'total_hours': fields.function(_hours_get, method=True, string='Total Hours', multi='hours', store=True, help="Computed as: Time Spent + Remaining Time."),
323 'progress': fields.function(_hours_get, method=True, string='Progress (%)', multi='hours', store=True, help="Computed as: Time Spent / Total Time."),
324 '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."),
326 'user_id': fields.many2one('res.users', 'Assigned to'),
327 'delegated_user_id': fields.related('child_ids','user_id',type='many2one', relation='res.users', string='Delegated To'),
328 'partner_id': fields.many2one('res.partner', 'Partner'),
329 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
330 'manager_id': fields.related('project_id','manager', type='many2one', relation='res.users', string='Project Manager'),
331 'company_id': fields.many2one('res.company', 'Company'),
334 'user_id': lambda obj,cr,uid,context: uid,
335 'state': lambda *a: 'draft',
336 'priority': lambda *a: '2',
337 'progress': lambda *a: 0,
338 'sequence': lambda *a: 10,
339 'active': lambda *a: True,
340 'date_start': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S'),
341 'project_id': _default_project,
342 'occupation_rate':lambda *a: '1',
343 'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
345 _order = "sequence, priority, date_deadline, id"
348 # Override view according to the company definition
350 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
351 obj_tm = self.pool.get('res.users').browse(cr, uid, uid, context).company_id.project_time_mode_id
352 tm = obj_tm and obj_tm.name or 'Hours'
354 res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
356 if tm in ['Hours','Hour']:
359 eview = etree.fromstring(res['arch'])
361 def _check_rec(eview):
362 if eview.attrib.get('widget','') == 'float_time':
363 eview.set('widget','float')
370 res['arch'] = etree.tostring(eview)
372 for f in res['fields']:
373 if 'Hours' in res['fields'][f]['string']:
374 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
378 def do_close(self, cr, uid, ids, *args):
379 request = self.pool.get('res.request')
380 tasks = self.browse(cr, uid, ids)
382 project = task.project_id
384 if project.warn_manager and project.user_id and (project.user_id.id != uid):
385 request.create(cr, uid, {
386 'name': _("Task '%s' closed") % task.name,
389 'act_to': project.user_id.id,
390 'ref_partner_id': task.partner_id.id,
391 'ref_doc1': 'project.task,%d'% (task.id,),
392 'ref_doc2': 'project.project,%d'% (project.id,),
394 self.write(cr, uid, [task.id], {'state': 'done', 'date_close':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
395 if task.parent_ids and task.parent_ids.state in ('pending','draft'):
397 for child in task.parent_ids.child_ids:
398 if child.id != task.id and child.state not in ('done','cancelled'):
401 self.do_reopen(cr, uid, [task.parent_ids.id])
404 def do_reopen(self, cr, uid, ids, *args):
405 request = self.pool.get('res.request')
406 tasks = self.browse(cr, uid, ids)
408 project = task.project_id
409 if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
410 request.create(cr, uid, {
411 'name': _("Task '%s' set in progress") % task.name,
414 'act_to': project.user_id.id,
415 'ref_partner_id': task.partner_id.id,
416 'ref_doc1': 'project.task,%d' % task.id,
417 'ref_doc2': 'project.project,%d' % project.id,
420 self.write(cr, uid, [task.id], {'state': 'open'})
423 def do_cancel(self, cr, uid, ids, *args):
424 request = self.pool.get('res.request')
425 tasks = self.browse(cr, uid, ids)
427 project = task.project_id
428 if project.warn_manager and project.user_id and (project.user_id.id != uid):
429 request.create(cr, uid, {
430 'name': _("Task '%s' cancelled") % task.name,
433 'act_to': project.user_id.id,
434 'ref_partner_id': task.partner_id.id,
435 'ref_doc1': 'project.task,%d' % task.id,
436 'ref_doc2': 'project.project,%d' % project.id,
438 self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0})
441 def do_open(self, cr, uid, ids, *args):
442 tasks= self.browse(cr,uid,ids)
444 self.write(cr, uid, [t.id], {'state': 'open'})
447 def do_draft(self, cr, uid, ids, *args):
448 self.write(cr, uid, ids, {'state': 'draft'})
452 def do_pending(self, cr, uid, ids, *args):
453 self.write(cr, uid, ids, {'state': 'pending'})
459 class project_work(osv.osv):
460 _name = "project.task.work"
461 _description = "Task Work"
463 'name': fields.char('Work summary', size=128),
464 'date': fields.datetime('Date'),
465 'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True),
466 'hours': fields.float('Time Spent'),
467 'user_id': fields.many2one('res.users', 'Done by', required=True),
468 'company_id': fields.related('task_id','company_id',type='many2one',relation='res.company',string='Company',store=True)
471 'user_id': lambda obj,cr,uid,context: uid,
472 'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
475 def create(self, cr, uid, vals, *args, **kwargs):
476 if 'hours' in vals and (not vals['hours']):
478 if 'task_id' in vals:
479 cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
480 return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
482 def write(self, cr, uid, ids,vals,context={}):
483 if 'hours' in vals and (not vals['hours']):
486 for work in self.browse(cr, uid, ids, context):
487 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))
488 return super(project_work,self).write(cr, uid, ids, vals, context)
490 def unlink(self, cr, uid, ids, *args, **kwargs):
491 for work in self.browse(cr, uid, ids):
492 cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
493 return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
496 class config_compute_remaining(osv.osv_memory):
497 _name='config.compute.remaining'
498 def _get_remaining(self,cr, uid, ctx):
499 if 'active_id' in ctx:
500 return self.pool.get('project.task').browse(cr,uid,ctx['active_id']).remaining_hours
504 'remaining_hours' : fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
508 'remaining_hours': _get_remaining
511 def compute_hours(self, cr, uid, ids, context=None):
512 if 'active_id' in context:
513 remaining_hrs=self.browse(cr,uid,ids)[0].remaining_hours
514 self.pool.get('project.task').write(cr,uid,context['active_id'],{'remaining_hours':remaining_hrs})
516 'type': 'ir.actions.act_window_close',
518 config_compute_remaining()
520 class message(osv.osv):
521 _name = "project.message"
522 _description = "Message"
524 'subject': fields.char('Subject', size=128),
525 'description': fields.char('Description', size =128),
526 'project_id': fields.many2one('project.project', 'Project', ondelete='cascade'),
527 'date': fields.date('Date'),
528 'user_id': fields.many2one('res.users', 'User'),
532 def _project_get(self, cr, uid, context={}):
533 obj = self.pool.get('project.project')
534 ids = obj.search(cr, uid, [])
535 res = obj.read(cr, uid, ids, ['id','name'], context)
536 res = [(str(r['id']),r['name']) for r in res]
539 class users(osv.osv):
540 _inherit = 'res.users'
541 _description = "Users"
543 'context_project_id': fields.selection(_project_get, 'Project'),
548 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: