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 base_status.base_stage import base_stage
24 from datetime import datetime
25 from osv import fields,osv
26 from tools.translate import _
31 class project_issue_version(osv.osv):
32 _name = "project.issue.version"
35 'name': fields.char('Version Number', size=32, required=True),
36 'active': fields.boolean('Active', required=False),
41 project_issue_version()
43 _ISSUE_STATE= [('draft', 'New'), ('open', 'In Progress'), ('cancel', 'Cancelled'), ('done', 'Done'),('pending', 'Pending')]
45 class project_issue(base_stage, osv.osv):
46 _name = "project.issue"
47 _description = "Project Issue"
48 _order = "priority, create_date desc"
49 _inherit = ['mail.thread', 'ir.needaction_mixin']
51 def _get_default_project_id(self, cr, uid, context=None):
52 """ Gives default project by checking if present in the context """
53 return self._resolve_project_id_from_context(cr, uid, context=context)
55 def _get_default_stage_id(self, cr, uid, context=None):
56 """ Gives default stage_id """
57 project_id = self._get_default_project_id(cr, uid, context=context)
58 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
60 def _resolve_project_id_from_context(self, cr, uid, context=None):
61 """ Returns ID of project based on the value of 'default_project_id'
62 context key, or None if it cannot be resolved to a single
67 if type(context.get('default_project_id')) in (int, long):
68 return context.get('default_project_id')
69 if isinstance(context.get('default_project_id'), basestring):
70 project_name = context['default_project_id']
71 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
72 if len(project_ids) == 1:
73 return int(project_ids[0][0])
76 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
77 access_rights_uid = access_rights_uid or uid
78 stage_obj = self.pool.get('project.task.type')
79 order = stage_obj._order
80 # lame hack to allow reverting search, should just work in the trivial case
81 if read_group_order == 'stage_id desc':
82 order = "%s desc" % order
83 # retrieve section_id from the context and write the domain
84 # - ('id', 'in', 'ids'): add columns that should be present
85 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
86 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
88 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
90 search_domain += ['|', ('project_ids', '=', project_id)]
91 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
93 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
94 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
95 # restore order of the search
96 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
99 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
100 fold[stage.id] = stage.fold or False
103 def _compute_day(self, cr, uid, ids, fields, args, context=None):
105 @param cr: the current row, from the database cursor,
106 @param uid: the current user’s ID for security checks,
107 @param ids: List of Openday’s IDs
108 @return: difference between current date and log date
109 @param context: A standard dictionary for contextual values
111 cal_obj = self.pool.get('resource.calendar')
112 res_obj = self.pool.get('resource.resource')
115 for issue in self.browse(cr, uid, ids, context=context):
122 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
123 if field in ['working_hours_open','day_open']:
125 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
126 ans = date_open - date_create
127 date_until = issue.date_open
128 #Calculating no. of working hours to open the issue
129 if issue.project_id.resource_calendar_id:
130 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
133 elif field in ['working_hours_close','day_close']:
134 if issue.date_closed:
135 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
136 date_until = issue.date_closed
137 ans = date_close - date_create
138 #Calculating no. of working hours to close the issue
139 if issue.project_id.resource_calendar_id:
140 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
143 elif field in ['days_since_creation']:
144 if issue.create_date:
145 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
146 res[issue.id][field] = days_since_creation.days
149 elif field in ['inactivity_days']:
150 res[issue.id][field] = 0
151 if issue.date_action_last:
152 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
153 res[issue.id][field] = inactive_days.days
158 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
159 if resource_ids and len(resource_ids):
160 resource_id = resource_ids[0]
161 duration = float(ans.days)
162 if issue.project_id and issue.project_id.resource_calendar_id:
163 duration = float(ans.days) * 24
165 new_dates = cal_obj.interval_min_get(cr, uid,
166 issue.project_id.resource_calendar_id.id,
168 duration, resource=resource_id)
170 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
171 for in_time, out_time in new_dates:
172 if in_time.date not in no_days:
173 no_days.append(in_time.date)
174 if out_time > date_until:
176 duration = len(no_days)
178 if field in ['working_hours_open','working_hours_close']:
179 res[issue.id][field] = hours
181 res[issue.id][field] = abs(float(duration))
185 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
186 task_pool = self.pool.get('project.task')
188 for issue in self.browse(cr, uid, ids, context=context):
191 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
192 res[issue.id] = {'progress' : progress}
195 def on_change_project(self, cr, uid, ids, project_id, context=None):
198 def _get_issue_task(self, cr, uid, ids, context=None):
200 issue_pool = self.pool.get('project.issue')
201 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
202 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
205 def _get_issue_work(self, cr, uid, ids, context=None):
207 issue_pool = self.pool.get('project.issue')
208 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
210 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
214 'id': fields.integer('ID', readonly=True),
215 'name': fields.char('Issue', size=128, required=True),
216 'active': fields.boolean('Active', required=False),
217 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
218 'write_date': fields.datetime('Update Date', readonly=True),
219 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
220 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
221 'date_deadline': fields.date('Deadline'),
222 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
223 select=True, help='Sales team to which Case belongs to.\
224 Define Responsible user and Email account for mail gateway.'),
225 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
226 'company_id': fields.many2one('res.company', 'Company'),
227 'description': fields.text('Description'),
228 'state': fields.related('stage_id', 'state', type="selection", store=True,
229 selection=_ISSUE_STATE, string="State", readonly=True,
230 help='The state is set to \'Draft\', when a case is created.\
231 If the case is in progress the state is set to \'Open\'.\
232 When the case is over, the state is set to \'Done\'.\
233 If the case needs to be reviewed then the state is \
234 set to \'Pending\'.'),
235 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
236 'email_cc': fields.char('Watchers Emails', size=256, help="These email addresses will be added to the CC field of all inbound and outbound emails for this record before being sent. Separate multiple email addresses with a comma"),
237 'date_open': fields.datetime('Opened', readonly=True,select=True),
238 # Project Issue fields
239 'date_closed': fields.datetime('Closed', readonly=True,select=True),
240 'date': fields.datetime('Date'),
241 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
242 'categ_ids': fields.many2many('project.category', string='Categories'),
243 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
244 'version_id': fields.many2one('project.issue.version', 'Version'),
245 'stage_id': fields.many2one ('project.task.type', 'Stage',
246 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
247 'project_id':fields.many2one('project.project', 'Project'),
248 'duration': fields.float('Duration'),
249 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
250 'day_open': fields.function(_compute_day, string='Days to Open', \
251 multi='compute_day', type="float", store=True),
252 'day_close': fields.function(_compute_day, string='Days to Close', \
253 multi='compute_day', type="float", store=True),
254 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1),
255 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
256 multi='compute_day', type="float", store=True),
257 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
258 multi='compute_day', type="float", store=True),
259 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
260 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
261 'color': fields.integer('Color Index'),
262 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
263 'date_action_last': fields.datetime('Last Action', readonly=1),
264 'date_action_next': fields.datetime('Next Action', readonly=1),
265 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
267 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
268 'project.task': (_get_issue_task, ['progress'], 10),
269 'project.task.work': (_get_issue_work, ['hours'], 10),
275 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
276 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
278 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
279 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
280 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
281 'priority': crm.AVAILABLE_PRIORITIES[2][0],
285 'stage_id': _read_group_stage_ids
288 def set_priority(self, cr, uid, ids, priority, *args):
291 return self.write(cr, uid, ids, {'priority' : priority})
293 def set_high_priority(self, cr, uid, ids, *args):
294 """Set lead priority to high
296 return self.set_priority(cr, uid, ids, '1')
298 def set_normal_priority(self, cr, uid, ids, *args):
299 """Set lead priority to normal
301 return self.set_priority(cr, uid, ids, '3')
303 def convert_issue_task(self, cr, uid, ids, context=None):
307 case_obj = self.pool.get('project.issue')
308 data_obj = self.pool.get('ir.model.data')
309 task_obj = self.pool.get('project.task')
311 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
312 res = data_obj.read(cr, uid, result, ['res_id'])
313 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
314 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
316 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
318 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
320 for bug in case_obj.browse(cr, uid, ids, context=context):
321 new_task_id = task_obj.create(cr, uid, {
323 'partner_id': bug.partner_id.id,
324 'description':bug.description,
325 'date_deadline': bug.date,
326 'project_id': bug.project_id.id,
327 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
328 'priority': str(int(bug.priority) - 1),
329 'user_id': bug.user_id.id,
330 'planned_hours': 0.0,
333 'task_id': new_task_id,
336 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
337 case_obj.write(cr, uid, [bug.id], vals, context=context)
338 self.case_pending_send_note(cr, uid, [bug.id], context=context)
343 'view_mode': 'form,tree',
344 'res_model': 'project.task',
345 'res_id': int(new_task_id),
347 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
348 'type': 'ir.actions.act_window',
349 'search_view_id': res['res_id'],
353 def copy(self, cr, uid, id, default=None, context=None):
354 issue = self.read(cr, uid, id, ['name'], context=context)
357 default = default.copy()
358 default['name'] = issue['name'] + _(' (copy)')
359 return super(project_issue, self).copy(cr, uid, id, default=default,
362 def write(self, cr, uid, ids, vals, context=None):
363 #Update last action date every time the user change the stage, the state or send a new email
364 logged_fields = ['stage_id', 'state', 'message_ids']
365 if any([field in vals for field in logged_fields]):
366 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
367 return super(project_issue, self).write(cr, uid, ids, vals, context)
369 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
372 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
373 return {'value':{'user_id': task.user_id.id,}}
375 def case_reset(self, cr, uid, ids, context=None):
376 """Resets case as draft
378 res = super(project_issue, self).case_reset(cr, uid, ids, context)
379 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
382 def create(self, cr, uid, vals, context=None):
383 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
384 project_obj = self.pool.get("project.project")
385 project_id = self.browse(cr, uid, obj_id, context=context).project_id
386 followers = [follower.id for follower in project_id.message_follower_ids]
387 self.message_subscribe(cr, uid, [obj_id], followers, context=context)
388 self.create_send_note(cr, uid, [obj_id], context=context)
391 # -------------------------------------------------------
393 # -------------------------------------------------------
395 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
396 """ Override of the base.stage method
397 Parameter of the stage search taken from the issue:
398 - type: stage type must be the same or 'both'
399 - section_id: if set, stages must belong to this section or
402 if isinstance(cases, (int, long)):
403 cases = self.browse(cr, uid, cases, context=context)
404 # collect all section_ids
407 section_ids.append(section_id)
410 section_ids.append(task.project_id.id)
411 # OR all section_ids and OR with case_default
414 search_domain += [('|')] * len(section_ids)
415 for section_id in section_ids:
416 search_domain.append(('project_ids', '=', section_id))
417 search_domain.append(('case_default', '=', True))
418 # AND with the domain in parameter
419 search_domain += list(domain)
420 # perform search, return the first found
421 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
426 def case_cancel(self, cr, uid, ids, context=None):
428 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
429 self.case_cancel_send_note(cr, uid, ids, context=context)
432 def case_escalate(self, cr, uid, ids, context=None):
433 cases = self.browse(cr, uid, ids)
436 if case.project_id.project_escalation_id:
437 data['project_id'] = case.project_id.project_escalation_id.id
438 if case.project_id.project_escalation_id.user_id:
439 data['user_id'] = case.project_id.project_escalation_id.user_id.id
441 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
443 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
444 self.case_set(cr, uid, ids, 'draft', data, context=context)
445 self.case_escalate_send_note(cr, uid, [case.id], context=context)
448 # -------------------------------------------------------
450 # -------------------------------------------------------
452 def message_new(self, cr, uid, msg, custom_values=None, context=None):
453 """ Overrides mail_thread message_new that is called by the mailgateway
454 through message_process.
455 This override updates the document according to the email.
457 if custom_values is None: custom_values = {}
458 if context is None: context = {}
459 context['state_to'] = 'draft'
461 custom_values.update({
462 'name': msg.get('subject') or _("No Subject"),
463 'description': msg.get('body'),
464 'email_from': msg.get('from'),
465 'email_cc': msg.get('cc'),
468 if msg.get('priority'):
469 custom_values['priority'] = msg.get('priority')
471 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
472 # self.convert_to_bug(cr, uid, [res_id], context=context)
475 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
476 """ Overrides mail_thread message_update that is called by the mailgateway
477 through message_process.
478 This method updates the document according to the email.
480 if isinstance(ids, (str, int, long)):
482 if update_vals is None: update_vals = {}
484 # Update doc values according to the message
485 update_vals['description'] = msg.get('body', '')
486 if msg.get('priority'):
487 update_vals['priority'] = msg.get('priority')
488 # Parse 'body' to find values to update
490 'cost': 'planned_cost',
491 'revenue': 'planned_revenue',
492 'probability': 'probability',
494 for line in msg.get('body', '').split('\n'):
496 res = tools.misc.command_re.match(line)
497 if res and maps.get(res.group(1).lower(), False):
498 key = maps.get(res.group(1).lower())
499 update_vals[key] = res.group(2).lower()
501 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
503 # -------------------------------------------------------
504 # OpenChatter methods and notifications
505 # -------------------------------------------------------
507 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
508 """ Override of the (void) default notification method. """
509 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
510 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
512 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
513 """ Override of default prefix for notifications. """
514 return 'Project issue'
516 def convert_to_task_send_note(self, cr, uid, ids, context=None):
517 message = _("Project issue <b>converted</b> to task.")
518 return self.message_post(cr, uid, ids, body=message, context=context)
520 def create_send_note(self, cr, uid, ids, context=None):
521 message = _("Project issue <b>created</b>.")
522 return self.message_post(cr, uid, ids, body=message, context=context)
524 def case_escalate_send_note(self, cr, uid, ids, context=None):
525 for obj in self.browse(cr, uid, ids, context=context):
527 message = _("<b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
528 obj.message_post(body=message)
530 message = _("<b>escalated</b>.")
531 obj.message_post(body=message)
536 class project(osv.osv):
537 _inherit = "project.project"
539 def _get_alias_models(self, cr, uid, context=None):
540 return [('project.task', "Tasks"), ("project.issue", "Issues")]
542 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
543 res = dict.fromkeys(ids, 0)
544 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
545 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
546 res[issue.project_id.id] += 1
550 'project_escalation_id' : fields.many2one('project.project','Project Escalation', help='If any issue is escalated from the current Project, it will be listed under the project selected here.', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
551 'issue_count': fields.function(_issue_count, type='integer'),
554 def _check_escalation(self, cr, uid, ids, context=None):
555 project_obj = self.browse(cr, uid, ids[0], context=context)
556 if project_obj.project_escalation_id:
557 if project_obj.project_escalation_id.id == project_obj.id:
562 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
567 class account_analytic_account(osv.osv):
568 _inherit = 'account.analytic.account'
569 _description = 'Analytic Account'
572 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
575 def on_change_template(self, cr, uid, ids, template_id, context=None):
576 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
577 if template_id and 'value' in res:
578 template = self.browse(cr, uid, template_id, context=context)
579 res['value']['use_issues'] = template.use_issues
582 def _trigger_project_creation(self, cr, uid, vals, context=None):
583 if context is None: context = {}
584 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
585 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
587 account_analytic_account()
589 class project_project(osv.osv):
590 _inherit = 'project.project'
595 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: