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), ('fold', '=', False)]
91 search_domain += ['|', ('id', 'in', ids), '&', ('case_default', '=', True), ('fold', '=', False)]
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 def _compute_day(self, cr, uid, ids, fields, args, context=None):
101 @param cr: the current row, from the database cursor,
102 @param uid: the current user’s ID for security checks,
103 @param ids: List of Openday’s IDs
104 @return: difference between current date and log date
105 @param context: A standard dictionary for contextual values
107 cal_obj = self.pool.get('resource.calendar')
108 res_obj = self.pool.get('resource.resource')
111 for issue in self.browse(cr, uid, ids, context=context):
118 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
119 if field in ['working_hours_open','day_open']:
121 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
122 ans = date_open - date_create
123 date_until = issue.date_open
124 #Calculating no. of working hours to open the issue
125 if issue.project_id.resource_calendar_id:
126 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
129 elif field in ['working_hours_close','day_close']:
130 if issue.date_closed:
131 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
132 date_until = issue.date_closed
133 ans = date_close - date_create
134 #Calculating no. of working hours to close the issue
135 if issue.project_id.resource_calendar_id:
136 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
139 elif field in ['days_since_creation']:
140 if issue.create_date:
141 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
142 res[issue.id][field] = days_since_creation.days
145 elif field in ['inactivity_days']:
146 res[issue.id][field] = 0
147 if issue.date_action_last:
148 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
149 res[issue.id][field] = inactive_days.days
154 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
155 if resource_ids and len(resource_ids):
156 resource_id = resource_ids[0]
157 duration = float(ans.days)
158 if issue.project_id and issue.project_id.resource_calendar_id:
159 duration = float(ans.days) * 24
161 new_dates = cal_obj.interval_min_get(cr, uid,
162 issue.project_id.resource_calendar_id.id,
164 duration, resource=resource_id)
166 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
167 for in_time, out_time in new_dates:
168 if in_time.date not in no_days:
169 no_days.append(in_time.date)
170 if out_time > date_until:
172 duration = len(no_days)
174 if field in ['working_hours_open','working_hours_close']:
175 res[issue.id][field] = hours
177 res[issue.id][field] = abs(float(duration))
181 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
182 task_pool = self.pool.get('project.task')
184 for issue in self.browse(cr, uid, ids, context=context):
187 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
188 res[issue.id] = {'progress' : progress}
191 def on_change_project(self, cr, uid, ids, project_id, context=None):
194 def _get_issue_task(self, cr, uid, ids, context=None):
196 issue_pool = self.pool.get('project.issue')
197 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
198 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
201 def _get_issue_work(self, cr, uid, ids, context=None):
203 issue_pool = self.pool.get('project.issue')
204 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
206 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
210 'id': fields.integer('ID', readonly=True),
211 'name': fields.char('Issue', size=128, required=True),
212 'active': fields.boolean('Active', required=False),
213 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
214 'write_date': fields.datetime('Update Date', readonly=True),
215 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
216 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
217 'date_deadline': fields.date('Deadline'),
218 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
219 select=True, help='Sales team to which Case belongs to.\
220 Define Responsible user and Email account for mail gateway.'),
221 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
222 'company_id': fields.many2one('res.company', 'Company'),
223 'description': fields.text('Description'),
224 'state': fields.related('stage_id', 'state', type="selection", store=True,
225 selection=_ISSUE_STATE, string="State", readonly=True,
226 help='The state is set to \'Draft\', when a case is created.\
227 If the case is in progress the state is set to \'Open\'.\
228 When the case is over, the state is set to \'Done\'.\
229 If the case needs to be reviewed then the state is \
230 set to \'Pending\'.'),
231 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
232 '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"),
233 'date_open': fields.datetime('Opened', readonly=True,select=True),
234 # Project Issue fields
235 'date_closed': fields.datetime('Closed', readonly=True,select=True),
236 'date': fields.datetime('Date'),
237 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
238 'categ_ids': fields.many2many('project.category', string='Categories'),
239 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
240 'version_id': fields.many2one('project.issue.version', 'Version'),
241 'stage_id': fields.many2one ('project.task.type', 'Stage',
242 domain="['|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
243 'project_id':fields.many2one('project.project', 'Project'),
244 'duration': fields.float('Duration'),
245 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
246 'day_open': fields.function(_compute_day, string='Days to Open', \
247 multi='compute_day', type="float", store=True),
248 'day_close': fields.function(_compute_day, string='Days to Close', \
249 multi='compute_day', type="float", store=True),
250 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1),
251 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
252 multi='compute_day', type="float", store=True),
253 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
254 multi='compute_day', type="float", store=True),
255 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
256 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
257 'color': fields.integer('Color Index'),
258 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
259 'date_action_last': fields.datetime('Last Action', readonly=1),
260 'date_action_next': fields.datetime('Next Action', readonly=1),
261 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
263 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
264 'project.task': (_get_issue_task, ['progress'], 10),
265 'project.task.work': (_get_issue_work, ['hours'], 10),
271 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
272 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
274 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
275 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
276 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
277 'priority': crm.AVAILABLE_PRIORITIES[2][0],
281 'stage_id': _read_group_stage_ids
284 def set_priority(self, cr, uid, ids, priority, *args):
287 return self.write(cr, uid, ids, {'priority' : priority})
289 def set_high_priority(self, cr, uid, ids, *args):
290 """Set lead priority to high
292 return self.set_priority(cr, uid, ids, '1')
294 def set_normal_priority(self, cr, uid, ids, *args):
295 """Set lead priority to normal
297 return self.set_priority(cr, uid, ids, '3')
299 def convert_issue_task(self, cr, uid, ids, context=None):
303 case_obj = self.pool.get('project.issue')
304 data_obj = self.pool.get('ir.model.data')
305 task_obj = self.pool.get('project.task')
307 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
308 res = data_obj.read(cr, uid, result, ['res_id'])
309 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
310 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
312 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
314 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
316 for bug in case_obj.browse(cr, uid, ids, context=context):
317 new_task_id = task_obj.create(cr, uid, {
319 'partner_id': bug.partner_id.id,
320 'description':bug.description,
321 'date_deadline': bug.date,
322 'project_id': bug.project_id.id,
323 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
324 'priority': str(int(bug.priority) - 1),
325 'user_id': bug.user_id.id,
326 'planned_hours': 0.0,
329 'task_id': new_task_id,
332 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
333 case_obj.write(cr, uid, [bug.id], vals, context=context)
334 self.case_pending_send_note(cr, uid, [bug.id], context=context)
339 'view_mode': 'form,tree',
340 'res_model': 'project.task',
341 'res_id': int(new_task_id),
343 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
344 'type': 'ir.actions.act_window',
345 'search_view_id': res['res_id'],
349 def copy(self, cr, uid, id, default=None, context=None):
350 issue = self.read(cr, uid, id, ['name'], context=context)
353 default = default.copy()
354 default['name'] = issue['name'] + _(' (copy)')
355 return super(project_issue, self).copy(cr, uid, id, default=default,
358 def write(self, cr, uid, ids, vals, context=None):
359 #Update last action date every time the user change the stage, the state or send a new email
360 logged_fields = ['stage_id', 'state', 'message_ids']
361 if any([field in vals for field in logged_fields]):
362 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
363 return super(project_issue, self).write(cr, uid, ids, vals, context)
365 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
369 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
370 return {'value':{'user_id': task.user_id.id,}}
372 def case_reset(self, cr, uid, ids, context=None):
373 """Resets case as draft
375 res = super(project_issue, self).case_reset(cr, uid, ids, context)
376 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
379 def create(self, cr, uid, vals, context=None):
380 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
381 self.create_send_note(cr, uid, [obj_id], context=context)
384 # -------------------------------------------------------
386 # -------------------------------------------------------
388 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
389 """ Override of the base.stage method
390 Parameter of the stage search taken from the issue:
391 - type: stage type must be the same or 'both'
392 - section_id: if set, stages must belong to this section or
395 if isinstance(cases, (int, long)):
396 cases = self.browse(cr, uid, cases, context=context)
397 # collect all section_ids
400 section_ids.append(section_id)
403 section_ids.append(task.project_id.id)
404 # OR all section_ids and OR with case_default
407 search_domain += [('|')] * len(section_ids)
408 for section_id in section_ids:
409 search_domain.append(('project_ids', '=', section_id))
410 search_domain.append(('case_default', '=', True))
411 # AND with the domain in parameter
412 search_domain += list(domain)
413 # perform search, return the first found
414 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
419 def case_cancel(self, cr, uid, ids, context=None):
421 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
422 self.case_cancel_send_note(cr, uid, ids, context=context)
425 def case_escalate(self, cr, uid, ids, context=None):
426 cases = self.browse(cr, uid, ids)
429 if case.project_id.project_escalation_id:
430 data['project_id'] = case.project_id.project_escalation_id.id
431 if case.project_id.project_escalation_id.user_id:
432 data['user_id'] = case.project_id.project_escalation_id.user_id.id
434 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
436 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
437 self.case_set(cr, uid, ids, 'draft', data, context=context)
438 self.case_escalate_send_note(cr, uid, [case.id], context=context)
441 # -------------------------------------------------------
443 # -------------------------------------------------------
445 def message_new(self, cr, uid, msg, custom_values=None, context=None):
446 """ Overrides mail_thread message_new that is called by the mailgateway
447 through message_process.
448 This override updates the document according to the email.
450 if custom_values is None: custom_values = {}
451 if context is None: context = {}
452 context['state_to'] = 'draft'
454 custom_values.update({
455 'name': msg.get('subject') or _("No Subject"),
456 'description': msg.get('body'),
457 'email_from': msg.get('from'),
458 'email_cc': msg.get('cc'),
461 if msg.get('priority'):
462 custom_values['priority'] = msg.get('priority')
464 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
465 # self.convert_to_bug(cr, uid, [res_id], context=context)
468 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
469 """ Overrides mail_thread message_update that is called by the mailgateway
470 through message_process.
471 This method updates the document according to the email.
473 if isinstance(ids, (str, int, long)):
475 if update_vals is None: update_vals = {}
477 # Update doc values according to the message
478 update_vals['description'] = msg.get('body', '')
479 if msg.get('priority'):
480 update_vals['priority'] = msg.get('priority')
481 # Parse 'body' to find values to update
483 'cost': 'planned_cost',
484 'revenue': 'planned_revenue',
485 'probability': 'probability',
487 for line in msg.get('body', '').split('\n'):
489 res = tools.misc.command_re.match(line)
490 if res and maps.get(res.group(1).lower(), False):
491 key = maps.get(res.group(1).lower())
492 update_vals[key] = res.group(2).lower()
494 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
496 # -------------------------------------------------------
497 # OpenChatter methods and notifications
498 # -------------------------------------------------------
500 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
501 """ Override of the (void) default notification method. """
502 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
503 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), subtype="stage change", context=context)
505 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
506 """ Override of default prefix for notifications. """
507 return 'Project issue'
509 def convert_to_task_send_note(self, cr, uid, ids, context=None):
510 message = _("Project issue <b>converted</b> to task.")
511 return self.message_post(cr, uid, ids, body=message, context=context)
513 def create_send_note(self, cr, uid, ids, context=None):
514 message = _("Project issue <b>created</b>.")
515 return self.message_post(cr, uid, ids, body=message, subtype="new", context=context)
517 def case_escalate_send_note(self, cr, uid, ids, context=None):
518 for obj in self.browse(cr, uid, ids, context=context):
520 message = _("<b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
521 obj.message_post(body=message)
523 message = _("<b>escalated</b>.")
524 obj.message_post(body=message)
529 class project(osv.osv):
530 _inherit = "project.project"
532 def _get_alias_models(self, cr, uid, context=None):
533 return [('project.task', "Tasks"), ("project.issue", "Issues")]
535 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
536 res = dict.fromkeys(ids, 0)
537 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
538 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
539 res[issue.project_id.id] += 1
543 '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)]}),
544 'issue_count': fields.function(_issue_count, type='integer'),
547 def _check_escalation(self, cr, uid, ids, context=None):
548 project_obj = self.browse(cr, uid, ids[0], context=context)
549 if project_obj.project_escalation_id:
550 if project_obj.project_escalation_id.id == project_obj.id:
555 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
560 class account_analytic_account(osv.osv):
561 _inherit = 'account.analytic.account'
562 _description = 'Analytic Account'
565 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
568 def on_change_template(self, cr, uid, ids, template_id, context=None):
569 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
570 if template_id and 'value' in res:
571 template = self.browse(cr, uid, template_id, context=context)
572 res['value']['use_issues'] = template.use_issues
575 def _trigger_project_creation(self, cr, uid, vals, context=None):
576 if context is None: context = {}
577 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
578 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
580 account_analytic_account()
582 class project_project(osv.osv):
583 _inherit = 'project.project'
588 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: