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 base_status.base_stage import base_stage
24 from datetime import datetime
25 from osv import fields,osv
26 from tools.translate import _
30 from 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')]
46 class project_issue(base_stage, osv.osv):
47 _name = "project.issue"
48 _description = "Project Issue"
49 _order = "priority, create_date desc"
50 _inherit = ['mail.thread', 'ir.needaction_mixin']
52 def _get_default_project_id(self, cr, uid, context=None):
53 """ Gives default project by checking if present in the context """
54 return self._resolve_project_id_from_context(cr, uid, context=context)
56 def _get_default_stage_id(self, cr, uid, context=None):
57 """ Gives default stage_id """
58 project_id = self._get_default_project_id(cr, uid, context=context)
59 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
61 def _resolve_project_id_from_context(self, cr, uid, context=None):
62 """ Returns ID of project based on the value of 'default_project_id'
63 context key, or None if it cannot be resolved to a single
68 if type(context.get('default_project_id')) in (int, long):
69 return context.get('default_project_id')
70 if isinstance(context.get('default_project_id'), basestring):
71 project_name = context['default_project_id']
72 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
73 if len(project_ids) == 1:
74 return int(project_ids[0][0])
77 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
78 access_rights_uid = access_rights_uid or uid
79 stage_obj = self.pool.get('project.task.type')
80 order = stage_obj._order
81 # lame hack to allow reverting search, should just work in the trivial case
82 if read_group_order == 'stage_id desc':
83 order = "%s desc" % order
84 # retrieve section_id from the context and write the domain
85 # - ('id', 'in', 'ids'): add columns that should be present
86 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
87 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
89 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
91 search_domain += ['|', ('project_ids', '=', project_id)]
92 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
94 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
95 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
96 # restore order of the search
97 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
100 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
101 fold[stage.id] = stage.fold or False
104 def _compute_day(self, cr, uid, ids, fields, args, context=None):
106 @param cr: the current row, from the database cursor,
107 @param uid: the current user’s ID for security checks,
108 @param ids: List of Openday’s IDs
109 @return: difference between current date and log date
110 @param context: A standard dictionary for contextual values
112 cal_obj = self.pool.get('resource.calendar')
113 res_obj = self.pool.get('resource.resource')
116 for issue in self.browse(cr, uid, ids, context=context):
123 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
124 if field in ['working_hours_open','day_open']:
126 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
127 ans = date_open - date_create
128 date_until = issue.date_open
129 #Calculating no. of working hours to open the issue
130 if issue.project_id.resource_calendar_id:
131 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
134 elif field in ['working_hours_close','day_close']:
135 if issue.date_closed:
136 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
137 date_until = issue.date_closed
138 ans = date_close - date_create
139 #Calculating no. of working hours to close the issue
140 if issue.project_id.resource_calendar_id:
141 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
144 elif field in ['days_since_creation']:
145 if issue.create_date:
146 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
147 res[issue.id][field] = days_since_creation.days
150 elif field in ['inactivity_days']:
151 res[issue.id][field] = 0
152 if issue.date_action_last:
153 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
154 res[issue.id][field] = inactive_days.days
159 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
160 if resource_ids and len(resource_ids):
161 resource_id = resource_ids[0]
162 duration = float(ans.days)
163 if issue.project_id and issue.project_id.resource_calendar_id:
164 duration = float(ans.days) * 24
166 new_dates = cal_obj.interval_min_get(cr, uid,
167 issue.project_id.resource_calendar_id.id,
169 duration, resource=resource_id)
171 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
172 for in_time, out_time in new_dates:
173 if in_time.date not in no_days:
174 no_days.append(in_time.date)
175 if out_time > date_until:
177 duration = len(no_days)
179 if field in ['working_hours_open','working_hours_close']:
180 res[issue.id][field] = hours
182 res[issue.id][field] = abs(float(duration))
186 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
187 task_pool = self.pool.get('project.task')
189 for issue in self.browse(cr, uid, ids, context=context):
192 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
193 res[issue.id] = {'progress' : progress}
196 def on_change_project(self, cr, uid, ids, project_id, context=None):
199 def _get_issue_task(self, cr, uid, ids, context=None):
201 issue_pool = self.pool.get('project.issue')
202 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
203 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
206 def _get_issue_work(self, cr, uid, ids, context=None):
208 issue_pool = self.pool.get('project.issue')
209 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
211 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
215 'id': fields.integer('ID', readonly=True),
216 'name': fields.char('Issue', size=128, required=True),
217 'active': fields.boolean('Active', required=False),
218 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
219 'write_date': fields.datetime('Update Date', readonly=True),
220 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
221 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
222 'date_deadline': fields.date('Deadline'),
223 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
224 select=True, help='Sales team to which Case belongs to.\
225 Define Responsible user and Email account for mail gateway.'),
226 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
227 'company_id': fields.many2one('res.company', 'Company'),
228 'description': fields.text('Description'),
229 'state': fields.related('stage_id', 'state', type="selection", store=True,
230 selection=_ISSUE_STATE, string="Status", readonly=True,
231 help='The status is set to \'Draft\', when a case is created.\
232 If the case is in progress the status is set to \'Open\'.\
233 When the case is over, the status is set to \'Done\'.\
234 If the case needs to be reviewed then the status is \
235 set to \'Pending\'.'),
236 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
237 '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"),
238 'date_open': fields.datetime('Opened', readonly=True,select=True),
239 # Project Issue fields
240 'date_closed': fields.datetime('Closed', readonly=True,select=True),
241 'date': fields.datetime('Date'),
242 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
243 'categ_ids': fields.many2many('project.category', string='Tags'),
244 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
245 'version_id': fields.many2one('project.issue.version', 'Version'),
246 'stage_id': fields.many2one ('project.task.type', 'Stage',
247 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
248 'project_id':fields.many2one('project.project', 'Project'),
249 'duration': fields.float('Duration'),
250 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
251 'day_open': fields.function(_compute_day, string='Days to Open', \
252 multi='compute_day', type="float", store=True),
253 'day_close': fields.function(_compute_day, string='Days to Close', \
254 multi='compute_day', type="float", store=True),
255 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1),
256 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
257 multi='compute_day', type="float", store=True),
258 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
259 multi='compute_day', type="float", store=True),
260 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
261 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
262 'color': fields.integer('Color Index'),
263 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
264 'date_action_last': fields.datetime('Last Action', readonly=1),
265 'date_action_next': fields.datetime('Next Action', readonly=1),
266 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
268 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
269 'project.task': (_get_issue_task, ['progress'], 10),
270 'project.task.work': (_get_issue_work, ['hours'], 10),
276 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
277 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
278 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
279 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
280 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
281 'priority': crm.AVAILABLE_PRIORITIES[2][0],
285 'stage_id': _read_group_stage_ids
288 def set_priority(self, cr, uid, ids, priority, *args):
291 return self.write(cr, uid, ids, {'priority' : priority})
293 def set_high_priority(self, cr, uid, ids, *args):
294 """Set lead priority to high
296 return self.set_priority(cr, uid, ids, '1')
298 def set_normal_priority(self, cr, uid, ids, *args):
299 """Set lead priority to normal
301 return self.set_priority(cr, uid, ids, '3')
303 def convert_issue_task(self, cr, uid, ids, context=None):
307 case_obj = self.pool.get('project.issue')
308 data_obj = self.pool.get('ir.model.data')
309 task_obj = self.pool.get('project.task')
311 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
312 res = data_obj.read(cr, uid, result, ['res_id'])
313 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
314 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
316 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
318 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
320 for bug in case_obj.browse(cr, uid, ids, context=context):
321 new_task_id = task_obj.create(cr, uid, {
323 'partner_id': bug.partner_id.id,
324 'description':bug.description,
325 'date_deadline': bug.date,
326 'project_id': bug.project_id.id,
327 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
328 'priority': str(int(bug.priority) - 1),
329 'user_id': bug.user_id.id,
330 'planned_hours': 0.0,
333 'task_id': new_task_id,
334 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
336 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
337 case_obj.write(cr, uid, [bug.id], vals, context=context)
338 self.case_pending_send_note(cr, uid, [bug.id], context=context)
343 'view_mode': 'form,tree',
344 'res_model': 'project.task',
345 'res_id': int(new_task_id),
347 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
348 'type': 'ir.actions.act_window',
349 'search_view_id': res['res_id'],
353 def copy(self, cr, uid, id, default=None, context=None):
354 issue = self.read(cr, uid, id, ['name'], context=context)
357 default = default.copy()
358 default.update(name=_('%s (copy)') % (issue['name']))
359 return super(project_issue, self).copy(cr, uid, id, default=default,
362 def _subscribe_project_followers_to_issue(self, cr, uid, task_id, context=None):
363 # task followers are project followers, with matching subtypes
364 task_record = self.browse(cr, uid, task_id, context=context)
365 subtype_obj = self.pool.get('mail.message.subtype')
366 follower_obj = self.pool.get('mail.followers')
367 if task_record.project_id:
369 task_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('res_model', '=', self._name)], context=context)
370 task_subtypes = subtype_obj.browse(cr, uid, task_subtype_ids, context=context)
371 # fetch subscriptions
372 follower_ids = follower_obj.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', task_record.project_id.id)], context=context)
374 for follower in follower_obj.browse(cr, uid, follower_ids, context=context):
375 if not follower.subtype_ids:
377 project_subtype_names = [project_subtype.name for project_subtype in follower.subtype_ids]
378 task_subtype_ids = [task_subtype.id for task_subtype in task_subtypes if task_subtype.name in project_subtype_names]
379 self.message_subscribe(cr, uid, [task_id], [follower.partner_id.id],
380 subtype_ids=task_subtype_ids, context=context)
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 # subscribe new project followers to the issue
389 if vals.get('project_id'):
391 self._subscribe_project_followers_to_issue(cr, uid, id, context=context)
393 return super(project_issue, self).write(cr, uid, ids, vals, 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 def create(self, cr, uid, vals, context=None):
409 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
411 # subscribe project follower to the issue
412 self._subscribe_project_followers_to_issue(cr, uid, obj_id, context=context)
413 self.create_send_note(cr, uid, [obj_id], context=context)
417 # -------------------------------------------------------
419 # -------------------------------------------------------
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)
441 for section_id in section_ids:
442 search_domain.append(('project_ids', '=', section_id))
443 search_domain.append(('case_default', '=', True))
444 # AND with the domain in parameter
445 search_domain += list(domain)
446 # perform search, return the first found
447 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
452 def case_cancel(self, cr, uid, ids, context=None):
454 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
455 self.case_cancel_send_note(cr, uid, ids, 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)
471 self.case_escalate_send_note(cr, uid, [case.id], context=context)
474 # -------------------------------------------------------
476 # -------------------------------------------------------
478 def message_new(self, cr, uid, msg, custom_values=None, context=None):
479 """ Overrides mail_thread message_new that is called by the mailgateway
480 through message_process.
481 This override updates the document according to the email.
483 if custom_values is None: custom_values = {}
484 if context is None: context = {}
485 context['state_to'] = 'draft'
487 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
489 custom_values.update({
490 'name': msg.get('subject') or _("No Subject"),
492 'email_from': msg.get('from'),
493 'email_cc': msg.get('cc'),
496 if msg.get('priority'):
497 custom_values['priority'] = msg.get('priority')
499 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
500 # self.convert_to_bug(cr, uid, [res_id], context=context)
503 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
504 """ Overrides mail_thread message_update that is called by the mailgateway
505 through message_process.
506 This method updates the document according to the email.
508 if isinstance(ids, (str, int, long)):
510 if update_vals is None: update_vals = {}
512 # Update doc values according to the message
513 if msg.get('priority'):
514 update_vals['priority'] = msg.get('priority')
515 # Parse 'body' to find values to update
517 'cost': 'planned_cost',
518 'revenue': 'planned_revenue',
519 'probability': 'probability',
521 for line in msg.get('body', '').split('\n'):
523 res = tools.misc.command_re.match(line)
524 if res and maps.get(res.group(1).lower(), False):
525 key = maps.get(res.group(1).lower())
526 update_vals[key] = res.group(2).lower()
528 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
530 # -------------------------------------------------------
531 # OpenChatter methods and notifications
532 # -------------------------------------------------------
534 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
535 """ Override of the (void) default notification method. """
536 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
537 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), subtype="mt_issue_new", context=context)
539 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
540 """ Override of default prefix for notifications. """
541 return 'Project issue'
543 def convert_to_task_send_note(self, cr, uid, ids, context=None):
544 message = _("Project issue <b>converted</b> to task.")
545 return self.message_post(cr, uid, ids, body=message, context=context)
547 def create_send_note(self, cr, uid, ids, context=None):
548 message = _("Project issue <b>created</b>.")
549 return self.message_post(cr, uid, ids, body=message, subtype="mt_issue_new", context=context)
551 def case_escalate_send_note(self, cr, uid, ids, context=None):
552 for obj in self.browse(cr, uid, ids, context=context):
554 message = _("<b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
555 obj.message_post(body=message)
557 message = _("<b>escalated</b>.")
558 obj.message_post(body=message)
563 class project(osv.osv):
564 _inherit = "project.project"
566 def _get_alias_models(self, cr, uid, context=None):
567 return [('project.task', "Tasks"), ("project.issue", "Issues")]
569 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
570 res = dict.fromkeys(ids, 0)
571 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
572 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
573 res[issue.project_id.id] += 1
577 '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)]}),
578 'issue_count': fields.function(_issue_count, type='integer'),
581 def _check_escalation(self, cr, uid, ids, context=None):
582 project_obj = self.browse(cr, uid, ids[0], context=context)
583 if project_obj.project_escalation_id:
584 if project_obj.project_escalation_id.id == project_obj.id:
589 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
594 class account_analytic_account(osv.osv):
595 _inherit = 'account.analytic.account'
596 _description = 'Analytic Account'
599 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
602 def on_change_template(self, cr, uid, ids, template_id, context=None):
603 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
604 if template_id and 'value' in res:
605 template = self.browse(cr, uid, template_id, context=context)
606 res['value']['use_issues'] = template.use_issues
609 def _trigger_project_creation(self, cr, uid, vals, context=None):
610 if context is None: context = {}
611 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
612 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
614 account_analytic_account()
616 class project_project(osv.osv):
617 _inherit = 'project.project'
622 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: