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_project_issue_closed': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
56 'project_issue.mt_project_issue_started': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'open',
59 'project_issue.mt_project_issue_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['done', 'open'],
62 'project_issue.mt_project_issue_blocked': lambda self, cr, uid, obj, ctx=None: obj['kanban_state'] == 'blocked',
66 def _get_default_project_id(self, cr, uid, context=None):
67 """ Gives default project by checking if present in the context """
68 return self._resolve_project_id_from_context(cr, uid, context=context)
70 def _get_default_stage_id(self, cr, uid, context=None):
71 """ Gives default stage_id """
72 project_id = self._get_default_project_id(cr, uid, context=context)
73 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
75 def _resolve_project_id_from_context(self, cr, uid, context=None):
76 """ Returns ID of project based on the value of 'default_project_id'
77 context key, or None if it cannot be resolved to a single
82 if type(context.get('default_project_id')) in (int, long):
83 return context.get('default_project_id')
84 if isinstance(context.get('default_project_id'), basestring):
85 project_name = context['default_project_id']
86 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
87 if len(project_ids) == 1:
88 return int(project_ids[0][0])
91 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
92 access_rights_uid = access_rights_uid or uid
93 stage_obj = self.pool.get('project.task.type')
94 order = stage_obj._order
95 # lame hack to allow reverting search, should just work in the trivial case
96 if read_group_order == 'stage_id desc':
97 order = "%s desc" % order
98 # retrieve section_id from the context and write the domain
99 # - ('id', 'in', 'ids'): add columns that should be present
100 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
101 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
103 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
105 search_domain += ['|', ('project_ids', '=', project_id)]
106 search_domain += [('id', 'in', ids)]
108 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
109 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
110 # restore order of the search
111 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
114 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
115 fold[stage.id] = stage.fold or False
118 def _compute_day(self, cr, uid, ids, fields, args, context=None):
120 @param cr: the current row, from the database cursor,
121 @param uid: the current user’s ID for security checks,
122 @param ids: List of Openday’s IDs
123 @return: difference between current date and log date
124 @param context: A standard dictionary for contextual values
126 cal_obj = self.pool.get('resource.calendar')
127 res_obj = self.pool.get('resource.resource')
130 for issue in self.browse(cr, uid, ids, context=context):
137 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
138 if field in ['working_hours_open','day_open']:
140 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
141 ans = date_open - date_create
142 date_until = issue.date_open
143 #Calculating no. of working hours to open the issue
144 if issue.project_id.resource_calendar_id:
145 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
148 elif field in ['working_hours_close','day_close']:
149 if issue.date_closed:
150 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
151 date_until = issue.date_closed
152 ans = date_close - date_create
153 #Calculating no. of working hours to close the issue
154 if issue.project_id.resource_calendar_id:
155 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
158 elif field in ['days_since_creation']:
159 if issue.create_date:
160 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
161 res[issue.id][field] = days_since_creation.days
164 elif field in ['inactivity_days']:
165 res[issue.id][field] = 0
166 if issue.date_action_last:
167 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
168 res[issue.id][field] = inactive_days.days
173 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
174 if resource_ids and len(resource_ids):
175 resource_id = resource_ids[0]
176 duration = float(ans.days)
177 if issue.project_id and issue.project_id.resource_calendar_id:
178 duration = float(ans.days) * 24
180 new_dates = cal_obj.interval_min_get(cr, uid,
181 issue.project_id.resource_calendar_id.id,
183 duration, resource=resource_id)
185 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
186 for in_time, out_time in new_dates:
187 if in_time.date not in no_days:
188 no_days.append(in_time.date)
189 if out_time > date_until:
191 duration = len(no_days)
193 if field in ['working_hours_open','working_hours_close']:
194 res[issue.id][field] = hours
196 res[issue.id][field] = abs(float(duration))
200 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
201 task_pool = self.pool.get('project.task')
203 for issue in self.browse(cr, uid, ids, context=context):
206 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
207 res[issue.id] = {'progress' : progress}
210 def on_change_project(self, cr, uid, ids, project_id, context=None):
213 def _get_issue_task(self, cr, uid, ids, context=None):
215 issue_pool = self.pool.get('project.issue')
216 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
217 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
220 def _get_issue_work(self, cr, uid, ids, context=None):
222 issue_pool = self.pool.get('project.issue')
223 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
225 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
229 'id': fields.integer('ID', readonly=True),
230 'name': fields.char('Issue', size=128, required=True),
231 'active': fields.boolean('Active', required=False),
232 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
233 'write_date': fields.datetime('Update Date', readonly=True),
234 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
235 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
236 'date_deadline': fields.date('Deadline'),
237 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
238 select=True, help='Sales team to which Case belongs to.\
239 Define Responsible user and Email account for mail gateway.'),
240 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
241 'company_id': fields.many2one('res.company', 'Company'),
242 'description': fields.text('Private Note'),
243 'state': fields.related('stage_id', 'state', type="selection", store=True,
244 selection=_ISSUE_STATE, string="Status", readonly=True,
245 help='The status is set to \'Draft\', when a case is created.\
246 If the case is in progress the status is set to \'Open\'.\
247 When the case is over, the status is set to \'Done\'.\
248 If the case needs to be reviewed then the status is \
249 set to \'Pending\'.'),
250 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
252 help="A Issue's kanban state indicates special situations affecting it:\n"
253 " * Normal is the default situation\n"
254 " * Blocked indicates something is preventing the progress of this issue\n"
255 " * Ready for next stage indicates the issue is ready to be pulled to the next stage",
256 readonly=True, required=False),
257 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
258 '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"),
259 'date_open': fields.datetime('Opened', readonly=True,select=True),
260 # Project Issue fields
261 'date_closed': fields.datetime('Closed', readonly=True,select=True),
262 'date': fields.datetime('Date'),
263 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
264 'categ_ids': fields.many2many('project.category', string='Tags'),
265 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
266 'version_id': fields.many2one('project.issue.version', 'Version'),
267 'stage_id': fields.many2one ('project.task.type', 'Stage',
269 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
270 'project_id':fields.many2one('project.project', 'Project', _track_visibility=1),
271 'duration': fields.float('Duration'),
272 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
273 'day_open': fields.function(_compute_day, string='Days to Open', \
274 multi='compute_day', type="float", store=True),
275 'day_close': fields.function(_compute_day, string='Days to Close', \
276 multi='compute_day', type="float", store=True),
277 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1, _track_visibility=1),
278 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
279 multi='compute_day', type="float", store=True),
280 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
281 multi='compute_day', type="float", store=True),
282 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
283 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
284 'color': fields.integer('Color Index'),
285 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
286 'date_action_last': fields.datetime('Last Action', readonly=1),
287 'date_action_next': fields.datetime('Next Action', readonly=1),
288 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
290 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
291 'project.task': (_get_issue_task, ['progress'], 10),
292 'project.task.work': (_get_issue_work, ['hours'], 10),
298 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
299 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
300 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
301 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
302 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
303 'priority': crm.AVAILABLE_PRIORITIES[2][0],
304 'kanban_state': 'normal',
308 'stage_id': _read_group_stage_ids
311 def set_priority(self, cr, uid, ids, priority, *args):
314 return self.write(cr, uid, ids, {'priority' : priority})
316 def set_high_priority(self, cr, uid, ids, *args):
317 """Set lead priority to high
319 return self.set_priority(cr, uid, ids, '1')
321 def set_normal_priority(self, cr, uid, ids, *args):
322 """Set lead priority to normal
324 return self.set_priority(cr, uid, ids, '3')
326 def convert_issue_task(self, cr, uid, ids, context=None):
330 case_obj = self.pool.get('project.issue')
331 data_obj = self.pool.get('ir.model.data')
332 task_obj = self.pool.get('project.task')
334 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
335 res = data_obj.read(cr, uid, result, ['res_id'])
336 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
337 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
339 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
341 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
343 for bug in case_obj.browse(cr, uid, ids, context=context):
344 new_task_id = task_obj.create(cr, uid, {
346 'partner_id': bug.partner_id.id,
347 'description':bug.description,
348 'date_deadline': bug.date,
349 'project_id': bug.project_id.id,
350 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
351 'priority': str(int(bug.priority) - 1),
352 'user_id': bug.user_id.id,
353 'planned_hours': 0.0,
356 'task_id': new_task_id,
357 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
359 message = _("Project issue <b>converted</b> to task.")
360 self.message_post(cr, uid, [bug.id], body=message, context=context)
361 case_obj.write(cr, uid, [bug.id], vals, context=context)
366 'view_mode': 'form,tree',
367 'res_model': 'project.task',
368 'res_id': int(new_task_id),
370 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
371 'type': 'ir.actions.act_window',
372 'search_view_id': res['res_id'],
376 def copy(self, cr, uid, id, default=None, context=None):
377 issue = self.read(cr, uid, id, ['name'], context=context)
380 default = default.copy()
381 default.update(name=_('%s (copy)') % (issue['name']))
382 return super(project_issue, self).copy(cr, uid, id, default=default,
385 def write(self, cr, uid, ids, vals, context=None):
386 #Update last action date every time the user change the stage, the state or send a new email
387 logged_fields = ['stage_id', 'state', 'message_ids']
388 if any([field in vals for field in logged_fields]):
389 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
391 res = super(project_issue, self).write(cr, uid, ids, vals, context)
393 # subscribe new project followers to the issue
394 if vals.get('project_id'):
395 self.message_subscribe_from_parent(cr, uid, ids, context=context)
398 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
401 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
402 return {'value': {'user_id': task.user_id.id, }}
404 def case_reset(self, cr, uid, ids, context=None):
405 """Resets case as draft
407 res = super(project_issue, self).case_reset(cr, uid, ids, context)
408 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
411 # -------------------------------------------------------
413 # -------------------------------------------------------
415 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
416 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
418 def set_kanban_state_normal(self, cr, uid, ids, context=None):
419 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
421 def set_kanban_state_done(self, cr, uid, ids, context=None):
422 return self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
424 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
425 """ Override of the base.stage method
426 Parameter of the stage search taken from the issue:
427 - type: stage type must be the same or 'both'
428 - section_id: if set, stages must belong to this section or
431 if isinstance(cases, (int, long)):
432 cases = self.browse(cr, uid, cases, context=context)
433 # collect all section_ids
436 section_ids.append(section_id)
439 section_ids.append(task.project_id.id)
440 # OR all section_ids and OR with case_default
443 search_domain += [('|')] * (len(section_ids)-1)
444 for section_id in section_ids:
445 search_domain.append(('project_ids', '=', section_id))
446 search_domain += list(domain)
447 # perform search, return the first found
448 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
453 def case_cancel(self, cr, uid, ids, context=None):
455 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
458 def case_escalate(self, cr, uid, ids, context=None):
459 cases = self.browse(cr, uid, ids)
462 if case.project_id.project_escalation_id:
463 data['project_id'] = case.project_id.project_escalation_id.id
464 if case.project_id.project_escalation_id.user_id:
465 data['user_id'] = case.project_id.project_escalation_id.user_id.id
467 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
469 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
470 self.case_set(cr, uid, ids, 'draft', data, context=context)
473 # -------------------------------------------------------
475 # -------------------------------------------------------
477 def message_new(self, cr, uid, msg, custom_values=None, context=None):
478 """ Overrides mail_thread message_new that is called by the mailgateway
479 through message_process.
480 This override updates the document according to the email.
482 if custom_values is None: custom_values = {}
483 if context is None: context = {}
484 context['state_to'] = 'draft'
486 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
488 custom_values.update({
489 'name': msg.get('subject') or _("No Subject"),
491 'email_from': msg.get('from'),
492 'email_cc': msg.get('cc'),
495 if msg.get('priority'):
496 custom_values['priority'] = msg.get('priority')
498 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
501 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
502 """ Overrides mail_thread message_update that is called by the mailgateway
503 through message_process.
504 This method updates the document according to the email.
506 if isinstance(ids, (str, int, long)):
508 if update_vals is None: update_vals = {}
510 # Update doc values according to the message
511 if msg.get('priority'):
512 update_vals['priority'] = msg.get('priority')
513 # Parse 'body' to find values to update
515 'cost': 'planned_cost',
516 'revenue': 'planned_revenue',
517 'probability': 'probability',
519 for line in msg.get('body', '').split('\n'):
521 res = tools.misc.command_re.match(line)
522 if res and maps.get(res.group(1).lower(), False):
523 key = maps.get(res.group(1).lower())
524 update_vals[key] = res.group(2).lower()
526 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
529 class project(osv.osv):
530 _inherit = "project.project"
532 def _get_alias_models(self, cr, uid, context=None):
533 return [('project.task', "Tasks"), ("project.issue", "Issues")]
535 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
536 res = dict.fromkeys(ids, 0)
537 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
538 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
539 res[issue.project_id.id] += 1
543 'project_escalation_id' : fields.many2one('project.project','Project Escalation', help='If any issue is escalated from the current Project, it will be listed under the project selected here.', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
544 'issue_count': fields.function(_issue_count, type='integer'),
547 def _check_escalation(self, cr, uid, ids, context=None):
548 project_obj = self.browse(cr, uid, ids[0], context=context)
549 if project_obj.project_escalation_id:
550 if project_obj.project_escalation_id.id == project_obj.id:
555 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
560 class account_analytic_account(osv.osv):
561 _inherit = 'account.analytic.account'
562 _description = 'Analytic Account'
565 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
568 def on_change_template(self, cr, uid, ids, template_id, context=None):
569 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
570 if template_id and 'value' in res:
571 template = self.browse(cr, uid, template_id, context=context)
572 res['value']['use_issues'] = template.use_issues
575 def _trigger_project_creation(self, cr, uid, vals, context=None):
576 if context is None: context = {}
577 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
578 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
580 account_analytic_account()
582 class project_project(osv.osv):
583 _inherit = 'project.project'
588 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: