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 """ TDE note: not the best way to do this, we could override _get_followers
364 of issue, and perform a better mapping of subtypes than a mapping
366 However we will keep this implementation, maybe to be refactored
367 in 7.1 of future versions. """
368 # task followers are project followers, with matching subtypes
369 task_record = self.browse(cr, uid, task_id, context=context)
370 subtype_obj = self.pool.get('mail.message.subtype')
371 follower_obj = self.pool.get('mail.followers')
372 if task_record.project_id:
374 task_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('res_model', '=', self._name)], context=context)
375 task_subtypes = subtype_obj.browse(cr, uid, task_subtype_ids, context=context)
376 # fetch subscriptions
377 follower_ids = follower_obj.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', task_record.project_id.id)], context=context)
379 for follower in follower_obj.browse(cr, uid, follower_ids, context=context):
380 if not follower.subtype_ids:
382 project_subtype_names = [project_subtype.name for project_subtype in follower.subtype_ids]
383 task_subtype_ids = [task_subtype.id for task_subtype in task_subtypes if task_subtype.name in project_subtype_names]
384 self.message_subscribe(cr, uid, [task_id], [follower.partner_id.id],
385 subtype_ids=task_subtype_ids, context=context)
387 def write(self, cr, uid, ids, vals, context=None):
388 #Update last action date every time the user change the stage, the state or send a new email
389 logged_fields = ['stage_id', 'state', 'message_ids']
390 if any([field in vals for field in logged_fields]):
391 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
393 # subscribe new project followers to the issue
394 if vals.get('project_id'):
396 self._subscribe_project_followers_to_issue(cr, uid, id, context=context)
398 return super(project_issue, self).write(cr, uid, ids, vals, context)
400 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
403 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
404 return {'value': {'user_id': task.user_id.id, }}
406 def case_reset(self, cr, uid, ids, context=None):
407 """Resets case as draft
409 res = super(project_issue, self).case_reset(cr, uid, ids, context)
410 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
413 def create(self, cr, uid, vals, context=None):
414 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
416 # subscribe project follower to the issue
417 self._subscribe_project_followers_to_issue(cr, uid, obj_id, context=context)
418 self.create_send_note(cr, uid, [obj_id], context=context)
422 # -------------------------------------------------------
424 # -------------------------------------------------------
426 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
427 """ Override of the base.stage method
428 Parameter of the stage search taken from the issue:
429 - type: stage type must be the same or 'both'
430 - section_id: if set, stages must belong to this section or
433 if isinstance(cases, (int, long)):
434 cases = self.browse(cr, uid, cases, context=context)
435 # collect all section_ids
438 section_ids.append(section_id)
441 section_ids.append(task.project_id.id)
442 # OR all section_ids and OR with case_default
445 search_domain += [('|')] * len(section_ids)
446 for section_id in section_ids:
447 search_domain.append(('project_ids', '=', section_id))
448 search_domain.append(('case_default', '=', True))
449 # AND with the domain in parameter
450 search_domain += list(domain)
451 # perform search, return the first found
452 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
457 def case_cancel(self, cr, uid, ids, context=None):
459 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
462 def case_escalate(self, cr, uid, ids, context=None):
463 cases = self.browse(cr, uid, ids)
466 if case.project_id.project_escalation_id:
467 data['project_id'] = case.project_id.project_escalation_id.id
468 if case.project_id.project_escalation_id.user_id:
469 data['user_id'] = case.project_id.project_escalation_id.user_id.id
471 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
473 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
474 self.case_set(cr, uid, ids, 'draft', data, context=context)
475 self.case_escalate_send_note(cr, uid, [case.id], context=context)
478 # -------------------------------------------------------
480 # -------------------------------------------------------
482 def message_new(self, cr, uid, msg, custom_values=None, context=None):
483 """ Overrides mail_thread message_new that is called by the mailgateway
484 through message_process.
485 This override updates the document according to the email.
487 if custom_values is None: custom_values = {}
488 if context is None: context = {}
489 context['state_to'] = 'draft'
491 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
493 custom_values.update({
494 'name': msg.get('subject') or _("No Subject"),
496 'email_from': msg.get('from'),
497 'email_cc': msg.get('cc'),
500 if msg.get('priority'):
501 custom_values['priority'] = msg.get('priority')
503 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
504 # self.convert_to_bug(cr, uid, [res_id], context=context)
507 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
508 """ Overrides mail_thread message_update that is called by the mailgateway
509 through message_process.
510 This method updates the document according to the email.
512 if isinstance(ids, (str, int, long)):
514 if update_vals is None: update_vals = {}
516 # Update doc values according to the message
517 if msg.get('priority'):
518 update_vals['priority'] = msg.get('priority')
519 # Parse 'body' to find values to update
521 'cost': 'planned_cost',
522 'revenue': 'planned_revenue',
523 'probability': 'probability',
525 for line in msg.get('body', '').split('\n'):
527 res = tools.misc.command_re.match(line)
528 if res and maps.get(res.group(1).lower(), False):
529 key = maps.get(res.group(1).lower())
530 update_vals[key] = res.group(2).lower()
532 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
534 # -------------------------------------------------------
535 # OpenChatter methods and notifications
536 # -------------------------------------------------------
538 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
539 """ Override of default prefix for notifications. """
540 return 'Project issue'
542 def convert_to_task_send_note(self, cr, uid, ids, context=None):
543 message = _("Project issue <b>converted</b> to task.")
544 return self.message_post(cr, uid, ids, body=message, context=context)
546 def create_send_note(self, cr, uid, ids, context=None):
547 message = _("Project issue <b>created</b>.")
548 return self.message_post(cr, uid, ids, body=message, subtype="mt_issue_new", context=context)
550 def case_escalate_send_note(self, cr, uid, ids, context=None):
551 for obj in self.browse(cr, uid, ids, context=context):
553 message = _("<b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
554 obj.message_post(body=message)
556 message = _("<b>escalated</b>.")
557 obj.message_post(body=message)
562 class project(osv.osv):
563 _inherit = "project.project"
565 def _get_alias_models(self, cr, uid, context=None):
566 return [('project.task', "Tasks"), ("project.issue", "Issues")]
568 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
569 res = dict.fromkeys(ids, 0)
570 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
571 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
572 res[issue.project_id.id] += 1
576 '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)]}),
577 'issue_count': fields.function(_issue_count, type='integer'),
580 def _check_escalation(self, cr, uid, ids, context=None):
581 project_obj = self.browse(cr, uid, ids[0], context=context)
582 if project_obj.project_escalation_id:
583 if project_obj.project_escalation_id.id == project_obj.id:
588 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
593 class account_analytic_account(osv.osv):
594 _inherit = 'account.analytic.account'
595 _description = 'Analytic Account'
598 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
601 def on_change_template(self, cr, uid, ids, template_id, context=None):
602 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
603 if template_id and 'value' in res:
604 template = self.browse(cr, uid, template_id, context=context)
605 res['value']['use_issues'] = template.use_issues
608 def _trigger_project_creation(self, cr, uid, vals, context=None):
609 if context is None: context = {}
610 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
611 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
613 account_analytic_account()
615 class project_project(osv.osv):
616 _inherit = 'project.project'
621 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: