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.stage_id and obj.stage_id.state == 'done',
56 'project_issue.mt_project_issue_started': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.state == 'open',
57 'project_issue.mt_project_issue_stage': lambda self, cr, uid, obj, ctx=None: obj.stage_id and obj.stage_id.state not in ['done', 'open'],
60 'project_issue.mt_project_issue_blocked': lambda self, cr, uid, obj, ctx=None: obj.kanban_state == 'blocked',
64 def _get_default_project_id(self, cr, uid, context=None):
65 """ Gives default project by checking if present in the context """
66 return self._resolve_project_id_from_context(cr, uid, context=context)
68 def _get_default_stage_id(self, cr, uid, context=None):
69 """ Gives default stage_id """
70 project_id = self._get_default_project_id(cr, uid, context=context)
71 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
73 def _resolve_project_id_from_context(self, cr, uid, context=None):
74 """ Returns ID of project based on the value of 'default_project_id'
75 context key, or None if it cannot be resolved to a single
80 if type(context.get('default_project_id')) in (int, long):
81 return context.get('default_project_id')
82 if isinstance(context.get('default_project_id'), basestring):
83 project_name = context['default_project_id']
84 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
85 if len(project_ids) == 1:
86 return int(project_ids[0][0])
89 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
90 access_rights_uid = access_rights_uid or uid
91 stage_obj = self.pool.get('project.task.type')
92 order = stage_obj._order
93 # lame hack to allow reverting search, should just work in the trivial case
94 if read_group_order == 'stage_id desc':
95 order = "%s desc" % order
96 # retrieve section_id from the context and write the domain
97 # - ('id', 'in', 'ids'): add columns that should be present
98 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
99 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
101 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
103 search_domain += ['|', ('project_ids', '=', project_id)]
104 search_domain += [('id', 'in', ids)]
106 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
107 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
108 # restore order of the search
109 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
112 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
113 fold[stage.id] = stage.fold or False
116 def _compute_day(self, cr, uid, ids, fields, args, context=None):
118 @param cr: the current row, from the database cursor,
119 @param uid: the current user’s ID for security checks,
120 @param ids: List of Openday’s IDs
121 @return: difference between current date and log date
122 @param context: A standard dictionary for contextual values
124 cal_obj = self.pool.get('resource.calendar')
125 res_obj = self.pool.get('resource.resource')
128 for issue in self.browse(cr, uid, ids, context=context):
135 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
136 if field in ['working_hours_open','day_open']:
138 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
139 ans = date_open - date_create
140 date_until = issue.date_open
141 #Calculating no. of working hours to open the issue
142 if issue.project_id.resource_calendar_id:
143 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
146 elif field in ['working_hours_close','day_close']:
147 if issue.date_closed:
148 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
149 date_until = issue.date_closed
150 ans = date_close - date_create
151 #Calculating no. of working hours to close the issue
152 if issue.project_id.resource_calendar_id:
153 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
156 elif field in ['days_since_creation']:
157 if issue.create_date:
158 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
159 res[issue.id][field] = days_since_creation.days
162 elif field in ['inactivity_days']:
163 res[issue.id][field] = 0
164 if issue.date_action_last:
165 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
166 res[issue.id][field] = inactive_days.days
171 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
172 if resource_ids and len(resource_ids):
173 resource_id = resource_ids[0]
174 duration = float(ans.days)
175 if issue.project_id and issue.project_id.resource_calendar_id:
176 duration = float(ans.days) * 24
178 new_dates = cal_obj.interval_min_get(cr, uid,
179 issue.project_id.resource_calendar_id.id,
181 duration, resource=resource_id)
183 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
184 for in_time, out_time in new_dates:
185 if in_time.date not in no_days:
186 no_days.append(in_time.date)
187 if out_time > date_until:
189 duration = len(no_days)
191 if field in ['working_hours_open','working_hours_close']:
192 res[issue.id][field] = hours
194 res[issue.id][field] = abs(float(duration))
198 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
199 task_pool = self.pool.get('project.task')
201 for issue in self.browse(cr, uid, ids, context=context):
204 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
205 res[issue.id] = {'progress' : progress}
208 def on_change_project(self, cr, uid, ids, project_id, context=None):
211 def _get_issue_task(self, cr, uid, ids, context=None):
213 issue_pool = self.pool.get('project.issue')
214 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
215 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
218 def _get_issue_work(self, cr, uid, ids, context=None):
220 issue_pool = self.pool.get('project.issue')
221 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
223 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
227 'id': fields.integer('ID', readonly=True),
228 'name': fields.char('Issue', size=128, required=True),
229 'active': fields.boolean('Active', required=False),
230 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
231 'write_date': fields.datetime('Update Date', readonly=True),
232 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
233 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
234 'date_deadline': fields.date('Deadline'),
235 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
236 select=True, help='Sales team to which Case belongs to.\
237 Define Responsible user and Email account for mail gateway.'),
238 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
239 'company_id': fields.many2one('res.company', 'Company'),
240 'description': fields.text('Private Note'),
241 'state': fields.related('stage_id', 'state', type="selection", store=True,
242 selection=_ISSUE_STATE, string="Status", readonly=True,
243 help='The status is set to \'Draft\', when a case is created.\
244 If the case is in progress the status is set to \'Open\'.\
245 When the case is over, the status is set to \'Done\'.\
246 If the case needs to be reviewed then the status is \
247 set to \'Pending\'.'),
248 'kanban_state': fields.selection([('normal', 'Normal'),('blocked', 'Blocked'),('done', 'Ready for next stage')], 'Kanban State',
250 help="A Issue's kanban state indicates special situations affecting it:\n"
251 " * Normal is the default situation\n"
252 " * Blocked indicates something is preventing the progress of this issue\n"
253 " * Ready for next stage indicates the issue is ready to be pulled to the next stage",
254 readonly=True, required=False),
255 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
256 '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"),
257 'date_open': fields.datetime('Opened', readonly=True,select=True),
258 # Project Issue fields
259 'date_closed': fields.datetime('Closed', readonly=True,select=True),
260 'date': fields.datetime('Date'),
261 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
262 'categ_ids': fields.many2many('project.category', string='Tags'),
263 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
264 'version_id': fields.many2one('project.issue.version', 'Version'),
265 'stage_id': fields.many2one ('project.task.type', 'Stage',
267 domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"),
268 'project_id':fields.many2one('project.project', 'Project', _track_visibility=1),
269 'duration': fields.float('Duration'),
270 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
271 'day_open': fields.function(_compute_day, string='Days to Open', \
272 multi='compute_day', type="float", store=True),
273 'day_close': fields.function(_compute_day, string='Days to Close', \
274 multi='compute_day', type="float", store=True),
275 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1, _track_visibility=1),
276 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
277 multi='compute_day', type="float", store=True),
278 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
279 multi='compute_day', type="float", store=True),
280 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
281 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
282 'color': fields.integer('Color Index'),
283 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
284 'date_action_last': fields.datetime('Last Action', readonly=1),
285 'date_action_next': fields.datetime('Next Action', readonly=1),
286 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
288 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
289 'project.task': (_get_issue_task, ['progress'], 10),
290 'project.task.work': (_get_issue_work, ['hours'], 10),
296 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
297 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
298 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
299 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
300 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
301 'priority': crm.AVAILABLE_PRIORITIES[2][0],
302 'kanban_state': 'normal',
306 'stage_id': _read_group_stage_ids
309 def set_priority(self, cr, uid, ids, priority, *args):
312 return self.write(cr, uid, ids, {'priority' : priority})
314 def set_high_priority(self, cr, uid, ids, *args):
315 """Set lead priority to high
317 return self.set_priority(cr, uid, ids, '1')
319 def set_normal_priority(self, cr, uid, ids, *args):
320 """Set lead priority to normal
322 return self.set_priority(cr, uid, ids, '3')
324 def convert_issue_task(self, cr, uid, ids, context=None):
328 case_obj = self.pool.get('project.issue')
329 data_obj = self.pool.get('ir.model.data')
330 task_obj = self.pool.get('project.task')
332 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
333 res = data_obj.read(cr, uid, result, ['res_id'])
334 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
335 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
337 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
339 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
341 for bug in case_obj.browse(cr, uid, ids, context=context):
342 new_task_id = task_obj.create(cr, uid, {
344 'partner_id': bug.partner_id.id,
345 'description':bug.description,
346 'date_deadline': bug.date,
347 'project_id': bug.project_id.id,
348 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
349 'priority': str(int(bug.priority) - 1),
350 'user_id': bug.user_id.id,
351 'planned_hours': 0.0,
354 'task_id': new_task_id,
355 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
357 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
358 case_obj.write(cr, uid, [bug.id], vals, context=context)
363 'view_mode': 'form,tree',
364 'res_model': 'project.task',
365 'res_id': int(new_task_id),
367 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
368 'type': 'ir.actions.act_window',
369 'search_view_id': res['res_id'],
373 def copy(self, cr, uid, id, default=None, context=None):
374 issue = self.read(cr, uid, id, ['name'], context=context)
377 default = default.copy()
378 default.update(name=_('%s (copy)') % (issue['name']))
379 return super(project_issue, self).copy(cr, uid, id, default=default,
382 def write(self, cr, uid, ids, vals, context=None):
383 #Update last action date every time the user change the stage, the state or send a new email
384 logged_fields = ['stage_id', 'state', 'message_ids']
385 if any([field in vals for field in logged_fields]):
386 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
388 res = super(project_issue, self).write(cr, uid, ids, vals, context)
390 # subscribe new project followers to the issue
391 if vals.get('project_id'):
392 self.message_subscribe_from_parent(cr, uid, ids, context=context)
395 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
398 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
399 return {'value': {'user_id': task.user_id.id, }}
401 def case_reset(self, cr, uid, ids, context=None):
402 """Resets case as draft
404 res = super(project_issue, self).case_reset(cr, uid, ids, context)
405 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
408 # -------------------------------------------------------
410 # -------------------------------------------------------
412 def set_kanban_state_blocked(self, cr, uid, ids, context=None):
413 return self.write(cr, uid, ids, {'kanban_state': 'blocked'}, context=context)
415 def set_kanban_state_normal(self, cr, uid, ids, context=None):
416 return self.write(cr, uid, ids, {'kanban_state': 'normal'}, context=context)
418 def set_kanban_state_done(self, cr, uid, ids, context=None):
419 return self.write(cr, uid, ids, {'kanban_state': 'done'}, context=context)
421 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
422 """ Override of the base.stage method
423 Parameter of the stage search taken from the issue:
424 - type: stage type must be the same or 'both'
425 - section_id: if set, stages must belong to this section or
428 if isinstance(cases, (int, long)):
429 cases = self.browse(cr, uid, cases, context=context)
430 # collect all section_ids
433 section_ids.append(section_id)
436 section_ids.append(task.project_id.id)
437 # OR all section_ids and OR with case_default
440 search_domain += [('|')] * (len(section_ids)-1)
441 for section_id in section_ids:
442 search_domain.append(('project_ids', '=', section_id))
443 search_domain += list(domain)
444 # perform search, return the first found
445 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
450 def case_cancel(self, cr, uid, ids, context=None):
452 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
455 def case_escalate(self, cr, uid, ids, context=None):
456 cases = self.browse(cr, uid, ids)
459 if case.project_id.project_escalation_id:
460 data['project_id'] = case.project_id.project_escalation_id.id
461 if case.project_id.project_escalation_id.user_id:
462 data['user_id'] = case.project_id.project_escalation_id.user_id.id
464 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
466 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
467 self.case_set(cr, uid, ids, 'draft', data, context=context)
470 # -------------------------------------------------------
472 # -------------------------------------------------------
474 def message_new(self, cr, uid, msg, custom_values=None, context=None):
475 """ Overrides mail_thread message_new that is called by the mailgateway
476 through message_process.
477 This override updates the document according to the email.
479 if custom_values is None: custom_values = {}
480 if context is None: context = {}
481 context['state_to'] = 'draft'
483 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
485 custom_values.update({
486 'name': msg.get('subject') or _("No Subject"),
488 'email_from': msg.get('from'),
489 'email_cc': msg.get('cc'),
492 if msg.get('priority'):
493 custom_values['priority'] = msg.get('priority')
495 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
498 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
499 """ Overrides mail_thread message_update that is called by the mailgateway
500 through message_process.
501 This method updates the document according to the email.
503 if isinstance(ids, (str, int, long)):
505 if update_vals is None: update_vals = {}
507 # Update doc values according to the message
508 if msg.get('priority'):
509 update_vals['priority'] = msg.get('priority')
510 # Parse 'body' to find values to update
512 'cost': 'planned_cost',
513 'revenue': 'planned_revenue',
514 'probability': 'probability',
516 for line in msg.get('body', '').split('\n'):
518 res = tools.misc.command_re.match(line)
519 if res and maps.get(res.group(1).lower(), False):
520 key = maps.get(res.group(1).lower())
521 update_vals[key] = res.group(2).lower()
523 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
525 # -------------------------------------------------------
526 # OpenChatter methods and notifications
527 # -------------------------------------------------------
529 def convert_to_task_send_note(self, cr, uid, ids, context=None):
530 message = _("Project issue <b>converted</b> to task.")
531 return self.message_post(cr, uid, ids, body=message, context=context)
535 class project(osv.osv):
536 _inherit = "project.project"
538 def _get_alias_models(self, cr, uid, context=None):
539 return [('project.task', "Tasks"), ("project.issue", "Issues")]
541 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
542 res = dict.fromkeys(ids, 0)
543 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
544 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
545 res[issue.project_id.id] += 1
549 '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)]}),
550 'issue_count': fields.function(_issue_count, type='integer'),
553 def _check_escalation(self, cr, uid, ids, context=None):
554 project_obj = self.browse(cr, uid, ids[0], context=context)
555 if project_obj.project_escalation_id:
556 if project_obj.project_escalation_id.id == project_obj.id:
561 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
566 class account_analytic_account(osv.osv):
567 _inherit = 'account.analytic.account'
568 _description = 'Analytic Account'
571 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
574 def on_change_template(self, cr, uid, ids, template_id, context=None):
575 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
576 if template_id and 'value' in res:
577 template = self.browse(cr, uid, template_id, context=context)
578 res['value']['use_issues'] = template.use_issues
581 def _trigger_project_creation(self, cr, uid, vals, context=None):
582 if context is None: context = {}
583 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
584 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
586 account_analytic_account()
588 class project_project(osv.osv):
589 _inherit = 'project.project'
594 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: