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 openerp.addons.base_status.base_stage import base_stage
23 from openerp.addons.crm import crm
24 from datetime import datetime
25 from openerp.osv import fields,osv
26 from openerp.tools.translate import _
29 from openerp import tools
30 from openerp.tools import html2plaintext
32 class project_issue_version(osv.osv):
33 _name = "project.issue.version"
36 'name': fields.char('Version Number', size=32, required=True),
37 'active': fields.boolean('Active', required=False),
42 project_issue_version()
44 _ISSUE_STATE = [('draft', 'New'), ('open', 'In Progress'), ('cancel', 'Cancelled'), ('done', 'Done'), ('pending', 'Pending')]
47 class project_issue(base_stage, osv.osv):
48 _name = "project.issue"
49 _description = "Project Issue"
50 _order = "priority, create_date desc"
51 _inherit = ['mail.thread', 'ir.needaction_mixin']
55 'project_issue.mt_issue_new': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'new',
56 'project_issue.mt_issue_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
57 'project_issue.mt_issue_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
60 'project_issue.mt_issue_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'done', 'open'],
63 'project_issue.mt_issue_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
67 def _get_default_project_id(self, cr, uid, context=None):
68 """ Gives default project by checking if present in the context """
69 return self._resolve_project_id_from_context(cr, uid, context=context)
71 def _get_default_stage_id(self, cr, uid, context=None):
72 """ Gives default stage_id """
73 project_id = self._get_default_project_id(cr, uid, context=context)
74 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
76 def _resolve_project_id_from_context(self, cr, uid, context=None):
77 """ Returns ID of project based on the value of 'default_project_id'
78 context key, or None if it cannot be resolved to a single
83 if type(context.get('default_project_id')) in (int, long):
84 return context.get('default_project_id')
85 if isinstance(context.get('default_project_id'), basestring):
86 project_name = context['default_project_id']
87 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
88 if len(project_ids) == 1:
89 return int(project_ids[0][0])
92 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
93 access_rights_uid = access_rights_uid or uid
94 stage_obj = self.pool.get('project.task.type')
95 order = stage_obj._order
96 # lame hack to allow reverting search, should just work in the trivial case
97 if read_group_order == 'stage_id desc':
98 order = "%s desc" % order
99 # retrieve section_id from the context and write the domain
100 # - ('id', 'in', 'ids'): add columns that should be present
101 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
102 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
104 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
106 search_domain += ['|', ('project_ids', '=', project_id)]
107 search_domain += [('id', 'in', ids)]
109 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
110 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
111 # restore order of the search
112 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
115 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
116 fold[stage.id] = stage.fold or False
119 def _compute_day(self, cr, uid, ids, fields, args, context=None):
121 @param cr: the current row, from the database cursor,
122 @param uid: the current user’s ID for security checks,
123 @param ids: List of Openday’s IDs
124 @return: difference between current date and log date
125 @param context: A standard dictionary for contextual values
127 cal_obj = self.pool.get('resource.calendar')
128 res_obj = self.pool.get('resource.resource')
131 for issue in self.browse(cr, uid, ids, context=context):
138 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
139 if field in ['working_hours_open','day_open']:
141 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
142 ans = date_open - date_create
143 date_until = issue.date_open
144 #Calculating no. of working hours to open the issue
145 if issue.project_id.resource_calendar_id:
146 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
149 elif field in ['working_hours_close','day_close']:
150 if issue.date_closed:
151 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
152 date_until = issue.date_closed
153 ans = date_close - date_create
154 #Calculating no. of working hours to close the issue
155 if issue.project_id.resource_calendar_id:
156 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
159 elif field in ['days_since_creation']:
160 if issue.create_date:
161 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
162 res[issue.id][field] = days_since_creation.days
165 elif field in ['inactivity_days']:
166 res[issue.id][field] = 0
167 if issue.date_action_last:
168 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
169 res[issue.id][field] = inactive_days.days
174 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
175 if resource_ids and len(resource_ids):
176 resource_id = resource_ids[0]
177 duration = float(ans.days)
178 if issue.project_id and issue.project_id.resource_calendar_id:
179 duration = float(ans.days) * 24
181 new_dates = cal_obj.interval_min_get(cr, uid,
182 issue.project_id.resource_calendar_id.id,
184 duration, resource=resource_id)
186 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
187 for in_time, out_time in new_dates:
188 if in_time.date not in no_days:
189 no_days.append(in_time.date)
190 if out_time > date_until:
192 duration = len(no_days)
194 if field in ['working_hours_open','working_hours_close']:
195 res[issue.id][field] = hours
197 res[issue.id][field] = abs(float(duration))
201 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
202 task_pool = self.pool.get('project.task')
204 for issue in self.browse(cr, uid, ids, context=context):
207 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
208 res[issue.id] = {'progress' : progress}
211 def on_change_project(self, cr, uid, ids, project_id, context=None):
214 def _get_issue_task(self, cr, uid, ids, context=None):
216 issue_pool = self.pool.get('project.issue')
217 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
218 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
221 def _get_issue_work(self, cr, uid, ids, context=None):
223 issue_pool = self.pool.get('project.issue')
224 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
226 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
230 'id': fields.integer('ID', readonly=True),
231 'name': fields.char('Issue', size=128, required=True),
232 'active': fields.boolean('Active', required=False),
233 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
234 'write_date': fields.datetime('Update Date', readonly=True),
235 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
236 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
237 'date_deadline': fields.date('Deadline'),
238 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
239 select=True, help='Sales team to which Case belongs to.\
240 Define Responsible user and Email account for mail gateway.'),
241 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
242 'company_id': fields.many2one('res.company', 'Company'),
243 'description': fields.text('Private Note'),
244 'state': fields.related('stage_id', 'state', type="selection", store=True,
245 selection=_ISSUE_STATE, string="Status", readonly=True,
246 help='The status is set to \'Draft\', when a case is created.\
247 If the case is in progress the status is set to \'Open\'.\
248 When the case is over, the status is set to \'Done\'.\
249 If the case needs to be reviewed then the status is \
250 set to \'Pending\'.'),
251 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
252 track_visibility='onchange',
253 help="A Issue's kanban state indicates special situations affecting it:\n"
254 " * Normal is the default situation\n"
255 " * Blocked indicates something is preventing the progress of this issue\n"
256 " * Ready for next stage indicates the issue is ready to be pulled to the next stage",
257 readonly=True, required=False),
258 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
259 '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"),
260 'date_open': fields.datetime('Opened', readonly=True,select=True),
261 # Project Issue fields
262 'date_closed': fields.datetime('Closed', readonly=True,select=True),
263 'date': fields.datetime('Date'),
264 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
265 'categ_ids': fields.many2many('project.category', string='Tags'),
266 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
267 'version_id': fields.many2one('project.issue.version', 'Version'),
268 'stage_id': fields.many2one ('project.task.type', 'Stage',
269 track_visibility='onchange',
270 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
271 'project_id':fields.many2one('project.project', 'Project', track_visibility='onchange'),
272 'duration': fields.float('Duration'),
273 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
274 'day_open': fields.function(_compute_day, string='Days to Open', \
275 multi='compute_day', type="float", store=True),
276 'day_close': fields.function(_compute_day, string='Days to Close', \
277 multi='compute_day', type="float", store=True),
278 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1, track_visibility='onchange'),
279 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
280 multi='compute_day', type="float", store=True),
281 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
282 multi='compute_day', type="float", store=True),
283 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
284 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
285 'color': fields.integer('Color Index'),
286 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
287 'date_action_last': fields.datetime('Last Action', readonly=1),
288 'date_action_next': fields.datetime('Next Action', readonly=1),
289 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
291 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
292 'project.task': (_get_issue_task, ['progress'], 10),
293 'project.task.work': (_get_issue_work, ['hours'], 10),
299 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
300 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
301 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
302 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
303 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
304 'priority': crm.AVAILABLE_PRIORITIES[2][0],
305 'kanban_state': 'normal',
309 'stage_id': _read_group_stage_ids
312 def set_priority(self, cr, uid, ids, priority, *args):
315 return self.write(cr, uid, ids, {'priority' : priority})
317 def set_high_priority(self, cr, uid, ids, *args):
318 """Set lead priority to high
320 return self.set_priority(cr, uid, ids, '1')
322 def set_normal_priority(self, cr, uid, ids, *args):
323 """Set lead priority to normal
325 return self.set_priority(cr, uid, ids, '3')
327 def convert_issue_task(self, cr, uid, ids, context=None):
331 case_obj = self.pool.get('project.issue')
332 data_obj = self.pool.get('ir.model.data')
333 task_obj = self.pool.get('project.task')
335 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
336 res = data_obj.read(cr, uid, result, ['res_id'])
337 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
338 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
340 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
342 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
344 for bug in case_obj.browse(cr, uid, ids, context=context):
345 new_task_id = task_obj.create(cr, uid, {
347 'partner_id': bug.partner_id.id,
348 'description':bug.description,
349 'date_deadline': bug.date,
350 'project_id': bug.project_id.id,
351 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
352 'priority': str(int(bug.priority) - 1),
353 'user_id': bug.user_id.id,
354 'planned_hours': 0.0,
357 'task_id': new_task_id,
358 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
360 message = _("Project issue <b>converted</b> to task.")
361 self.message_post(cr, uid, [bug.id], body=message, context=context)
362 case_obj.write(cr, uid, [bug.id], vals, context=context)
367 'view_mode': 'form,tree',
368 'res_model': 'project.task',
369 'res_id': int(new_task_id),
371 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
372 'type': 'ir.actions.act_window',
373 'search_view_id': res['res_id'],
377 def copy(self, cr, uid, id, default=None, context=None):
378 issue = self.read(cr, uid, id, ['name'], context=context)
381 default = default.copy()
382 default.update(name=_('%s (copy)') % (issue['name']))
383 return super(project_issue, self).copy(cr, uid, id, default=default,
386 def write(self, cr, uid, ids, vals, context=None):
387 #Update last action date every time the user change the stage, the state or send a new email
388 logged_fields = ['stage_id', 'state', 'message_ids']
389 if any([field in vals for field in logged_fields]):
390 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
392 return super(project_issue, self).write(cr, uid, ids, vals, context)
394 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
397 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
398 return {'value': {'user_id': task.user_id.id, }}
400 def case_reset(self, cr, uid, ids, context=None):
401 """Resets case as draft
403 res = super(project_issue, self).case_reset(cr, uid, ids, context)
404 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
407 # -------------------------------------------------------
409 # -------------------------------------------------------
411 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
412 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
414 def set_kanban_state_normal(self, cr, uid, ids, context=None):
415 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
417 def set_kanban_state_done(self, cr, uid, ids, context=None):
418 return self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
420 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
421 """ Override of the base.stage method
422 Parameter of the stage search taken from the issue:
423 - type: stage type must be the same or 'both'
424 - section_id: if set, stages must belong to this section or
427 if isinstance(cases, (int, long)):
428 cases = self.browse(cr, uid, cases, context=context)
429 # collect all section_ids
432 section_ids.append(section_id)
435 section_ids.append(task.project_id.id)
436 # OR all section_ids and OR with case_default
439 search_domain += [('|')] * (len(section_ids)-1)
440 for section_id in section_ids:
441 search_domain.append(('project_ids', '=', section_id))
442 search_domain += list(domain)
443 # perform search, return the first found
444 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
449 def case_cancel(self, cr, uid, ids, context=None):
451 self.case_set(cr, uid, ids, 'cancel', {'active': True}, context=context)
454 def case_escalate(self, cr, uid, ids, context=None):
455 cases = self.browse(cr, uid, ids)
458 if case.project_id.project_escalation_id:
459 data['project_id'] = case.project_id.project_escalation_id.id
460 if case.project_id.project_escalation_id.user_id:
461 data['user_id'] = case.project_id.project_escalation_id.user_id.id
463 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
465 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
466 self.case_set(cr, uid, ids, 'draft', data, context=context)
469 # -------------------------------------------------------
471 # -------------------------------------------------------
473 def message_new(self, cr, uid, msg, custom_values=None, context=None):
474 """ Overrides mail_thread message_new that is called by the mailgateway
475 through message_process.
476 This override updates the document according to the email.
478 if custom_values is None: custom_values = {}
479 if context is None: context = {}
480 context['state_to'] = 'draft'
482 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
484 custom_values.update({
485 'name': msg.get('subject') or _("No Subject"),
487 'email_from': msg.get('from'),
488 'email_cc': msg.get('cc'),
491 if msg.get('priority'):
492 custom_values['priority'] = msg.get('priority')
494 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
497 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
498 """ Overrides mail_thread message_update that is called by the mailgateway
499 through message_process.
500 This method updates the document according to the email.
502 if isinstance(ids, (str, int, long)):
504 if update_vals is None: update_vals = {}
506 # Update doc values according to the message
507 if msg.get('priority'):
508 update_vals['priority'] = msg.get('priority')
509 # Parse 'body' to find values to update
511 'cost': 'planned_cost',
512 'revenue': 'planned_revenue',
513 'probability': 'probability',
515 for line in msg.get('body', '').split('\n'):
517 res = tools.misc.command_re.match(line)
518 if res and maps.get(res.group(1).lower(), False):
519 key = maps.get(res.group(1).lower())
520 update_vals[key] = res.group(2).lower()
522 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
525 class project(osv.osv):
526 _inherit = "project.project"
528 def _get_alias_models(self, cr, uid, context=None):
529 return [('project.task', "Tasks"), ("project.issue", "Issues")]
531 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
532 res = dict.fromkeys(ids, 0)
533 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
534 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
535 res[issue.project_id.id] += 1
539 '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)]}),
540 'issue_count': fields.function(_issue_count, type='integer'),
543 def _check_escalation(self, cr, uid, ids, context=None):
544 project_obj = self.browse(cr, uid, ids[0], context=context)
545 if project_obj.project_escalation_id:
546 if project_obj.project_escalation_id.id == project_obj.id:
551 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
556 class account_analytic_account(osv.osv):
557 _inherit = 'account.analytic.account'
558 _description = 'Analytic Account'
561 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
564 def on_change_template(self, cr, uid, ids, template_id, context=None):
565 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
566 if template_id and 'value' in res:
567 template = self.browse(cr, uid, template_id, context=context)
568 res['value']['use_issues'] = template.use_issues
571 def _trigger_project_creation(self, cr, uid, vals, context=None):
572 if context is None: context = {}
573 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
574 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
576 account_analytic_account()
578 class project_project(osv.osv):
579 _inherit = 'project.project'
584 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: