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', 'Contact', 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_ids': fields.many2many('project.category', string='Categories'),
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', '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),
272 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
273 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
275 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
276 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
277 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
278 'priority': crm.AVAILABLE_PRIORITIES[2][0],
282 'stage_id': _read_group_stage_ids
285 def set_priority(self, cr, uid, ids, priority, *args):
288 return self.write(cr, uid, ids, {'priority' : priority})
290 def set_high_priority(self, cr, uid, ids, *args):
291 """Set lead priority to high
293 return self.set_priority(cr, uid, ids, '1')
295 def set_normal_priority(self, cr, uid, ids, *args):
296 """Set lead priority to normal
298 return self.set_priority(cr, uid, ids, '3')
300 def convert_issue_task(self, cr, uid, ids, context=None):
304 case_obj = self.pool.get('project.issue')
305 data_obj = self.pool.get('ir.model.data')
306 task_obj = self.pool.get('project.task')
308 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
309 res = data_obj.read(cr, uid, result, ['res_id'])
310 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
311 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
313 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
315 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
317 for bug in case_obj.browse(cr, uid, ids, context=context):
318 new_task_id = task_obj.create(cr, uid, {
320 'partner_id': bug.partner_id.id,
321 'description':bug.description,
322 'date_deadline': bug.date,
323 'project_id': bug.project_id.id,
324 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
325 'priority': str(int(bug.priority) - 1),
326 'user_id': bug.user_id.id,
327 'planned_hours': 0.0,
330 'task_id': new_task_id,
333 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
334 case_obj.write(cr, uid, [bug.id], vals, context=context)
335 self.case_pending_send_note(cr, uid, [bug.id], context=context)
340 'view_mode': 'form,tree',
341 'res_model': 'project.task',
342 'res_id': int(new_task_id),
344 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
345 'type': 'ir.actions.act_window',
346 'search_view_id': res['res_id'],
350 def copy(self, cr, uid, id, default=None, context=None):
351 issue = self.read(cr, uid, id, ['name'], context=context)
354 default = default.copy()
355 default['name'] = issue['name'] + _(' (copy)')
356 return super(project_issue, self).copy(cr, uid, id, default=default,
359 def write(self, cr, uid, ids, vals, context=None):
360 #Update last action date every time the user change the stage, the state or send a new email
361 logged_fields = ['stage_id', 'state', 'message_ids']
362 if any([field in vals for field in logged_fields]):
363 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
364 return super(project_issue, self).write(cr, uid, ids, vals, context)
366 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
370 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
371 return {'value':{'user_id': task.user_id.id,}}
373 def case_reset(self, cr, uid, ids, context=None):
374 """Resets case as draft
376 res = super(project_issue, self).case_reset(cr, uid, ids, context)
377 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
380 def create(self, cr, uid, vals, context=None):
381 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
382 self.create_send_note(cr, uid, [obj_id], context=context)
385 # -------------------------------------------------------
387 # -------------------------------------------------------
389 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
390 """ Override of the base.stage method
391 Parameter of the stage search taken from the issue:
392 - type: stage type must be the same or 'both'
393 - section_id: if set, stages must belong to this section or
396 if isinstance(cases, (int, long)):
397 cases = self.browse(cr, uid, cases, context=context)
398 # collect all section_ids
401 section_ids.append(section_id)
404 section_ids.append(task.project_id.id)
405 # OR all section_ids and OR with case_default
408 search_domain += [('|')] * len(section_ids)
409 for section_id in section_ids:
410 search_domain.append(('project_ids', '=', section_id))
411 search_domain.append(('case_default', '=', True))
412 # AND with the domain in parameter
413 search_domain += list(domain)
414 # perform search, return the first found
415 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
420 def case_cancel(self, cr, uid, ids, context=None):
422 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
423 self.case_cancel_send_note(cr, uid, ids, context=context)
426 def case_escalate(self, cr, uid, ids, context=None):
427 cases = self.browse(cr, uid, ids)
430 if case.project_id.project_escalation_id:
431 data['project_id'] = case.project_id.project_escalation_id.id
432 if case.project_id.project_escalation_id.user_id:
433 data['user_id'] = case.project_id.project_escalation_id.user_id.id
435 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
437 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
438 self.case_set(cr, uid, ids, 'draft', data, context=context)
439 self.case_escalate_send_note(cr, uid, [case.id], context=context)
442 # -------------------------------------------------------
444 # -------------------------------------------------------
446 def message_new(self, cr, uid, msg, custom_values=None, context=None):
447 """ Overrides mail_thread message_new that is called by the mailgateway
448 through message_process.
449 This override updates the document according to the email.
451 if custom_values is None: custom_values = {}
452 if context is None: context = {}
453 context['state_to'] = 'draft'
455 custom_values.update({
456 'name': msg.get('subject') or _("No Subject"),
457 'description': msg.get('body_text'),
458 'email_from': msg.get('from'),
459 'email_cc': msg.get('cc'),
462 if msg.get('priority'):
463 custom_values['priority'] = msg.get('priority')
464 custom_values.update(self.message_partner_by_email(cr, uid, msg.get('from'), context=context))
466 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
467 # self.convert_to_bug(cr, uid, [res_id], context=context)
470 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
471 """ Overrides mail_thread message_update that is called by the mailgateway
472 through message_process.
473 This method updates the document according to the email.
475 if isinstance(ids, (str, int, long)):
477 if update_vals is None: update_vals = {}
479 # Update doc values according to the message
480 update_vals['description'] = msg.get('body_text', '')
481 if msg.get('priority'):
482 update_vals['priority'] = msg.get('priority')
483 # Parse 'body_text' to find values to update
485 'cost': 'planned_cost',
486 'revenue': 'planned_revenue',
487 'probability': 'probability',
489 for line in msg.get('body_text', '').split('\n'):
491 res = tools.misc.command_re.match(line)
492 if res and maps.get(res.group(1).lower(), False):
493 key = maps.get(res.group(1).lower())
494 update_vals[key] = res.group(2).lower()
496 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
498 # -------------------------------------------------------
499 # OpenChatter methods and notifications
500 # -------------------------------------------------------
502 def message_get_monitored_follower_fields(self, cr, uid, ids, context=None):
503 """ Add 'user_id' to the monitored fields """
504 res = super(project_issue, self).message_get_monitored_follower_fields(cr, uid, ids, context=context)
505 return res + ['user_id']
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_append_note(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 has been <b>converted</b> into task.")
518 return self.message_append_note(cr, uid, ids, body=message, context=context)
520 def create_send_note(self, cr, uid, ids, context=None):
521 message = _("Project issue has been <b>created</b>.")
522 return self.message_append_note(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 = _("has been <b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
528 obj.message_append_note(body=message, context=context)
530 message = _("has been <b>escalated</b>.")
531 obj.message_append_note(body=message, context=context)
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: