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 = ['ir.needaction_mixin', 'mail.thread']
50 _mail_compose_message = True
52 def _get_default_project_id(self, cr, uid, context=None):
53 """ Gives default project by checking if present in the context """
54 return self._resolve_project_id_from_context(cr, uid, context=context)
56 def _get_default_stage_id(self, cr, uid, context=None):
57 """ Gives default stage_id """
58 project_id = self._get_default_project_id(cr, uid, context=context)
59 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
61 def _resolve_project_id_from_context(self, cr, uid, context=None):
62 """ Returns ID of project based on the value of 'default_project_id'
63 context key, or None if it cannot be resolved to a single
68 if type(context.get('default_project_id')) in (int, long):
69 return context.get('default_project_id')
70 if isinstance(context.get('default_project_id'), basestring):
71 project_name = context['default_project_id']
72 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
73 if len(project_ids) == 1:
74 return int(project_ids[0][0])
77 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
78 access_rights_uid = access_rights_uid or uid
79 stage_obj = self.pool.get('project.task.type')
80 order = stage_obj._order
81 # lame hack to allow reverting search, should just work in the trivial case
82 if read_group_order == 'stage_id desc':
83 order = "%s desc" % order
84 # retrieve section_id from the context and write the domain
85 # - ('id', 'in', 'ids'): add columns that should be present
86 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
87 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
89 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
91 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
92 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
94 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
95 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
96 # restore order of the search
97 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
100 def _compute_day(self, cr, uid, ids, fields, args, context=None):
102 @param cr: the current row, from the database cursor,
103 @param uid: the current user’s ID for security checks,
104 @param ids: List of Openday’s IDs
105 @return: difference between current date and log date
106 @param context: A standard dictionary for contextual values
108 cal_obj = self.pool.get('resource.calendar')
109 res_obj = self.pool.get('resource.resource')
112 for issue in self.browse(cr, uid, ids, context=context):
119 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
120 if field in ['working_hours_open','day_open']:
122 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
123 ans = date_open - date_create
124 date_until = issue.date_open
125 #Calculating no. of working hours to open the issue
126 if issue.project_id.resource_calendar_id:
127 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
130 elif field in ['working_hours_close','day_close']:
131 if issue.date_closed:
132 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
133 date_until = issue.date_closed
134 ans = date_close - date_create
135 #Calculating no. of working hours to close the issue
136 if issue.project_id.resource_calendar_id:
137 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
140 elif field in ['days_since_creation']:
141 if issue.create_date:
142 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
143 res[issue.id][field] = days_since_creation.days
146 elif field in ['inactivity_days']:
147 res[issue.id][field] = 0
148 if issue.date_action_last:
149 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
150 res[issue.id][field] = inactive_days.days
155 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
156 if resource_ids and len(resource_ids):
157 resource_id = resource_ids[0]
158 duration = float(ans.days)
159 if issue.project_id and issue.project_id.resource_calendar_id:
160 duration = float(ans.days) * 24
162 new_dates = cal_obj.interval_min_get(cr, uid,
163 issue.project_id.resource_calendar_id.id,
165 duration, resource=resource_id)
167 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
168 for in_time, out_time in new_dates:
169 if in_time.date not in no_days:
170 no_days.append(in_time.date)
171 if out_time > date_until:
173 duration = len(no_days)
175 if field in ['working_hours_open','working_hours_close']:
176 res[issue.id][field] = hours
178 res[issue.id][field] = abs(float(duration))
182 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
183 task_pool = self.pool.get('project.task')
185 for issue in self.browse(cr, uid, ids, context=context):
188 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
189 res[issue.id] = {'progress' : progress}
192 def on_change_project(self, cr, uid, ids, project_id, context=None):
195 def _get_issue_task(self, cr, uid, ids, context=None):
197 issue_pool = self.pool.get('project.issue')
198 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
199 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
202 def _get_issue_work(self, cr, uid, ids, context=None):
204 issue_pool = self.pool.get('project.issue')
205 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
207 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
211 'id': fields.integer('ID', readonly=True),
212 'name': fields.char('Issue', size=128, required=True),
213 'active': fields.boolean('Active', required=False),
214 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
215 'write_date': fields.datetime('Update Date', readonly=True),
216 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
217 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
218 'date_deadline': fields.date('Deadline'),
219 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
220 select=True, help='Sales team to which Case belongs to.\
221 Define Responsible user and Email account for mail gateway.'),
222 'partner_id': fields.many2one('res.partner', 'Partner', select=1),
223 'company_id': fields.many2one('res.company', 'Company'),
224 'description': fields.text('Description'),
225 'state': fields.related('stage_id', 'state', type="selection", store=True,
226 selection=_ISSUE_STATE, string="State", readonly=True,
227 help='The state is set to \'Draft\', when a case is created.\
228 If the case is in progress the state is set to \'Open\'.\
229 When the case is over, the state is set to \'Done\'.\
230 If the case needs to be reviewed then the state is \
231 set to \'Pending\'.'),
232 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
233 '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"),
234 'date_open': fields.datetime('Opened', readonly=True,select=True),
235 # Project Issue fields
236 'date_closed': fields.datetime('Closed', readonly=True,select=True),
237 'date': fields.datetime('Date'),
238 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
239 'categ_id': fields.many2one('crm.case.categ', 'Category', domain="[('object_id.model', '=', 'crm.project.bug')]"),
240 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
241 'version_id': fields.many2one('project.issue.version', 'Version'),
242 'stage_id': fields.many2one ('project.task.type', 'Stage',
243 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
244 'project_id':fields.many2one('project.project', 'Project'),
245 'duration': fields.float('Duration'),
246 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
247 'day_open': fields.function(_compute_day, string='Days to Open', \
248 multi='compute_day', type="float", store=True),
249 'day_close': fields.function(_compute_day, string='Days to Close', \
250 multi='compute_day', type="float", store=True),
251 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1),
252 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
253 multi='compute_day', type="float", store=True),
254 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
255 multi='compute_day', type="float", store=True),
256 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
257 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
258 'color': fields.integer('Color Index'),
259 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
260 'date_action_last': fields.datetime('Last Action', readonly=1),
261 'date_action_next': fields.datetime('Next Action', readonly=1),
262 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
264 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
265 'project.task': (_get_issue_task, ['progress'], 10),
266 'project.task.work': (_get_issue_work, ['hours'], 10),
270 def on_change_project(self, cr, uid, ids, project_id, context=None):
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],
282 'categ_id' : lambda *a: False,
286 'stage_id': _read_group_stage_ids
289 def set_priority(self, cr, uid, ids, priority, *args):
292 return self.write(cr, uid, ids, {'priority' : priority})
294 def set_high_priority(self, cr, uid, ids, *args):
295 """Set lead priority to high
297 return self.set_priority(cr, uid, ids, '1')
299 def set_normal_priority(self, cr, uid, ids, *args):
300 """Set lead priority to normal
302 return self.set_priority(cr, uid, ids, '3')
304 def convert_issue_task(self, cr, uid, ids, context=None):
308 case_obj = self.pool.get('project.issue')
309 data_obj = self.pool.get('ir.model.data')
310 task_obj = self.pool.get('project.task')
312 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
313 res = data_obj.read(cr, uid, result, ['res_id'])
314 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
315 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
317 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
319 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
321 for bug in case_obj.browse(cr, uid, ids, context=context):
322 new_task_id = task_obj.create(cr, uid, {
324 'partner_id': bug.partner_id.id,
325 'description':bug.description,
326 'date_deadline': bug.date,
327 'project_id': bug.project_id.id,
328 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
329 'priority': str(int(bug.priority) - 1),
330 'user_id': bug.user_id.id,
331 'planned_hours': 0.0,
334 'task_id': new_task_id,
337 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
338 case_obj.write(cr, uid, [bug.id], vals, context=context)
339 self.case_pending_send_note(cr, uid, [bug.id], context=context)
344 'view_mode': 'form,tree',
345 'res_model': 'project.task',
346 'res_id': int(new_task_id),
348 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
349 'type': 'ir.actions.act_window',
350 'search_view_id': res['res_id'],
355 def _convert(self, cr, uid, ids, xml_id, context=None):
356 data_obj = self.pool.get('ir.model.data')
357 id2 = data_obj._get_id(cr, uid, 'project_issue', xml_id)
360 categ_id = data_obj.browse(cr, uid, id2, context=context).res_id
362 self.write(cr, uid, ids, {'categ_id': categ_id})
365 def convert_to_feature(self, cr, uid, ids, context=None):
366 return self._convert(cr, uid, ids, 'feature_request_categ', context=context)
368 def convert_to_bug(self, cr, uid, ids, context=None):
369 return self._convert(cr, uid, ids, 'bug_categ', context=context)
371 def copy(self, cr, uid, id, default=None, context=None):
372 issue = self.read(cr, uid, id, ['name'], context=context)
375 default = default.copy()
376 default['name'] = issue['name'] + _(' (copy)')
377 return super(project_issue, self).copy(cr, uid, id, default=default,
380 def write(self, cr, uid, ids, vals, context=None):
381 #Update last action date every time the user change the stage, the state or send a new email
382 logged_fields = ['stage_id', 'state', 'message_ids']
383 if any([field in vals for field in logged_fields]):
384 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
385 return super(project_issue, self).write(cr, uid, ids, vals, context)
387 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
391 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
392 return {'value':{'user_id': task.user_id.id,}}
394 def case_reset(self, cr, uid, ids, context=None):
395 """Resets case as draft
397 res = super(project_issue, self).case_reset(cr, uid, ids, context)
398 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
401 def create(self, cr, uid, vals, context=None):
402 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
403 self.create_send_note(cr, uid, [obj_id], context=context)
406 # -------------------------------------------------------
408 # -------------------------------------------------------
410 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
411 """ Override of the base.stage method
412 Parameter of the stage search taken from the issue:
413 - type: stage type must be the same or 'both'
414 - section_id: if set, stages must belong to this section or
417 if isinstance(cases, (int, long)):
418 cases = self.browse(cr, uid, cases, context=context)
419 # collect all section_ids
422 section_ids.append(section_id)
425 section_ids.append(task.project_id.id)
426 # OR all section_ids and OR with case_default
429 search_domain += [('|')] * len(section_ids)
430 for section_id in section_ids:
431 search_domain.append(('project_ids', '=', section_id))
432 search_domain.append(('case_default', '=', True))
433 # AND with the domain in parameter
434 search_domain += list(domain)
435 # perform search, return the first found
436 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
441 def case_cancel(self, cr, uid, ids, context=None):
443 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
444 self.case_cancel_send_note(cr, uid, ids, context=context)
447 def case_escalate(self, cr, uid, ids, context=None):
448 cases = self.browse(cr, uid, ids)
451 if case.project_id.project_escalation_id:
452 data['project_id'] = case.project_id.project_escalation_id.id
453 if case.project_id.project_escalation_id.user_id:
454 data['user_id'] = case.project_id.project_escalation_id.user_id.id
456 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
458 raise osv.except_osv(_('Warning !'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
459 self.case_set(cr, uid, ids, 'draft', data, context=context)
460 self.case_escalate_send_note(cr, uid, [case.id], context=context)
463 # -------------------------------------------------------
465 # -------------------------------------------------------
467 def message_new(self, cr, uid, msg, custom_values=None, context=None):
468 """ Overrides mail_thread message_new that is called by the mailgateway
469 through message_process.
470 This override updates the document according to the email.
472 if custom_values is None: custom_values = {}
473 if context is None: context = {}
474 context['state_to'] = 'draft'
476 custom_values.update({
477 'name': msg.get('subject') or _("No Subject"),
478 'description': msg.get('body_text'),
479 'email_from': msg.get('from'),
480 'email_cc': msg.get('cc'),
483 if msg.get('priority'):
484 custom_values['priority'] = msg.get('priority')
485 custom_values.update(self.message_partner_by_email(cr, uid, msg.get('from'), context=context))
487 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
488 self.convert_to_bug(cr, uid, [res_id], context=context)
491 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
492 """ Overrides mail_thread message_update that is called by the mailgateway
493 through message_process.
494 This method updates the document according to the email.
496 if isinstance(ids, (str, int, long)):
498 if update_vals is None: update_vals = {}
500 # Update doc values according to the message
501 update_vals['description'] = msg.get('body_text', '')
502 if msg.get('priority'):
503 update_vals['priority'] = msg.get('priority')
504 # Parse 'body_text' to find values to update
506 'cost': 'planned_cost',
507 'revenue': 'planned_revenue',
508 'probability': 'probability',
510 for line in msg.get('body_text', '').split('\n'):
512 res = tools.misc.command_re.match(line)
513 if res and maps.get(res.group(1).lower(), False):
514 key = maps.get(res.group(1).lower())
515 update_vals[key] = res.group(2).lower()
517 return super(project_issue, self).message_update(cr, uid, ids, update_vals=update_vals, context=context)
519 # -------------------------------------------------------
520 # OpenChatter methods and notifications
521 # -------------------------------------------------------
523 def message_get_subscribers(self, cr, uid, ids, context=None):
524 """ Override to add responsible user. """
525 user_ids = super(project_issue, self).message_get_subscribers(cr, uid, ids, context=context)
526 for obj in self.browse(cr, uid, ids, context=context):
527 if obj.user_id and not obj.user_id.id in user_ids:
528 user_ids.append(obj.user_id.id)
531 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
532 """ Override of the (void) default notification method. """
533 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
534 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
536 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
537 """ Override of default prefix for notifications. """
538 return 'Project issue'
540 def convert_to_task_send_note(self, cr, uid, ids, context=None):
541 message = _("Project issue has been <b>converted</b> in to task.")
542 return self.message_append_note(cr, uid, ids, body=message, context=context)
544 def create_send_note(self, cr, uid, ids, context=None):
545 message = _("Project issue has been <b>created</b>.")
546 return self.message_append_note(cr, uid, ids, body=message, context=context)
548 def case_escalate_send_note(self, cr, uid, ids, context=None):
549 for obj in self.browse(cr, uid, ids, context=context):
551 message = _("has been <b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
552 obj.message_append_note(body=message, context=context)
554 message = _("has been <b>escalated</b>.")
555 obj.message_append_note(body=message, context=context)
560 class project(osv.osv):
561 _inherit = "project.project"
563 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
564 res = dict.fromkeys(ids, 0)
565 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
566 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
567 res[issue.project_id.id] += 1
571 '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)]}),
572 'reply_to' : fields.char('Reply-To Email Address', size=256),
573 'issue_count': fields.function(_issue_count, type='integer'),
576 def _check_escalation(self, cr, uid, ids, context=None):
577 project_obj = self.browse(cr, uid, ids[0], context=context)
578 if project_obj.project_escalation_id:
579 if project_obj.project_escalation_id.id == project_obj.id:
584 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
588 class account_analytic_account(osv.osv):
589 _inherit = 'account.analytic.account'
590 _description = 'Analytic Account'
593 'use_issues' : fields.boolean('Issues Tracking', help="Check this field if this project manages issues"),
596 def _trigger_project_creation(self, cr, uid, vals, context=None):
597 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
598 return res or vals.get('use_issues')
600 account_analytic_account()
602 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: