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 _
30 from crm import wizard
32 wizard.mail_compose_message.SUPPORTED_MODELS.append('project.issue')
34 class project_issue_version(osv.osv):
35 _name = "project.issue.version"
38 'name': fields.char('Version Number', size=32, required=True),
39 'active': fields.boolean('Active', required=False),
44 project_issue_version()
46 _ISSUE_STATE= [('draft', 'New'), ('open', 'In Progress'), ('cancel', 'Cancelled'), ('done', 'Done'),('pending', 'Pending')]
48 class project_issue(base_stage, osv.osv):
49 _name = "project.issue"
50 _description = "Project Issue"
51 _order = "priority, create_date desc"
52 _inherit = ['ir.needaction_mixin', 'mail.thread']
54 def _get_default_project_id(self, cr, uid, context=None):
55 """ Gives default project by checking if present in the context """
56 return self._resolve_project_id_from_context(cr, uid, context=context)
58 def _get_default_stage_id(self, cr, uid, context=None):
59 """ Gives default stage_id """
60 project_id = self._get_default_project_id(cr, uid, context=context)
61 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
63 def _resolve_project_id_from_context(self, cr, uid, context=None):
64 """ Returns ID of project based on the value of 'default_project_id'
65 context key, or None if it cannot be resolved to a single
70 if type(context.get('default_project_id')) in (int, long):
71 return context.get('default_project_id')
72 if isinstance(context.get('default_project_id'), basestring):
73 project_name = context['default_project_id']
74 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
75 if len(project_ids) == 1:
76 return int(project_ids[0][0])
79 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
80 access_rights_uid = access_rights_uid or uid
81 stage_obj = self.pool.get('project.task.type')
82 order = stage_obj._order
83 # lame hack to allow reverting search, should just work in the trivial case
84 if read_group_order == 'stage_id desc':
85 order = "%s desc" % order
86 # retrieve section_id from the context and write the domain
87 # - ('id', 'in', 'ids'): add columns that should be present
88 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
89 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
91 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
93 search_domain += ['|', '&', ('project_ids', '=', project_id), ('fold', '=', False)]
94 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
96 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
97 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
98 # restore order of the search
99 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
102 def _compute_day(self, cr, uid, ids, fields, args, context=None):
104 @param cr: the current row, from the database cursor,
105 @param uid: the current user’s ID for security checks,
106 @param ids: List of Openday’s IDs
107 @return: difference between current date and log date
108 @param context: A standard dictionary for contextual values
110 cal_obj = self.pool.get('resource.calendar')
111 res_obj = self.pool.get('resource.resource')
114 for issue in self.browse(cr, uid, ids, context=context):
121 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
122 if field in ['working_hours_open','day_open']:
124 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
125 ans = date_open - date_create
126 date_until = issue.date_open
127 #Calculating no. of working hours to open the issue
128 if issue.project_id.resource_calendar_id:
129 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
132 elif field in ['working_hours_close','day_close']:
133 if issue.date_closed:
134 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
135 date_until = issue.date_closed
136 ans = date_close - date_create
137 #Calculating no. of working hours to close the issue
138 if issue.project_id.resource_calendar_id:
139 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
142 elif field in ['days_since_creation']:
143 if issue.create_date:
144 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
145 res[issue.id][field] = days_since_creation.days
148 elif field in ['inactivity_days']:
149 res[issue.id][field] = 0
150 if issue.date_action_last:
151 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
152 res[issue.id][field] = inactive_days.days
157 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
158 if resource_ids and len(resource_ids):
159 resource_id = resource_ids[0]
160 duration = float(ans.days)
161 if issue.project_id and issue.project_id.resource_calendar_id:
162 duration = float(ans.days) * 24
164 new_dates = cal_obj.interval_min_get(cr, uid,
165 issue.project_id.resource_calendar_id.id,
167 duration, resource=resource_id)
169 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
170 for in_time, out_time in new_dates:
171 if in_time.date not in no_days:
172 no_days.append(in_time.date)
173 if out_time > date_until:
175 duration = len(no_days)
177 if field in ['working_hours_open','working_hours_close']:
178 res[issue.id][field] = hours
180 res[issue.id][field] = abs(float(duration))
184 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
185 task_pool = self.pool.get('project.task')
187 for issue in self.browse(cr, uid, ids, context=context):
190 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
191 res[issue.id] = {'progress' : progress}
194 def on_change_project(self, cr, uid, ids, project_id, context=None):
197 def _get_issue_task(self, cr, uid, ids, context=None):
199 issue_pool = self.pool.get('project.issue')
200 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
201 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
204 def _get_issue_work(self, cr, uid, ids, context=None):
206 issue_pool = self.pool.get('project.issue')
207 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
209 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
213 'id': fields.integer('ID', readonly=True),
214 'name': fields.char('Issue', size=128, required=True),
215 'active': fields.boolean('Active', required=False),
216 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
217 'write_date': fields.datetime('Update Date', readonly=True),
218 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
219 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
220 'date_deadline': fields.date('Deadline'),
221 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
222 select=True, help='Sales team to which Case belongs to.\
223 Define Responsible user and Email account for mail gateway.'),
224 'partner_id': fields.many2one('res.partner', 'Partner', select=1),
225 'company_id': fields.many2one('res.company', 'Company'),
226 'description': fields.text('Description'),
227 'state': fields.related('stage_id', 'state', type="selection", store=True,
228 selection=_ISSUE_STATE, string="State", readonly=True,
229 help='The state is set to \'Draft\', when a case is created.\
230 If the case is in progress the state is set to \'Open\'.\
231 When the case is over, the state is set to \'Done\'.\
232 If the case needs to be reviewed then the state is \
233 set to \'Pending\'.'),
234 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
235 '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"),
236 'date_open': fields.datetime('Opened', readonly=True,select=True),
237 # Project Issue fields
238 'date_closed': fields.datetime('Closed', readonly=True,select=True),
239 'date': fields.datetime('Date'),
240 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
241 'categ_id': fields.many2one('crm.case.categ', 'Category', domain="[('object_id.model', '=', 'crm.project.bug')]"),
242 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
243 'version_id': fields.many2one('project.issue.version', 'Version'),
244 'stage_id': fields.many2one ('project.task.type', 'Stages',
245 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
246 'project_id':fields.many2one('project.project', 'Project'),
247 'duration': fields.float('Duration'),
248 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
249 'day_open': fields.function(_compute_day, string='Days to Open', \
250 multi='compute_day', type="float", store=True),
251 'day_close': fields.function(_compute_day, string='Days to Close', \
252 multi='compute_day', type="float", store=True),
253 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1),
254 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
255 multi='compute_day', type="float", store=True),
256 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
257 multi='compute_day', type="float", store=True),
258 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
259 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
260 'color': fields.integer('Color Index'),
261 'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
262 'date_action_last': fields.datetime('Last Action', readonly=1),
263 'date_action_next': fields.datetime('Next Action', readonly=1),
264 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
266 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
267 'project.task': (_get_issue_task, ['progress'], 10),
268 'project.task.work': (_get_issue_work, ['hours'], 10),
272 def on_change_project(self, cr, uid, ids, project_id, context=None):
277 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
278 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
280 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
281 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
282 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
283 'priority': crm.AVAILABLE_PRIORITIES[2][0],
284 'categ_id' : lambda *a: False,
288 'stage_id': _read_group_stage_ids
291 def set_priority(self, cr, uid, ids, priority):
294 return self.write(cr, uid, ids, {'priority' : priority})
296 def set_high_priority(self, cr, uid, ids, *args):
297 """Set lead priority to high
299 return self.set_priority(cr, uid, ids, '1')
301 def set_normal_priority(self, cr, uid, ids, *args):
302 """Set lead priority to normal
304 return self.set_priority(cr, uid, ids, '3')
306 def convert_issue_task(self, cr, uid, ids, context=None):
310 case_obj = self.pool.get('project.issue')
311 data_obj = self.pool.get('ir.model.data')
312 task_obj = self.pool.get('project.task')
314 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
315 res = data_obj.read(cr, uid, result, ['res_id'])
316 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
317 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
319 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
321 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
323 for bug in case_obj.browse(cr, uid, ids, context=context):
324 new_task_id = task_obj.create(cr, uid, {
326 'partner_id': bug.partner_id.id,
327 'description':bug.description,
328 'date_deadline': bug.date,
329 'project_id': bug.project_id.id,
330 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
331 'priority': str(int(bug.priority) - 1),
332 'user_id': bug.user_id.id,
333 'planned_hours': 0.0,
336 'task_id': new_task_id,
339 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
340 case_obj.write(cr, uid, [bug.id], vals, context=context)
341 self.case_pending_send_note(cr, uid, [bug.id], context=context)
346 'view_mode': 'form,tree',
347 'res_model': 'project.task',
348 'res_id': int(new_task_id),
350 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
351 'type': 'ir.actions.act_window',
352 'search_view_id': res['res_id'],
357 def _convert(self, cr, uid, ids, xml_id, context=None):
358 data_obj = self.pool.get('ir.model.data')
359 id2 = data_obj._get_id(cr, uid, 'project_issue', xml_id)
362 categ_id = data_obj.browse(cr, uid, id2, context=context).res_id
364 self.write(cr, uid, ids, {'categ_id': categ_id})
367 def convert_to_feature(self, cr, uid, ids, context=None):
368 return self._convert(cr, uid, ids, 'feature_request_categ', context=context)
370 def convert_to_bug(self, cr, uid, ids, context=None):
371 return self._convert(cr, uid, ids, 'bug_categ', context=context)
373 def copy(self, cr, uid, id, default=None, context=None):
374 issue = self.read(cr, uid, id, ['name'], context=context)
377 default = default.copy()
378 default['name'] = issue['name'] + _(' (copy)')
379 return super(project_issue, self).copy(cr, uid, id, default=default,
382 def write(self, cr, uid, ids, vals, context=None):
383 #Update last action date every time the user change the stage, the state or send a new email
384 logged_fields = ['stage_id', 'state', 'message_ids']
385 if any([field in vals for field in logged_fields]):
386 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
387 return super(project_issue, self).write(cr, uid, ids, vals, context)
389 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
393 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
394 return {'value':{'user_id': task.user_id.id,}}
396 def case_reset(self, cr, uid, ids, context=None):
397 """Resets case as draft
399 res = super(project_issue, self).case_reset(cr, uid, ids, context)
400 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
403 def create(self, cr, uid, vals, context=None):
404 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
405 self.create_send_note(cr, uid, [obj_id], context=context)
408 # -------------------------------------------------------
410 # -------------------------------------------------------
412 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
413 """ Override of the base.stage method
414 Parameter of the stage search taken from the issue:
415 - type: stage type must be the same or 'both'
416 - section_id: if set, stages must belong to this section or
419 if isinstance(cases, (int, long)):
420 cases = self.browse(cr, uid, cases, context=context)
421 # collect all section_ids
424 section_ids.append(section_id)
427 section_ids.append(task.project_id.id)
428 # OR all section_ids and OR with case_default
431 search_domain += [('|')] * len(section_ids)
432 for section_id in section_ids:
433 search_domain.append(('project_ids', '=', section_id))
434 search_domain.append(('case_default', '=', True))
435 # AND with the domain in parameter
436 search_domain += list(domain)
437 # perform search, return the first found
438 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
443 def case_cancel(self, cr, uid, ids, context=None):
445 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
446 self.case_cancel_send_note(cr, uid, ids, context=context)
449 def case_escalate(self, cr, uid, ids, context=None):
450 cases = self.browse(cr, uid, ids)
453 if case.project_id.project_escalation_id:
454 data['project_id'] = case.project_id.project_escalation_id.id
455 if case.project_id.project_escalation_id.user_id:
456 data['user_id'] = case.project_id.project_escalation_id.user_id.id
458 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
460 raise osv.except_osv(_('Warning !'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
461 self.case_set(cr, uid, ids, 'draft', data, context=context)
462 self.case_escalate_send_note(cr, uid, [case.id], context=context)
465 # -------------------------------------------------------
467 # -------------------------------------------------------
469 def message_new(self, cr, uid, msg, custom_values=None, context=None):
470 """Automatically called when new email message arrives"""
473 subject = msg.get('subject') or _('No Title')
474 body = msg.get('body_text')
475 msg_from = msg.get('from')
476 priority = msg.get('priority')
479 'email_from': msg_from,
480 'email_cc': msg.get('cc'),
485 vals['priority'] = priority
486 vals.update(self.message_partner_by_email(cr, uid, msg_from))
487 context.update({'state_to' : 'draft'})
489 if custom_values and isinstance(custom_values, dict):
490 vals.update(custom_values)
492 res_id = self.create(cr, uid, vals, context)
493 self.message_append_dict(cr, uid, [res_id], msg, context=context)
494 self.convert_to_bug(cr, uid, [res_id], context=context)
497 def message_update(self, cr, uid, ids, msg, vals=None, default_act='pending', context=None):
501 if isinstance(ids, (str, int, long)):
505 'description': msg['body_text']
507 if msg.get('priority', False):
508 vals['priority'] = msg.get('priority')
511 'cost': 'planned_cost',
512 'revenue': 'planned_revenue',
513 'probability': 'probability'
516 # Reassign the 'open' state to the case if this one is in pending or done
517 for record in self.browse(cr, uid, ids, context=context):
518 if record.state in ('pending', 'done'):
519 self.case_set(cr, uid, ids, 'open', {}, context=context)
522 for line in msg['body_text'].split('\n'):
524 res = tools.misc.command_re.match(line)
525 if res and maps.get(res.group(1).lower(), False):
526 key = maps.get(res.group(1).lower())
527 vls[key] = res.group(2).lower()
530 res = self.write(cr, uid, ids, vals)
531 self.message_append_dict(cr, uid, ids, msg, context=context)
534 # -------------------------------------------------------
535 # OpenChatter methods and notifications
536 # -------------------------------------------------------
538 def get_needaction_user_ids(self, cr, uid, ids, context=None):
539 result = dict.fromkeys(ids, [])
540 for obj in self.browse(cr, uid, ids, context=context):
541 if obj.state == 'draft' and obj.user_id:
542 result[obj.id] = [obj.user_id.id]
545 def message_get_subscribers(self, cr, uid, ids, context=None):
546 sub_ids = self.message_get_subscribers_ids(cr, uid, ids, context=context);
547 for obj in self.browse(cr, uid, ids, context=context):
549 sub_ids.append(obj.user_id.id)
550 return self.pool.get('res.users').read(cr, uid, sub_ids, context=context)
552 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
553 """ Override of the (void) default notification method. """
554 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
555 return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
557 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
558 """ Override of default prefix for notifications. """
559 return 'Project issue'
561 def convert_to_task_send_note(self, cr, uid, ids, context=None):
562 message = _("Project issue has been <b>converted</b> in to task.")
563 return self.message_append_note(cr, uid, ids, body=message, context=context)
565 def create_send_note(self, cr, uid, ids, context=None):
566 message = _("Project issue has been <b>created</b>.")
567 return self.message_append_note(cr, uid, ids, body=message, context=context)
569 def case_escalate_send_note(self, cr, uid, ids, context=None):
570 for obj in self.browse(cr, uid, ids, context=context):
572 message = _("has been <b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
573 obj.message_append_note(body=message, context=context)
575 message = _("has been <b>escalated</b>.")
576 obj.message_append_note(body=message, context=context)
581 class project(osv.osv):
582 _inherit = "project.project"
584 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
585 res = dict.fromkeys(ids, 0)
586 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
587 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
588 res[issue.project_id.id] += 1
592 '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)]}),
593 'reply_to' : fields.char('Reply-To Email Address', size=256),
594 'use_issues' : fields.boolean('Use Issues', help="Check this field if this project manages issues"),
595 'issue_count': fields.function(_issue_count, type='integer'),
602 def _check_escalation(self, cr, uid, ids, context=None):
603 project_obj = self.browse(cr, uid, ids[0], context=context)
604 if project_obj.project_escalation_id:
605 if project_obj.project_escalation_id.id == project_obj.id:
610 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
614 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: