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 create(self, cr, uid, vals, context=None):
70 if not vals.get('stage_id'):
72 if vals.get('project_id'):
73 ctx['default_project_id'] = vals['project_id']
74 vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
75 return super(project_issue, self).create(cr, uid, vals, context=context)
77 def _get_default_project_id(self, cr, uid, context=None):
78 """ Gives default project by checking if present in the context """
79 return self._resolve_project_id_from_context(cr, uid, context=context)
81 def _get_default_stage_id(self, cr, uid, context=None):
82 """ Gives default stage_id """
83 project_id = self._get_default_project_id(cr, uid, context=context)
84 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
86 def _resolve_project_id_from_context(self, cr, uid, context=None):
87 """ Returns ID of project based on the value of 'default_project_id'
88 context key, or None if it cannot be resolved to a single
93 if type(context.get('default_project_id')) in (int, long):
94 return context.get('default_project_id')
95 if isinstance(context.get('default_project_id'), basestring):
96 project_name = context['default_project_id']
97 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
98 if len(project_ids) == 1:
99 return int(project_ids[0][0])
102 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
103 access_rights_uid = access_rights_uid or uid
104 stage_obj = self.pool.get('project.task.type')
105 order = stage_obj._order
106 # lame hack to allow reverting search, should just work in the trivial case
107 if read_group_order == 'stage_id desc':
108 order = "%s desc" % order
109 # retrieve section_id from the context and write the domain
110 # - ('id', 'in', 'ids'): add columns that should be present
111 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
112 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
114 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
116 search_domain += ['|', ('project_ids', '=', project_id)]
117 search_domain += [('id', 'in', ids)]
119 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
120 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
121 # restore order of the search
122 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
125 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
126 fold[stage.id] = stage.fold or False
129 def _compute_day(self, cr, uid, ids, fields, args, context=None):
131 @param cr: the current row, from the database cursor,
132 @param uid: the current user’s ID for security checks,
133 @param ids: List of Openday’s IDs
134 @return: difference between current date and log date
135 @param context: A standard dictionary for contextual values
137 cal_obj = self.pool.get('resource.calendar')
138 res_obj = self.pool.get('resource.resource')
141 for issue in self.browse(cr, uid, ids, context=context):
148 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
149 if field in ['working_hours_open','day_open']:
151 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
152 ans = date_open - date_create
153 date_until = issue.date_open
154 #Calculating no. of working hours to open 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 ['working_hours_close','day_close']:
160 if issue.date_closed:
161 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
162 date_until = issue.date_closed
163 ans = date_close - date_create
164 #Calculating no. of working hours to close the issue
165 if issue.project_id.resource_calendar_id:
166 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
169 elif field in ['days_since_creation']:
170 if issue.create_date:
171 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
172 res[issue.id][field] = days_since_creation.days
175 elif field in ['inactivity_days']:
176 res[issue.id][field] = 0
177 if issue.date_action_last:
178 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
179 res[issue.id][field] = inactive_days.days
184 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
185 if resource_ids and len(resource_ids):
186 resource_id = resource_ids[0]
187 duration = float(ans.days)
188 if issue.project_id and issue.project_id.resource_calendar_id:
189 duration = float(ans.days) * 24
191 new_dates = cal_obj.interval_min_get(cr, uid,
192 issue.project_id.resource_calendar_id.id,
194 duration, resource=resource_id)
196 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
197 for in_time, out_time in new_dates:
198 if in_time.date not in no_days:
199 no_days.append(in_time.date)
200 if out_time > date_until:
202 duration = len(no_days)
204 if field in ['working_hours_open','working_hours_close']:
205 res[issue.id][field] = hours
207 res[issue.id][field] = abs(float(duration))
211 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
212 task_pool = self.pool.get('project.task')
214 for issue in self.browse(cr, uid, ids, context=context):
217 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
218 res[issue.id] = {'progress' : progress}
221 def on_change_project(self, cr, uid, ids, project_id, context=None):
224 def _get_issue_task(self, cr, uid, ids, context=None):
226 issue_pool = self.pool.get('project.issue')
227 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
228 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
231 def _get_issue_work(self, cr, uid, ids, context=None):
233 issue_pool = self.pool.get('project.issue')
234 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
236 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
240 'id': fields.integer('ID', readonly=True),
241 'name': fields.char('Issue', size=128, required=True),
242 'active': fields.boolean('Active', required=False),
243 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
244 'write_date': fields.datetime('Update Date', readonly=True),
245 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
246 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
247 'date_deadline': fields.date('Deadline'),
248 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
249 select=True, help='Sales team to which Case belongs to.\
250 Define Responsible user and Email account for mail gateway.'),
251 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
252 'company_id': fields.many2one('res.company', 'Company'),
253 'description': fields.text('Private Note'),
254 'state': fields.related('stage_id', 'state', type="selection", store=True,
255 selection=_ISSUE_STATE, string="Status", readonly=True,
256 help='The status is set to \'Draft\', when a case is created.\
257 If the case is in progress the status is set to \'Open\'.\
258 When the case is over, the status is set to \'Done\'.\
259 If the case needs to be reviewed then the status is \
260 set to \'Pending\'.'),
261 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
262 track_visibility='onchange',
263 help="A Issue's kanban state indicates special situations affecting it:\n"
264 " * Normal is the default situation\n"
265 " * Blocked indicates something is preventing the progress of this issue\n"
266 " * Ready for next stage indicates the issue is ready to be pulled to the next stage",
267 readonly=True, required=False),
268 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
269 '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"),
270 'date_open': fields.datetime('Opened', readonly=True,select=True),
271 # Project Issue fields
272 'date_closed': fields.datetime('Closed', readonly=True,select=True),
273 'date': fields.datetime('Date'),
274 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
275 'categ_ids': fields.many2many('project.category', string='Tags'),
276 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
277 'version_id': fields.many2one('project.issue.version', 'Version'),
278 'stage_id': fields.many2one ('project.task.type', 'Stage',
279 track_visibility='onchange',
280 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
281 'project_id':fields.many2one('project.project', 'Project', track_visibility='onchange'),
282 'duration': fields.float('Duration'),
283 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
284 'day_open': fields.function(_compute_day, string='Days to Open', \
285 multi='compute_day', type="float", store=True),
286 'day_close': fields.function(_compute_day, string='Days to Close', \
287 multi='compute_day', type="float", store=True),
288 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1, track_visibility='onchange'),
289 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
290 multi='compute_day', type="float", store=True),
291 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
292 multi='compute_day', type="float", store=True),
293 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
294 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
295 'color': fields.integer('Color Index'),
296 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
297 'date_action_last': fields.datetime('Last Action', readonly=1),
298 'date_action_next': fields.datetime('Next Action', readonly=1),
299 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
301 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
302 'project.task': (_get_issue_task, ['progress'], 10),
303 'project.task.work': (_get_issue_work, ['hours'], 10),
309 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
310 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
311 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
312 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
313 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
314 'priority': crm.AVAILABLE_PRIORITIES[2][0],
315 'kanban_state': 'normal',
319 'stage_id': _read_group_stage_ids
322 def set_priority(self, cr, uid, ids, priority, *args):
325 return self.write(cr, uid, ids, {'priority' : priority})
327 def set_high_priority(self, cr, uid, ids, *args):
328 """Set lead priority to high
330 return self.set_priority(cr, uid, ids, '1')
332 def set_normal_priority(self, cr, uid, ids, *args):
333 """Set lead priority to normal
335 return self.set_priority(cr, uid, ids, '3')
337 def convert_issue_task(self, cr, uid, ids, context=None):
341 case_obj = self.pool.get('project.issue')
342 data_obj = self.pool.get('ir.model.data')
343 task_obj = self.pool.get('project.task')
345 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
346 res = data_obj.read(cr, uid, result, ['res_id'])
347 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
348 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
350 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
352 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
354 for bug in case_obj.browse(cr, uid, ids, context=context):
355 new_task_id = task_obj.create(cr, uid, {
357 'partner_id': bug.partner_id.id,
358 'description':bug.description,
359 'date_deadline': bug.date,
360 'project_id': bug.project_id.id,
361 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
362 'priority': str(int(bug.priority) - 1),
363 'user_id': bug.user_id.id,
364 'planned_hours': 0.0,
367 'task_id': new_task_id,
368 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
370 message = _("Project issue <b>converted</b> to task.")
371 self.message_post(cr, uid, [bug.id], body=message, context=context)
372 case_obj.write(cr, uid, [bug.id], vals, context=context)
377 'view_mode': 'form,tree',
378 'res_model': 'project.task',
379 'res_id': int(new_task_id),
381 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
382 'type': 'ir.actions.act_window',
383 'search_view_id': res['res_id'],
387 def copy(self, cr, uid, id, default=None, context=None):
388 issue = self.read(cr, uid, id, ['name'], context=context)
391 default = default.copy()
392 default.update(name=_('%s (copy)') % (issue['name']))
393 return super(project_issue, self).copy(cr, uid, id, default=default,
396 def write(self, cr, uid, ids, vals, context=None):
397 #Update last action date every time the user change the stage, the state or send a new email
398 logged_fields = ['stage_id', 'state', 'message_ids']
399 if any([field in vals for field in logged_fields]):
400 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
402 return super(project_issue, self).write(cr, uid, ids, vals, context)
404 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
407 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
408 return {'value': {'user_id': task.user_id.id, }}
410 def case_reset(self, cr, uid, ids, context=None):
411 """Resets case as draft
413 res = super(project_issue, self).case_reset(cr, uid, ids, context)
414 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
417 # -------------------------------------------------------
419 # -------------------------------------------------------
421 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
422 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
424 def set_kanban_state_normal(self, cr, uid, ids, context=None):
425 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
427 def set_kanban_state_done(self, cr, uid, ids, context=None):
428 return self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
430 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
431 """ Override of the base.stage method
432 Parameter of the stage search taken from the issue:
433 - type: stage type must be the same or 'both'
434 - section_id: if set, stages must belong to this section or
437 if isinstance(cases, (int, long)):
438 cases = self.browse(cr, uid, cases, context=context)
439 # collect all section_ids
442 section_ids.append(section_id)
445 section_ids.append(task.project_id.id)
446 # OR all section_ids and OR with case_default
449 search_domain += [('|')] * (len(section_ids)-1)
450 for section_id in section_ids:
451 search_domain.append(('project_ids', '=', section_id))
452 search_domain += list(domain)
453 # perform search, return the first found
454 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
459 def case_cancel(self, cr, uid, ids, context=None):
461 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
464 def case_escalate(self, cr, uid, ids, context=None):
465 cases = self.browse(cr, uid, ids)
468 if case.project_id.project_escalation_id:
469 data['project_id'] = case.project_id.project_escalation_id.id
470 if case.project_id.project_escalation_id.user_id:
471 data['user_id'] = case.project_id.project_escalation_id.user_id.id
473 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
475 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
476 self.case_set(cr, uid, ids, 'draft', data, context=context)
479 # -------------------------------------------------------
481 # -------------------------------------------------------
483 def message_get_reply_to(self, cr, uid, ids, context=None):
484 """ Override to get the reply_to of the parent project. """
485 return [issue.project_id.message_get_reply_to()[0] if issue.project_id else False
486 for issue in self.browse(cr, uid, ids, context=context)]
488 def message_new(self, cr, uid, msg, custom_values=None, context=None):
489 """ Overrides mail_thread message_new that is called by the mailgateway
490 through message_process.
491 This override updates the document according to the email.
493 if custom_values is None: custom_values = {}
494 if context is None: context = {}
495 context['state_to'] = 'draft'
497 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
500 'name': msg.get('subject') or _("No Subject"),
502 'email_from': msg.get('from'),
503 'email_cc': msg.get('cc'),
504 'partner_id': msg.get('author_id', False),
507 if msg.get('priority'):
508 defaults['priority'] = msg.get('priority')
510 defaults.update(custom_values)
511 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
514 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
515 """ Overrides mail_thread message_update that is called by the mailgateway
516 through message_process.
517 This method updates the document according to the email.
519 if isinstance(ids, (str, int, long)):
521 if update_vals is None: update_vals = {}
523 # Update doc values according to the message
524 if msg.get('priority'):
525 update_vals['priority'] = msg.get('priority')
526 # Parse 'body' to find values to update
528 'cost': 'planned_cost',
529 'revenue': 'planned_revenue',
530 'probability': 'probability',
532 for line in msg.get('body', '').split('\n'):
534 res = tools.command_re.match(line)
535 if res and maps.get(res.group(1).lower(), False):
536 key = maps.get(res.group(1).lower())
537 update_vals[key] = res.group(2).lower()
539 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
542 class project(osv.osv):
543 _inherit = "project.project"
545 def _get_alias_models(self, cr, uid, context=None):
546 return [('project.task', "Tasks"), ("project.issue", "Issues")]
548 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
549 res = dict.fromkeys(ids, 0)
550 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
551 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
552 res[issue.project_id.id] += 1
556 '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)]}),
557 'issue_count': fields.function(_issue_count, type='integer'),
560 def _check_escalation(self, cr, uid, ids, context=None):
561 project_obj = self.browse(cr, uid, ids[0], context=context)
562 if project_obj.project_escalation_id:
563 if project_obj.project_escalation_id.id == project_obj.id:
568 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
573 class account_analytic_account(osv.osv):
574 _inherit = 'account.analytic.account'
575 _description = 'Analytic Account'
578 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
581 def on_change_template(self, cr, uid, ids, template_id, context=None):
582 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
583 if template_id and 'value' in res:
584 template = self.browse(cr, uid, template_id, context=context)
585 res['value']['use_issues'] = template.use_issues
588 def _trigger_project_creation(self, cr, uid, vals, context=None):
589 if context is None: context = {}
590 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
591 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
593 account_analytic_account()
595 class project_project(osv.osv):
596 _inherit = 'project.project'
601 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: