diff --git a/addons/account/account_invoice.py b/addons/account/account_invoice.py index cdd340ef551..1404c2f0633 100644 --- a/addons/account/account_invoice.py +++ b/addons/account/account_invoice.py @@ -210,7 +210,7 @@ class account_invoice(osv.osv): ('open','Open'), ('paid','Paid'), ('cancel','Cancelled'), - ],'Status', select=True, readonly=True, + ],'Status', select=True, readonly=True, tracked=True, help=' * The \'Draft\' status is used when a user is encoding a new and unconfirmed Invoice. \ \n* The \'Pro-forma\' when invoice is in Pro-forma status,invoice does not have an invoice number. \ \n* The \'Open\' status is used when user create invoice,a invoice number is generated.Its in open status till user does not pay invoice. \ @@ -278,7 +278,7 @@ class account_invoice(osv.osv): help="Remaining amount due."), 'payment_ids': fields.function(_compute_lines, relation='account.move.line', type="many2many", string='Payments'), 'move_name': fields.char('Journal Entry', size=64, readonly=True, states={'draft':[('readonly',False)]}), - 'user_id': fields.many2one('res.users', 'Salesperson', readonly=True, states={'draft':[('readonly',False)]}), + 'user_id': fields.many2one('res.users', 'Salesperson', readonly=True, tracked=True, states={'draft':[('readonly',False)]}), 'fiscal_position': fields.many2one('account.fiscal.position', 'Fiscal Position', readonly=True, states={'draft':[('readonly',False)]}) } _defaults = { diff --git a/addons/crm/crm_lead.py b/addons/crm/crm_lead.py index 8303f858b7b..ea7cc2ccc8a 100644 --- a/addons/crm/crm_lead.py +++ b/addons/crm/crm_lead.py @@ -224,7 +224,7 @@ class crm_lead(base_stage, format_address, osv.osv): 'date_action_next': fields.datetime('Next Action', readonly=1), 'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1), 'section_id': fields.many2one('crm.case.section', 'Sales Team', \ - select=True, help='When sending mails, the default email address is taken from the sales team.'), + select=True, tracked=True, help='When sending mails, the default email address is taken from the sales team.'), 'create_date': fields.datetime('Creation Date' , readonly=True), 'email_cc': fields.text('Global CC', size=252 , 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"), 'description': fields.text('Notes'), @@ -240,9 +240,9 @@ class crm_lead(base_stage, format_address, osv.osv): 'type':fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', help="Type is used to separate Leads and Opportunities"), 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True), 'date_closed': fields.datetime('Closed', readonly=True), - 'stage_id': fields.many2one('crm.case.stage', 'Stage', + 'stage_id': fields.many2one('crm.case.stage', 'Stage',tracked=True, domain="['&', ('fold', '=', False), '&', '|', ('section_ids', '=', section_id), ('case_default', '=', True), '|', ('type', '=', type), ('type', '=', 'both')]"), - 'user_id': fields.many2one('res.users', 'Salesperson'), + 'user_id': fields.many2one('res.users', 'Salesperson', tracked=True), 'referred': fields.char('Referred By', size=64), 'date_open': fields.datetime('Opened', readonly=True), 'day_open': fields.function(_compute_day, string='Days to Open', \ @@ -255,7 +255,7 @@ class crm_lead(base_stage, format_address, osv.osv): # Only used for type opportunity 'probability': fields.float('Success Rate (%)',group_operator="avg"), - 'planned_revenue': fields.float('Expected Revenue'), + 'planned_revenue': fields.float('Expected Revenue', tracked=True), 'ref': fields.reference('Reference', selection=crm._links_get, size=128), 'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128), 'phone': fields.char("Phone", size=64), @@ -972,11 +972,6 @@ class crm_lead(base_stage, format_address, osv.osv): # OpenChatter methods and notifications # ---------------------------------------- - def stage_set_send_note(self, cr, uid, ids, stage_id, context=None): - """ Override of the (void) default notification method. """ - stage_name = self.pool.get('crm.case.stage').name_get(cr, uid, [stage_id], context=context)[0][1] - return self.message_post(cr, uid, ids, body=_("Stage changed to %s.") % (stage_name), subtype="mt_crm_stage", context=context) - def case_get_note_msg_prefix(self, cr, uid, lead, context=None): if isinstance(lead, (int, long)): lead = self.browse(cr, uid, [lead], context=context)[0] @@ -988,14 +983,6 @@ class crm_lead(base_stage, format_address, osv.osv): self.message_post(cr, uid, [id], body=message, context=context) return True - def case_mark_lost_send_note(self, cr, uid, ids, context=None): - message = _("Opportunity has been lost.") - return self.message_post(cr, uid, ids, body=message, subtype="mt_crm_lost", context=context) - - def case_mark_won_send_note(self, cr, uid, ids, context=None): - message = _("Opportunity has been won.") - return self.message_post(cr, uid, ids, body=message, subtype="mt_crm_won", context=context) - def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None): phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0] if action == 'log': prefix = 'Logged' diff --git a/addons/crm/crm_lead_data.xml b/addons/crm/crm_lead_data.xml index b7541b6daf0..0f4c1e383b4 100644 --- a/addons/crm/crm_lead_data.xml +++ b/addons/crm/crm_lead_data.xml @@ -39,6 +39,7 @@ Negotiation open + mail.mt_crm_stage opportunity @@ -47,6 +48,7 @@ Won done + mail.mt_crm_won @@ -57,6 +59,7 @@ cancel + mail.mt_crm_stage lead @@ -66,6 +69,7 @@ cancel + mail.mt_crm_lost opportunity diff --git a/addons/crm/res_partner.py b/addons/crm/res_partner.py index bb0ad606a25..a1f84759493 100644 --- a/addons/crm/res_partner.py +++ b/addons/crm/res_partner.py @@ -39,7 +39,7 @@ class res_partner(osv.osv): return res _columns = { - 'section_id': fields.many2one('crm.case.section', 'Sales Team'), + 'section_id': fields.many2one('crm.case.section', 'Sales Team', tracked=True), 'opportunity_ids': fields.one2many('crm.lead', 'partner_id',\ 'Leads and Opportunities', domain=[('state','in', ('draft','open','pending'))]), 'meeting_ids': fields.many2many('crm.meeting', 'crm_meeting_partner_rel','partner_id', 'meeting_id', diff --git a/addons/hr_recruitment/hr_recruitment.py b/addons/hr_recruitment/hr_recruitment.py index de1539da55a..489f218532e 100644 --- a/addons/hr_recruitment/hr_recruitment.py +++ b/addons/hr_recruitment/hr_recruitment.py @@ -186,7 +186,7 @@ class hr_applicant(base_stage, osv.Model): 'partner_id': fields.many2one('res.partner', 'Contact'), 'create_date': fields.datetime('Creation Date', readonly=True, select=True), 'write_date': fields.datetime('Update Date', readonly=True), - 'stage_id': fields.many2one ('hr.recruitment.stage', 'Stage', + 'stage_id': fields.many2one ('hr.recruitment.stage', 'Stage', tracked=True, domain="['&', ('fold', '=', False), '|', ('department_id', '=', department_id), ('department_id', '=', False)]"), 'state': fields.related('stage_id', 'state', type="selection", store=True, selection=AVAILABLE_STATES, string="Status", readonly=True, @@ -197,7 +197,7 @@ class hr_applicant(base_stage, osv.Model): set to \'Pending\'.'), 'categ_ids': fields.many2many('hr.applicant_category', string='Tags'), 'company_id': fields.many2one('res.company', 'Company'), - 'user_id': fields.many2one('res.users', 'Responsible'), + 'user_id': fields.many2one('res.users', 'Responsible',tracked=True), # Applicant Columns 'date_closed': fields.datetime('Closed', readonly=True, select=True), 'date_open': fields.datetime('Opened', readonly=True, select=True), diff --git a/addons/mail/doc/tracked_field.rst b/addons/mail/doc/tracked_field.rst new file mode 100644 index 00000000000..8d06ae1fa41 --- /dev/null +++ b/addons/mail/doc/tracked_field.rst @@ -0,0 +1,18 @@ +Tracked fields +--------------- +Tracked fields is generic logging system for the changes applied to some fields in open chatter. + - In the definition of a fields. you have to simply add tracked=True. + - When changing one of the fields having tracked=True on saved automatically write a log in openchatter for the changes in fields. + +How it works: ++++++++++++++ + - You have to add tracked=True in field defination as following. + - ``'stage_id': fields.many2one('project.task.type', 'Stage',tracked=True),`` + - For developed this feature we override write method of mail_thread. + - And make one mako template which shows old field and updated field. + +Open chatter log: ++++++++++++++++++ + - After changing field follower can show log of tracked field in open chatter as followed. + - Updated Fields: + - Stage: Analysis -> Specification \ No newline at end of file diff --git a/addons/mail/mail_thread.py b/addons/mail/mail_thread.py index 63043f992f3..0db7fbfc2db 100644 --- a/addons/mail/mail_thread.py +++ b/addons/mail/mail_thread.py @@ -20,19 +20,25 @@ ############################################################################## import base64 +from collections import defaultdict import dateutil import email +from functools import partial import logging import pytz import time import tools import xmlrpclib +from mako.template import Template as MakoTemplate + from email.message import Message from mail_message import decode from openerp import SUPERUSER_ID from osv import osv, fields +from openerp.osv.orm import browse_record from tools.safe_eval import safe_eval as eval +from tools.translate import _ _logger = logging.getLogger(__name__) @@ -68,6 +74,14 @@ class mail_thread(osv.AbstractModel): _name = 'mail.thread' _description = 'Email Thread' _mail_flat_thread = True + _TRACK_TEMPLATE = """ + ${updated_fields} +
    + %for chg in changes: +
  • ${chg[0]}: ${chg[1]} -> ${chg[2]}
  • + %endfor +
+ """ def _get_message_data(self, cr, uid, ids, name, args, context=None): """ Computes: @@ -241,6 +255,87 @@ class mail_thread(osv.AbstractModel): default['message_follower_ids'] = [] return super(mail_thread, self).copy(cr, uid, id, default=default, context=context) + #------------------------------------------------------ + # Automatically log tracked fields + #------------------------------------------------------ + + def write(self, cr, uid, ids, values, context=None): + + if context is None: + context = {} + #import pudb;pudb.set_trace() + + def false_value(f): + if f._type == 'boolean': + return False + return f._symbol_set[1](False) + + def convert_for_comparison(v, f): + # It will convert value for comparison between current and new. + if not v: + return false_value(f) + if isinstance(v, browse_record): + return v.id + return v + + tracked = dict((n, f) for n, f in self._all_columns.items() if getattr(f.column, 'tracked', False)) + to_log = [k for k in values if k in tracked] + + from_ = None + changes = defaultdict(list) + if to_log: + for record in self.browse(cr, uid, ids, context): + for tl in to_log: + column = tracked[tl].column + current = convert_for_comparison(record[tl], column) + new = convert_for_comparison(values[tl], column) + if new != current: + changes[record].append(tl) + from_ = record[tl] + + result = super(mail_thread, self).write(cr, uid, ids, values, context=context) + + updated_fields = _('Updated Fields:') + + Trans = self.pool['ir.translation'] + def _t(c): + # translate field + model = c.parent_model or self._name + lang = context.get('lang') + return Trans._get_source(cr, uid, '{0},{1}'.format(model, c.name), 'field', lang, ci.column.string) + + def get_subtype(model, record): + # it will return subtype name(xml_id) for stage. + record_model = self.pool[model].browse(cr, SUPERUSER_ID, record) + if record_model.__hasattr__('subtype'): + return record_model.subtype + return False + + for record, changed_fields in changes.items(): + # TODO tpl changed_fields + chg = [] + subtype = False + for f in changed_fields: + to = self.browse(cr, uid, ids[0], context)[f] + ci = tracked[f] + if ci.column._type == "many2one": + if to: + to = to.name_get()[0][1] + else: + to = "Removed" + if isinstance(from_, browse_record): + from_ = from_.name_get()[0][1] + + subtype = get_subtype(ci.column._obj,values[f]) + chg.append((_t(ci), from_, to)) + + message = MakoTemplate(self._TRACK_TEMPLATE).render_unicode(updated_fields=updated_fields, + changes=chg) + + record.message_post(message,subtype=subtype) + + return result + #------------------------------------------------------ # mail.message wrappers and tools #------------------------------------------------------ diff --git a/addons/mail/tests/test_mail_features.py b/addons/mail/tests/test_mail_features.py index b97c9a4af94..3a8a5ff767c 100644 --- a/addons/mail/tests/test_mail_features.py +++ b/addons/mail/tests/test_mail_features.py @@ -586,3 +586,31 @@ class test_mail(TestMailBase): na_demo_group = self.mail_message._needaction_count(cr, user_raoul.id, domain=[('model', '=', 'mail.group'), ('res_id', '=', self.group_pigs_id)]) self.assertEqual(na_demo, na_demo_base + 0, 'Demo should have 0 new needaction') self.assertEqual(na_demo_group, 0, 'Demo should have 0 needaction related to Pigs') + + def test_40_track_field(self): + """ Test designed for tracking field. """ + + cr, uid, user_admin, user_raoul, group_pigs = self.cr, self.uid, self.user_admin, self.user_raoul, self.group_pigs + partner_user_id = self.res_partner._columns.get('user_id') + + # Set tracked attribute as True for tracked field. + partner_user_id.tracked = True + p_d_id = self.res_partner.create(cr, uid, {'name': 'Osbert Armour', 'notification_email_send': 'all'}) + + #Set Raoul as sales person. + self.res_partner.write(cr, uid, [p_d_id], {'user_id' : user_raoul.id}) + mail_ids = self.mail_message.search(cr, uid, [('model', '=', 'res.partner'),('res_id','=',p_d_id)]) + + #Test: tracked record logged in openchatter + self.assertEqual(1,len(mail_ids), ' After change in field logged in openchatter.') + + # Set tracked attribute as False for tracked field. + partner_user_id.tracked = False + p_a_id = self.res_partner.create(cr, uid, {'name': 'Timmy Simons', 'notification_email_send': 'all'}) + + #Set Admin as sales person. + self.res_partner.write(cr, uid, [p_a_id], {'user_id' : user_admin.id}) + mail_a_ids = self.mail_message.search(cr, uid, [('model', '=', 'res.partner'),('res_id','=',p_a_id)]) + + #Test: tracked record logged in openchatter. + self.assertEqual(len(mail_a_ids),0,' No more logged openchatter.') diff --git a/addons/project/project.py b/addons/project/project.py index 2e5a990bc5c..a3a4567800f 100644 --- a/addons/project/project.py +++ b/addons/project/project.py @@ -742,7 +742,7 @@ class task(base_stage, osv.osv): 'description': fields.text('Description'), 'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority', select=True), 'sequence': fields.integer('Sequence', select=True, help="Gives the sequence order when displaying a list of tasks."), - 'stage_id': fields.many2one('project.task.type', 'Stage', + 'stage_id': fields.many2one('project.task.type', 'Stage', tracked=True, domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"), 'state': fields.related('stage_id', 'state', type="selection", store=True, selection=_TASK_STATE, string="Status", readonly=True, @@ -762,7 +762,7 @@ class task(base_stage, osv.osv): 'date_start': fields.datetime('Starting Date',select=True), 'date_end': fields.datetime('Ending Date',select=True), 'date_deadline': fields.date('Deadline',select=True), - 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"), + 'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1", tracked=True), 'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'), 'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'), 'notes': fields.text('Notes'), @@ -788,7 +788,7 @@ class task(base_stage, osv.osv): 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10), 'project.task.work': (_get_task, ['hours'], 10), }), - 'user_id': fields.many2one('res.users', 'Assigned to'), + 'user_id': fields.many2one('res.users', 'Assigned to', tracked=True), 'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'), 'partner_id': fields.many2one('res.partner', 'Customer'), 'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'), @@ -1060,7 +1060,6 @@ class task(base_stage, osv.osv): self.do_pending(cr, uid, [task.id], context=context) elif delegate_data['state'] == 'done': self.do_close(cr, uid, [task.id], context=context) - self.do_delegation_send_note(cr, uid, [task.id], context) delegated_tasks[task.id] = delegated_task_id return delegated_tasks @@ -1140,7 +1139,6 @@ class task(base_stage, osv.osv): self._subscribe_project_followers_to_task(cr, uid, task_id, context=context) self._store_history(cr, uid, [task_id], context=context) - self.create_send_note(cr, uid, [task_id], context=context) return task_id # Overridden to reset the kanban_state to normal whenever @@ -1158,7 +1156,6 @@ class task(base_stage, osv.osv): #raise osv.except_osv(_('Warning!'), _('Stage is not defined in the project.')) write_vals = vals_reset_kstate if t.stage_id != new_stage else vals super(task, self).write(cr, uid, [t.id], write_vals, context=context) - self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context) result = True else: result = super(task, self).write(cr, uid, ids, vals, context=context) diff --git a/addons/project_issue/project_issue.py b/addons/project_issue/project_issue.py index 3168e253fe8..ced3ac748b6 100644 --- a/addons/project_issue/project_issue.py +++ b/addons/project_issue/project_issue.py @@ -245,14 +245,14 @@ class project_issue(base_stage, osv.osv): 'version_id': fields.many2one('project.issue.version', 'Version'), 'stage_id': fields.many2one ('project.task.type', 'Stage', domain="['&', ('fold', '=', False), ('project_ids', '=', project_id)]"), - 'project_id':fields.many2one('project.project', 'Project'), + 'project_id':fields.many2one('project.project', 'Project', tracked=True), 'duration': fields.float('Duration'), 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"), 'day_open': fields.function(_compute_day, string='Days to Open', \ multi='compute_day', type="float", store=True), 'day_close': fields.function(_compute_day, string='Days to Close', \ multi='compute_day', type="float", store=True), - 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1), + 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1, tracked=True), 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \ multi='compute_day', type="float", store=True), 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \ diff --git a/addons/sale/sale.py b/addons/sale/sale.py index 6f4d38685a6..de11a3acce9 100644 --- a/addons/sale/sale.py +++ b/addons/sale/sale.py @@ -187,7 +187,7 @@ class sale_order(osv.osv): ('manual', 'Sale to Invoice'), ('invoice_except', 'Invoice Exception'), ('done', 'Done'), - ], 'Status', readonly=True, help="Gives the status of the quotation or sales order. \nThe exception status is automatically set when a cancel operation occurs in the processing of a document linked to the sale order. \nThe 'Waiting Schedule' status is set when the invoice is confirmed but waiting for the scheduler to run on the order date.", select=True), + ], 'Status', readonly=True, tracked=True, help="Gives the status of the quotation or sales order. \nThe exception status is automatically set when a cancel operation occurs in the processing of a document linked to the sale order. \nThe 'Waiting Schedule' status is set when the invoice is confirmed but waiting for the scheduler to run on the order date.", select=True), 'date_order': fields.date('Date', required=True, readonly=True, select=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}), 'create_date': fields.datetime('Creation Date', readonly=True, select=True, help="Date on which sales order is created."), 'date_confirm': fields.date('Confirmation Date', readonly=True, select=True, help="Date on which sales order is confirmed."),