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') and vals.get('project_id'):
72 ctx['default_project_id'] = vals['project_id']
73 vals['stage_id'] = self._get_default_stage_id(cr, uid, context=ctx)
74 elif not vals.get('stage_id') and context.get('default_project_id'):
75 vals['stage_id'] = self._get_default_stage_id(cr, uid, context=context)
76 return super(project_issue, self).create(cr, uid, vals, context=context)
78 def _get_default_project_id(self, cr, uid, context=None):
79 """ Gives default project by checking if present in the context """
80 return self._resolve_project_id_from_context(cr, uid, context=context)
82 def _get_default_stage_id(self, cr, uid, context=None):
83 """ Gives default stage_id """
84 project_id = self._get_default_project_id(cr, uid, context=context)
85 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
87 def _resolve_project_id_from_context(self, cr, uid, context=None):
88 """ Returns ID of project based on the value of 'default_project_id'
89 context key, or None if it cannot be resolved to a single
94 if type(context.get('default_project_id')) in (int, long):
95 return context.get('default_project_id')
96 if isinstance(context.get('default_project_id'), basestring):
97 project_name = context['default_project_id']
98 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
99 if len(project_ids) == 1:
100 return int(project_ids[0][0])
103 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
104 access_rights_uid = access_rights_uid or uid
105 stage_obj = self.pool.get('project.task.type')
106 order = stage_obj._order
107 # lame hack to allow reverting search, should just work in the trivial case
108 if read_group_order == 'stage_id desc':
109 order = "%s desc" % order
110 # retrieve section_id from the context and write the domain
111 # - ('id', 'in', 'ids'): add columns that should be present
112 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
113 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
115 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
117 search_domain += ['|', ('project_ids', '=', project_id)]
118 search_domain += [('id', 'in', ids)]
120 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
121 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
122 # restore order of the search
123 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
126 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
127 fold[stage.id] = stage.fold or False
130 def _compute_day(self, cr, uid, ids, fields, args, context=None):
132 @param cr: the current row, from the database cursor,
133 @param uid: the current user’s ID for security checks,
134 @param ids: List of Openday’s IDs
135 @return: difference between current date and log date
136 @param context: A standard dictionary for contextual values
138 cal_obj = self.pool.get('resource.calendar')
139 res_obj = self.pool.get('resource.resource')
142 for issue in self.browse(cr, uid, ids, context=context):
149 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
150 if field in ['working_hours_open','day_open']:
152 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
153 ans = date_open - date_create
154 date_until = issue.date_open
155 #Calculating no. of working hours to open the issue
156 if issue.project_id.resource_calendar_id:
157 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
160 elif field in ['working_hours_close','day_close']:
161 if issue.date_closed:
162 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
163 date_until = issue.date_closed
164 ans = date_close - date_create
165 #Calculating no. of working hours to close the issue
166 if issue.project_id.resource_calendar_id:
167 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
170 elif field in ['days_since_creation']:
171 if issue.create_date:
172 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
173 res[issue.id][field] = days_since_creation.days
176 elif field in ['inactivity_days']:
177 res[issue.id][field] = 0
178 if issue.date_action_last:
179 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
180 res[issue.id][field] = inactive_days.days
185 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
186 if resource_ids and len(resource_ids):
187 resource_id = resource_ids[0]
188 duration = float(ans.days)
189 if issue.project_id and issue.project_id.resource_calendar_id:
190 duration = float(ans.days) * 24
192 new_dates = cal_obj.interval_min_get(cr, uid,
193 issue.project_id.resource_calendar_id.id,
195 duration, resource=resource_id)
197 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
198 for in_time, out_time in new_dates:
199 if in_time.date not in no_days:
200 no_days.append(in_time.date)
201 if out_time > date_until:
203 duration = len(no_days)
205 if field in ['working_hours_open','working_hours_close']:
206 res[issue.id][field] = hours
208 res[issue.id][field] = abs(float(duration))
212 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
213 task_pool = self.pool.get('project.task')
215 for issue in self.browse(cr, uid, ids, context=context):
218 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
219 res[issue.id] = {'progress' : progress}
222 def on_change_project(self, cr, uid, ids, project_id, context=None):
225 def _get_issue_task(self, cr, uid, ids, context=None):
227 issue_pool = self.pool.get('project.issue')
228 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
229 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
232 def _get_issue_work(self, cr, uid, ids, context=None):
234 issue_pool = self.pool.get('project.issue')
235 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
237 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
241 'id': fields.integer('ID', readonly=True),
242 'name': fields.char('Issue', size=128, required=True),
243 'active': fields.boolean('Active', required=False),
244 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
245 'write_date': fields.datetime('Update Date', readonly=True),
246 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
247 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
248 'date_deadline': fields.date('Deadline'),
249 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
250 select=True, help='Sales team to which Case belongs to.\
251 Define Responsible user and Email account for mail gateway.'),
252 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
253 'company_id': fields.many2one('res.company', 'Company'),
254 'description': fields.text('Private Note'),
255 'state': fields.related('stage_id', 'state', type="selection", store=True,
256 selection=_ISSUE_STATE, string="Status", readonly=True,
257 help='The status is set to \'Draft\', when a case is created.\
258 If the case is in progress the status is set to \'Open\'.\
259 When the case is over, the status is set to \'Done\'.\
260 If the case needs to be reviewed then the status is \
261 set to \'Pending\'.'),
262 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
263 track_visibility='onchange',
264 help="A Issue's kanban state indicates special situations affecting it:\n"
265 " * Normal is the default situation\n"
266 " * Blocked indicates something is preventing the progress of this issue\n"
267 " * Ready for next stage indicates the issue is ready to be pulled to the next stage",
268 readonly=True, required=False),
269 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
270 '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"),
271 'date_open': fields.datetime('Opened', readonly=True,select=True),
272 # Project Issue fields
273 'date_closed': fields.datetime('Closed', readonly=True,select=True),
274 'date': fields.datetime('Date'),
275 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
276 'categ_ids': fields.many2many('project.category', string='Tags'),
277 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
278 'version_id': fields.many2one('project.issue.version', 'Version'),
279 'stage_id': fields.many2one ('project.task.type', 'Stage',
280 track_visibility='onchange',
281 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
282 'project_id':fields.many2one('project.project', 'Project', track_visibility='onchange'),
283 'duration': fields.float('Duration'),
284 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
285 'day_open': fields.function(_compute_day, string='Days to Open', \
286 multi='compute_day', type="float", store=True),
287 'day_close': fields.function(_compute_day, string='Days to Close', \
288 multi='compute_day', type="float", store=True),
289 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1, track_visibility='onchange'),
290 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
291 multi='compute_day', type="float", store=True),
292 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
293 multi='compute_day', type="float", store=True),
294 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
295 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
296 'color': fields.integer('Color Index'),
297 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
298 'date_action_last': fields.datetime('Last Action', readonly=1),
299 'date_action_next': fields.datetime('Next Action', readonly=1),
300 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
302 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
303 'project.task': (_get_issue_task, ['progress'], 10),
304 'project.task.work': (_get_issue_work, ['hours'], 10),
310 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
311 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
312 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
313 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
314 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
315 'priority': crm.AVAILABLE_PRIORITIES[2][0],
316 'kanban_state': 'normal',
320 'stage_id': _read_group_stage_ids
323 def set_priority(self, cr, uid, ids, priority, *args):
326 return self.write(cr, uid, ids, {'priority' : priority})
328 def set_high_priority(self, cr, uid, ids, *args):
329 """Set lead priority to high
331 return self.set_priority(cr, uid, ids, '1')
333 def set_normal_priority(self, cr, uid, ids, *args):
334 """Set lead priority to normal
336 return self.set_priority(cr, uid, ids, '3')
338 def convert_issue_task(self, cr, uid, ids, context=None):
342 case_obj = self.pool.get('project.issue')
343 data_obj = self.pool.get('ir.model.data')
344 task_obj = self.pool.get('project.task')
346 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
347 res = data_obj.read(cr, uid, result, ['res_id'])
348 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
349 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
351 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
353 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
355 for bug in case_obj.browse(cr, uid, ids, context=context):
356 new_task_id = task_obj.create(cr, uid, {
358 'partner_id': bug.partner_id.id,
359 'description':bug.description,
360 'date_deadline': bug.date,
361 'project_id': bug.project_id.id,
362 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
363 'priority': str(int(bug.priority) - 1),
364 'user_id': bug.user_id.id,
365 'planned_hours': 0.0,
368 'task_id': new_task_id,
369 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
371 message = _("Project issue <b>converted</b> to task.")
372 self.message_post(cr, uid, [bug.id], body=message, context=context)
373 case_obj.write(cr, uid, [bug.id], vals, context=context)
378 'view_mode': 'form,tree',
379 'res_model': 'project.task',
380 'res_id': int(new_task_id),
382 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
383 'type': 'ir.actions.act_window',
384 'search_view_id': res['res_id'],
388 def copy(self, cr, uid, id, default=None, context=None):
389 issue = self.read(cr, uid, id, ['name'], context=context)
392 default = default.copy()
393 default.update(name=_('%s (copy)') % (issue['name']))
394 return super(project_issue, self).copy(cr, uid, id, default=default,
397 def write(self, cr, uid, ids, vals, context=None):
398 #Update last action date every time the user change the stage, the state or send a new email
399 logged_fields = ['stage_id', 'state', 'message_ids']
400 if any([field in vals for field in logged_fields]):
401 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
403 return super(project_issue, self).write(cr, uid, ids, vals, context)
405 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
408 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
409 return {'value': {'user_id': task.user_id.id, }}
411 def case_reset(self, cr, uid, ids, context=None):
412 """Resets case as draft
414 res = super(project_issue, self).case_reset(cr, uid, ids, context)
415 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
418 # -------------------------------------------------------
420 # -------------------------------------------------------
422 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
423 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
425 def set_kanban_state_normal(self, cr, uid, ids, context=None):
426 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
428 def set_kanban_state_done(self, cr, uid, ids, context=None):
429 return self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
431 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
432 """ Override of the base.stage method
433 Parameter of the stage search taken from the issue:
434 - type: stage type must be the same or 'both'
435 - section_id: if set, stages must belong to this section or
438 if isinstance(cases, (int, long)):
439 cases = self.browse(cr, uid, cases, context=context)
440 # collect all section_ids
443 section_ids.append(section_id)
446 section_ids.append(task.project_id.id)
447 # OR all section_ids and OR with case_default
450 search_domain += [('|')] * (len(section_ids)-1)
451 for section_id in section_ids:
452 search_domain.append(('project_ids', '=', section_id))
453 search_domain += list(domain)
454 # perform search, return the first found
455 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
460 def case_cancel(self, cr, uid, ids, context=None):
462 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
465 def case_escalate(self, cr, uid, ids, context=None):
466 cases = self.browse(cr, uid, ids)
469 if case.project_id.project_escalation_id:
470 data['project_id'] = case.project_id.project_escalation_id.id
471 if case.project_id.project_escalation_id.user_id:
472 data['user_id'] = case.project_id.project_escalation_id.user_id.id
474 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
476 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
477 self.case_set(cr, uid, ids, 'draft', data, context=context)
480 # -------------------------------------------------------
482 # -------------------------------------------------------
484 def message_new(self, cr, uid, msg, custom_values=None, context=None):
485 """ Overrides mail_thread message_new that is called by the mailgateway
486 through message_process.
487 This override updates the document according to the email.
489 if custom_values is None: custom_values = {}
490 if context is None: context = {}
491 context['state_to'] = 'draft'
493 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
495 custom_values.update({
496 'name': msg.get('subject') or _("No Subject"),
498 'email_from': msg.get('from'),
499 'email_cc': msg.get('cc'),
502 if msg.get('priority'):
503 custom_values['priority'] = msg.get('priority')
505 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
508 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
509 """ Overrides mail_thread message_update that is called by the mailgateway
510 through message_process.
511 This method updates the document according to the email.
513 if isinstance(ids, (str, int, long)):
515 if update_vals is None: update_vals = {}
517 # Update doc values according to the message
518 if msg.get('priority'):
519 update_vals['priority'] = msg.get('priority')
520 # Parse 'body' to find values to update
522 'cost': 'planned_cost',
523 'revenue': 'planned_revenue',
524 'probability': 'probability',
526 for line in msg.get('body', '').split('\n'):
528 res = tools.command_re.match(line)
529 if res and maps.get(res.group(1).lower(), False):
530 key = maps.get(res.group(1).lower())
531 update_vals[key] = res.group(2).lower()
533 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
536 class project(osv.osv):
537 _inherit = "project.project"
539 def _get_alias_models(self, cr, uid, context=None):
540 return [('project.task', "Tasks"), ("project.issue", "Issues")]
542 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
543 res = dict.fromkeys(ids, 0)
544 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
545 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
546 res[issue.project_id.id] += 1
550 'project_escalation_id' : fields.many2one('project.project','Project Escalation', help='If any issue is escalated from the current Project, it will be listed under the project selected here.', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
551 'issue_count': fields.function(_issue_count, type='integer'),
554 def _check_escalation(self, cr, uid, ids, context=None):
555 project_obj = self.browse(cr, uid, ids[0], context=context)
556 if project_obj.project_escalation_id:
557 if project_obj.project_escalation_id.id == project_obj.id:
562 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
567 class account_analytic_account(osv.osv):
568 _inherit = 'account.analytic.account'
569 _description = 'Analytic Account'
572 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
575 def on_change_template(self, cr, uid, ids, template_id, context=None):
576 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
577 if template_id and 'value' in res:
578 template = self.browse(cr, uid, template_id, context=context)
579 res['value']['use_issues'] = template.use_issues
582 def _trigger_project_creation(self, cr, uid, vals, context=None):
583 if context is None: context = {}
584 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
585 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
587 account_analytic_account()
589 class project_project(osv.osv):
590 _inherit = 'project.project'
595 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: