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