[INP] portal_sale: remove buggy context
[odoo/odoo.git] / addons / crm / crm_lead.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-today OpenERP SA (<http://www.openerp.com>)
6 #
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.
11 #
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.
16 #
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/>.
19 #
20 ##############################################################################
21
22 from openerp.addons.base_status.base_stage import base_stage
23 import crm
24 from datetime import datetime
25 from operator import itemgetter
26 from openerp.osv import fields, osv, orm
27 import time
28 from openerp import SUPERUSER_ID
29 from openerp import tools
30 from openerp.tools.translate import _
31 from openerp.tools import html2plaintext
32
33 from base.res.res_partner import format_address
34
35 CRM_LEAD_FIELDS_TO_MERGE = ['name',
36     'partner_id',
37     'channel_id',
38     'company_id',
39     'country_id',
40     'section_id',
41     'state_id',
42     'stage_id',
43     'type_id',
44     'user_id',
45     'title',
46     'city',
47     'contact_name',
48     'description',
49     'email',
50     'fax',
51     'mobile',
52     'partner_name',
53     'phone',
54     'probability',
55     'planned_revenue',
56     'street',
57     'street2',
58     'zip',
59     'create_date',
60     'date_action_last',
61     'date_action_next',
62     'email_from',
63     'email_cc',
64     'partner_name']
65 CRM_LEAD_PENDING_STATES = (
66     crm.AVAILABLE_STATES[2][0], # Cancelled
67     crm.AVAILABLE_STATES[3][0], # Done
68     crm.AVAILABLE_STATES[4][0], # Pending
69 )
70
71 class crm_lead(base_stage, format_address, osv.osv):
72     """ CRM Lead Case """
73     _name = "crm.lead"
74     _description = "Lead/Opportunity"
75     _order = "priority,date_action,id desc"
76     _inherit = ['mail.thread', 'ir.needaction_mixin']
77
78     _track = {
79         'state': {
80             'crm.mt_lead_create': lambda self, cr, uid, obj, ctx=None: obj['state'] in ['new', 'draft'],
81             'crm.mt_lead_won': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'done',
82             'crm.mt_lead_lost': lambda self, cr, uid, obj, ctx=None: obj['state'] == 'cancel',
83         },
84         'stage_id': {
85             'crm.mt_lead_stage': lambda self, cr, uid, obj, ctx=None: obj['state'] not in ['new', 'draft', 'cancel', 'done'],
86         },
87     }
88
89     def create(self, cr, uid, vals, context=None):
90         if context is None:
91             context = {}
92         if vals.get('type') and not context.get('default_type'):
93             context['default_type'] = vals.get('type')
94         if vals.get('section_id') and not context.get('default_section_id'):
95             context['default_section_id'] = vals.get('section_id')
96
97         # context: no_log, because subtype already handle this
98         create_context = dict(context, mail_create_nolog=True)
99         return super(crm_lead, self).create(cr, uid, vals, context=create_context)
100
101     def _get_default_section_id(self, cr, uid, context=None):
102         """ Gives default section by checking if present in the context """
103         return self._resolve_section_id_from_context(cr, uid, context=context) or False
104
105     def _get_default_stage_id(self, cr, uid, context=None):
106         """ Gives default stage_id """
107         section_id = self._get_default_section_id(cr, uid, context=context)
108         return self.stage_find(cr, uid, [], section_id, [('state', '=', 'draft')], context=context)
109
110     def _resolve_section_id_from_context(self, cr, uid, context=None):
111         """ Returns ID of section based on the value of 'section_id'
112             context key, or None if it cannot be resolved to a single
113             Sales Team.
114         """
115         if context is None:
116             context = {}
117         if type(context.get('default_section_id')) in (int, long):
118             return context.get('default_section_id')
119         if isinstance(context.get('default_section_id'), basestring):
120             section_name = context['default_section_id']
121             section_ids = self.pool.get('crm.case.section').name_search(cr, uid, name=section_name, context=context)
122             if len(section_ids) == 1:
123                 return int(section_ids[0][0])
124         return None
125
126     def _resolve_type_from_context(self, cr, uid, context=None):
127         """ Returns the type (lead or opportunity) from the type context
128             key. Returns None if it cannot be resolved.
129         """
130         if context is None:
131             context = {}
132         return context.get('default_type')
133
134     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
135         access_rights_uid = access_rights_uid or uid
136         stage_obj = self.pool.get('crm.case.stage')
137         order = stage_obj._order
138         # lame hack to allow reverting search, should just work in the trivial case
139         if read_group_order == 'stage_id desc':
140             order = "%s desc" % order
141         # retrieve section_id from the context and write the domain
142         # - ('id', 'in', 'ids'): add columns that should be present
143         # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
144         # - OR ('section_ids', '=', section_id), ('fold', '=', False) if section_id: add section columns that are not folded
145         search_domain = []
146         section_id = self._resolve_section_id_from_context(cr, uid, context=context)
147         if section_id:
148             search_domain += ['|', ('section_ids', '=', section_id)]
149             search_domain += [('id', 'in', ids)]
150         else:
151             search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
152         # retrieve type from the context (if set: choose 'type' or 'both')
153         type = self._resolve_type_from_context(cr, uid, context=context)
154         if type:
155             search_domain += ['|', ('type', '=', type), ('type', '=', 'both')]
156         # perform search
157         stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
158         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
159         # restore order of the search
160         result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
161
162         fold = {}
163         for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
164             fold[stage.id] = stage.fold or False
165         return result, fold
166
167     def fields_view_get(self, cr, user, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
168         if view_type == 'form' and context and context.get('opportunity_id'):
169             # TODO: replace by get_formview_action call
170             lead_type = self.browse(cr, user, context['opportunity_id'], context=context).type
171             view_lead_xml_id = 'crm_case_form_view_oppor' if lead_type == 'opportunity' else 'crm_case_form_view_leads'
172             _, view_id = self.pool['ir.model.data'].get_object_reference(cr, user, 'crm', view_lead_xml_id)
173         res = super(crm_lead,self).fields_view_get(cr, user, view_id, view_type, context, toolbar=toolbar, submenu=submenu)
174         if view_type == 'form':
175             res['arch'] = self.fields_view_get_address(cr, user, res['arch'], context=context)
176         return res
177
178     _group_by_full = {
179         'stage_id': _read_group_stage_ids
180     }
181
182     def _compute_day(self, cr, uid, ids, fields, args, context=None):
183         """
184         :return dict: difference between current date and log date
185         """
186         cal_obj = self.pool.get('resource.calendar')
187         res_obj = self.pool.get('resource.resource')
188
189         res = {}
190         for lead in self.browse(cr, uid, ids, context=context):
191             for field in fields:
192                 res[lead.id] = {}
193                 duration = 0
194                 ans = False
195                 if field == 'day_open':
196                     if lead.date_open:
197                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
198                         date_open = datetime.strptime(lead.date_open, "%Y-%m-%d %H:%M:%S")
199                         ans = date_open - date_create
200                         date_until = lead.date_open
201                 elif field == 'day_close':
202                     if lead.date_closed:
203                         date_create = datetime.strptime(lead.create_date, "%Y-%m-%d %H:%M:%S")
204                         date_close = datetime.strptime(lead.date_closed, "%Y-%m-%d %H:%M:%S")
205                         date_until = lead.date_closed
206                         ans = date_close - date_create
207                 if ans:
208                     resource_id = False
209                     if lead.user_id:
210                         resource_ids = res_obj.search(cr, uid, [('user_id','=',lead.user_id.id)])
211                         if len(resource_ids):
212                             resource_id = resource_ids[0]
213
214                     duration = float(ans.days)
215                     if lead.section_id and lead.section_id.resource_calendar_id:
216                         duration =  float(ans.days) * 24
217                         new_dates = cal_obj.interval_get(cr,
218                             uid,
219                             lead.section_id.resource_calendar_id and lead.section_id.resource_calendar_id.id or False,
220                             datetime.strptime(lead.create_date, '%Y-%m-%d %H:%M:%S'),
221                             duration,
222                             resource=resource_id
223                         )
224                         no_days = []
225                         date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
226                         for in_time, out_time in new_dates:
227                             if in_time.date not in no_days:
228                                 no_days.append(in_time.date)
229                             if out_time > date_until:
230                                 break
231                         duration =  len(no_days)
232                 res[lead.id][field] = abs(int(duration))
233         return res
234
235     def _history_search(self, cr, uid, obj, name, args, context=None):
236         res = []
237         msg_obj = self.pool.get('mail.message')
238         message_ids = msg_obj.search(cr, uid, [('email_from','!=',False), ('subject', args[0][1], args[0][2])], context=context)
239         lead_ids = self.search(cr, uid, [('message_ids', 'in', message_ids)], context=context)
240
241         if lead_ids:
242             return [('id', 'in', lead_ids)]
243         else:
244             return [('id', '=', '0')]
245
246     _columns = {
247         'partner_id': fields.many2one('res.partner', 'Partner', ondelete='set null', track_visibility='onchange',
248             select=True, help="Linked partner (optional). Usually created when converting the lead."),
249
250         'id': fields.integer('ID', readonly=True),
251         'name': fields.char('Subject', size=64, required=True, select=1),
252         'active': fields.boolean('Active', required=False),
253         'date_action_last': fields.datetime('Last Action', readonly=1),
254         'date_action_next': fields.datetime('Next Action', readonly=1),
255         'email_from': fields.char('Email', size=128, help="Email address of the contact", select=1),
256         'section_id': fields.many2one('crm.case.section', 'Sales Team',
257                         select=True, track_visibility='onchange', help='When sending mails, the default email address is taken from the sales team.'),
258         'create_date': fields.datetime('Creation Date' , readonly=True),
259         '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"),
260         'description': fields.text('Notes'),
261         'write_date': fields.datetime('Update Date' , readonly=True),
262         'categ_ids': fields.many2many('crm.case.categ', 'crm_lead_category_rel', 'lead_id', 'category_id', 'Categories', \
263             domain="['|',('section_id','=',section_id),('section_id','=',False), ('object_id.model', '=', 'crm.lead')]"),
264         'type_id': fields.many2one('crm.case.resource.type', 'Campaign', \
265             domain="['|',('section_id','=',section_id),('section_id','=',False)]", help="From which campaign (seminar, marketing campaign, mass mailing, ...) did this contact come from?"),
266         'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel (mail, direct, phone, ...)"),
267         'contact_name': fields.char('Contact Name', size=64),
268         'partner_name': fields.char("Customer Name", size=64,help='The name of the future partner company that will be created while converting the lead into opportunity', select=1),
269         'opt_out': fields.boolean('Opt-Out', oldname='optout',
270             help="If opt-out is checked, this contact has refused to receive emails for mass mailing and marketing campaign. "
271                     "Filter 'Available for Mass Mailing' allows users to filter the leads when performing mass mailing."),
272         'type': fields.selection([ ('lead','Lead'), ('opportunity','Opportunity'), ],'Type', select=True, help="Type is used to separate Leads and Opportunities"),
273         'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
274         'date_closed': fields.datetime('Closed', readonly=True),
275         'stage_id': fields.many2one('crm.case.stage', 'Stage', track_visibility='onchange', select=True,
276                         domain="['&', '&', ('fold', '=', False), ('section_ids', '=', section_id), '|', ('type', '=', type), ('type', '=', 'both')]"),
277         'user_id': fields.many2one('res.users', 'Salesperson', select=True, track_visibility='onchange'),
278         'referred': fields.char('Referred By', size=64),
279         'date_open': fields.datetime('Opened', readonly=True),
280         'day_open': fields.function(_compute_day, string='Days to Open', \
281                                 multi='day_open', type="float", store=True),
282         'day_close': fields.function(_compute_day, string='Days to Close', \
283                                 multi='day_close', type="float", store=True),
284         'state': fields.related('stage_id', 'state', type="selection", store=True,
285                 selection=crm.AVAILABLE_STATES, string="Status", readonly=True, select=True,
286                 help='The Status is set to \'Draft\', when a case is created. If the case is in progress the Status is set to \'Open\'. When the case is over, the Status is set to \'Done\'. If the case needs to be reviewed then the Status is  set to \'Pending\'.'),
287
288         # Only used for type opportunity
289         'probability': fields.float('Success Rate (%)',group_operator="avg"),
290         'planned_revenue': fields.float('Expected Revenue', track_visibility='always'),
291         'ref': fields.reference('Reference', selection=crm._links_get, size=128),
292         'ref2': fields.reference('Reference 2', selection=crm._links_get, size=128),
293         'phone': fields.char("Phone", size=64),
294         'date_deadline': fields.date('Expected Closing', help="Estimate of the date on which the opportunity will be won."),
295         'date_action': fields.date('Next Action Date', select=True),
296         'title_action': fields.char('Next Action', size=64),
297         'color': fields.integer('Color Index'),
298         'partner_address_name': fields.related('partner_id', 'name', type='char', string='Partner Contact Name', readonly=True),
299         'partner_address_email': fields.related('partner_id', 'email', type='char', string='Partner Contact Email', readonly=True),
300         'company_currency': fields.related('company_id', 'currency_id', type='many2one', string='Currency', readonly=True, relation="res.currency"),
301         'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
302         'user_login': fields.related('user_id', 'login', type='char', string='User Login', readonly=True),
303
304         # Fields for address, due to separation from crm and res.partner
305         'street': fields.char('Street', size=128),
306         'street2': fields.char('Street2', size=128),
307         'zip': fields.char('Zip', change_default=True, size=24),
308         'city': fields.char('City', size=128),
309         'state_id': fields.many2one("res.country.state", 'State'),
310         'country_id': fields.many2one('res.country', 'Country'),
311         'phone': fields.char('Phone', size=64),
312         'fax': fields.char('Fax', size=64),
313         'mobile': fields.char('Mobile', size=64),
314         'function': fields.char('Function', size=128),
315         'title': fields.many2one('res.partner.title', 'Title'),
316         'company_id': fields.many2one('res.company', 'Company', select=1),
317         'payment_mode': fields.many2one('crm.payment.mode', 'Payment Mode', \
318                             domain="[('section_id','=',section_id)]"),
319         'planned_cost': fields.float('Planned Costs'),
320     }
321
322     _defaults = {
323         'active': 1,
324         'type': 'lead',
325         'user_id': lambda s, cr, uid, c: s._get_default_user(cr, uid, c),
326         'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
327         'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
328         'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
329         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.lead', context=c),
330         'priority': lambda *a: crm.AVAILABLE_PRIORITIES[2][0],
331         'color': 0,
332     }
333
334     _sql_constraints = [
335         ('check_probability', 'check(probability >= 0 and probability <= 100)', 'The probability of closing the deal should be between 0% and 100%!')
336     ]
337
338     def onchange_stage_id(self, cr, uid, ids, stage_id, context=None):
339         if not stage_id:
340             return {'value':{}}
341         stage = self.pool.get('crm.case.stage').browse(cr, uid, stage_id, context)
342         if not stage.on_change:
343             return {'value':{}}
344         return {'value':{'probability': stage.probability}}
345
346     def on_change_partner(self, cr, uid, ids, partner_id, context=None):
347         values = {}
348         if partner_id:
349             partner = self.pool.get('res.partner').browse(cr, uid, partner_id, context=context)
350             values = {
351                 'partner_name' : partner.name,
352                 'street' : partner.street,
353                 'street2' : partner.street2,
354                 'city' : partner.city,
355                 'state_id' : partner.state_id and partner.state_id.id or False,
356                 'country_id' : partner.country_id and partner.country_id.id or False,
357                 'email_from' : partner.email,
358                 'phone' : partner.phone,
359                 'mobile' : partner.mobile,
360                 'fax' : partner.fax,
361                 'zip': partner.zip,
362             }
363         return {'value' : values}
364
365     def on_change_user(self, cr, uid, ids, user_id, context=None):
366         """ When changing the user, also set a section_id or restrict section id
367             to the ones user_id is member of. """
368         if user_id:
369             section_ids = self.pool.get('crm.case.section').search(cr, uid, ['|', ('user_id', '=', user_id), ('member_ids', '=', user_id)], context=context)
370             if section_ids:
371                 return {'value': {'section_id': section_ids[0]}}
372         return {'value': {}}
373
374     def _check(self, cr, uid, ids=False, context=None):
375         """ Override of the base.stage method.
376             Function called by the scheduler to process cases for date actions
377             Only works on not done and cancelled cases
378         """
379         cr.execute('select * from crm_case \
380                 where (date_action_last<%s or date_action_last is null) \
381                 and (date_action_next<=%s or date_action_next is null) \
382                 and state not in (\'cancel\',\'done\')',
383                 (time.strftime("%Y-%m-%d %H:%M:%S"),
384                     time.strftime('%Y-%m-%d %H:%M:%S')))
385
386         ids2 = map(lambda x: x[0], cr.fetchall() or [])
387         cases = self.browse(cr, uid, ids2, context=context)
388         return self._action(cr, uid, cases, False, context=context)
389
390     def stage_find(self, cr, uid, cases, section_id, domain=None, order='sequence', context=None):
391         """ Override of the base.stage method
392             Parameter of the stage search taken from the lead:
393             - type: stage type must be the same or 'both'
394             - section_id: if set, stages must belong to this section or
395               be a default stage; if not set, stages must be default
396               stages
397         """
398         if isinstance(cases, (int, long)):
399             cases = self.browse(cr, uid, cases, context=context)
400         # collect all section_ids
401         section_ids = []
402         types = ['both']
403         if not cases :
404             type = context.get('default_type')
405             types += [type]
406         if section_id:
407             section_ids.append(section_id)
408         for lead in cases:
409             if lead.section_id:
410                 section_ids.append(lead.section_id.id)
411             if lead.type not in types:
412                 types.append(lead.type)
413         # OR all section_ids and OR with case_default
414         search_domain = []
415         if section_ids:
416             search_domain += [('|')] * len(section_ids)
417             for section_id in section_ids:
418                 search_domain.append(('section_ids', '=', section_id))
419         else:
420             search_domain.append(('case_default', '=', True))
421         # AND with cases types
422         search_domain.append(('type', 'in', types))
423         # AND with the domain in parameter
424         search_domain += list(domain)
425         # perform search, return the first found
426         stage_ids = self.pool.get('crm.case.stage').search(cr, uid, search_domain, order=order, context=context)
427         if stage_ids:
428             return stage_ids[0]
429         return False
430
431     def case_cancel(self, cr, uid, ids, context=None):
432         """ Overrides case_cancel from base_stage to set probability """
433         res = super(crm_lead, self).case_cancel(cr, uid, ids, context=context)
434         self.write(cr, uid, ids, {'probability' : 0.0}, context=context)
435         return res
436
437     def case_reset(self, cr, uid, ids, context=None):
438         """ Overrides case_reset from base_stage to set probability """
439         res = super(crm_lead, self).case_reset(cr, uid, ids, context=context)
440         self.write(cr, uid, ids, {'probability': 0.0}, context=context)
441         return res
442
443     def case_mark_lost(self, cr, uid, ids, context=None):
444         """ Mark the case as lost: state=cancel and probability=0 """
445         for lead in self.browse(cr, uid, ids):
446             stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 0.0),('on_change','=',True)], context=context)
447             if stage_id:
448                 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 0.0, 'date_closed': fields.datetime.now()}, new_stage_id=stage_id, context=context)
449         return True
450
451     def case_mark_won(self, cr, uid, ids, context=None):
452         """ Mark the case as won: state=done and probability=100 """
453         for lead in self.browse(cr, uid, ids):
454             stage_id = self.stage_find(cr, uid, [lead], lead.section_id.id or False, [('probability', '=', 100.0),('on_change','=',True)], context=context)
455             if stage_id:
456                 self.case_set(cr, uid, [lead.id], values_to_update={'probability': 100.0, 'date_closed': fields.datetime.now()}, new_stage_id=stage_id, context=context)
457         return True
458
459     def set_priority(self, cr, uid, ids, priority):
460         """ Set lead priority
461         """
462         return self.write(cr, uid, ids, {'priority' : priority})
463
464     def set_high_priority(self, cr, uid, ids, context=None):
465         """ Set lead priority to high
466         """
467         return self.set_priority(cr, uid, ids, '1')
468
469     def set_normal_priority(self, cr, uid, ids, context=None):
470         """ Set lead priority to normal
471         """
472         return self.set_priority(cr, uid, ids, '3')
473
474     def _merge_get_result_type(self, cr, uid, opps, context=None):
475         """
476         Define the type of the result of the merge.  If at least one of the
477         element to merge is an opp, the resulting new element will be an opp.
478         Otherwise it will be a lead.
479
480         We'll directly use a list of browse records instead of a list of ids
481         for performances' sake: it will spare a second browse of the
482         leads/opps.
483
484         :param list opps: list of browse records containing the leads/opps to process
485         :return string type: the type of the final element
486         """
487         for opp in opps:
488             if (opp.type == 'opportunity'):
489                 return 'opportunity'
490
491         return 'lead'
492
493     def _merge_data(self, cr, uid, ids, oldest, fields, context=None):
494         """
495         Prepare lead/opp data into a dictionary for merging.  Different types
496         of fields are processed in different ways:
497         - text: all the values are concatenated
498         - m2m and o2m: those fields aren't processed
499         - m2o: the first not null value prevails (the other are dropped)
500         - any other type of field: same as m2o
501
502         :param list ids: list of ids of the leads to process
503         :param list fields: list of leads' fields to process
504         :return dict data: contains the merged values
505         """
506         opportunities = self.browse(cr, uid, ids, context=context)
507
508         def _get_first_not_null(attr):
509             for opp in opportunities:
510                 if hasattr(opp, attr) and bool(getattr(opp, attr)):
511                     return getattr(opp, attr)
512             return False
513
514         def _get_first_not_null_id(attr):
515             res = _get_first_not_null(attr)
516             return res and res.id or False
517
518         def _concat_all(attr):
519             return '\n\n'.join(filter(lambda x: x, [getattr(opp, attr) or '' for opp in opportunities if hasattr(opp, attr)]))
520
521         # Process the fields' values
522         data = {}
523         for field_name in fields:
524             field_info = self._all_columns.get(field_name)
525             if field_info is None:
526                 continue
527             field = field_info.column
528             if field._type in ('many2many', 'one2many'):
529                 continue
530             elif field._type == 'many2one':
531                 data[field_name] = _get_first_not_null_id(field_name)  # !!
532             elif field._type == 'text':
533                 data[field_name] = _concat_all(field_name)  #not lost
534             else:
535                 data[field_name] = _get_first_not_null(field_name)  #not lost
536
537         # Define the resulting type ('lead' or 'opportunity')
538         data['type'] = self._merge_get_result_type(cr, uid, opportunities, context)
539         return data
540
541     def _mail_body(self, cr, uid, lead, fields, title=False, context=None):
542         body = []
543         if title:
544             body.append("%s\n" % (title))
545
546         for field_name in fields:
547             field_info = self._all_columns.get(field_name)
548             if field_info is None:
549                 continue
550             field = field_info.column
551             value = ''
552
553             if field._type == 'selection':
554                 if hasattr(field.selection, '__call__'):
555                     key = field.selection(self, cr, uid, context=context)
556                 else:
557                     key = field.selection
558                 value = dict(key).get(lead[field_name], lead[field_name])
559             elif field._type == 'many2one':
560                 if lead[field_name]:
561                     value = lead[field_name].name_get()[0][1]
562             elif field._type == 'many2many':
563                 if lead[field_name]:
564                     for val in lead[field_name]:
565                         field_value = val.name_get()[0][1]
566                         value += field_value + ","
567             else:
568                 value = lead[field_name]
569
570             body.append("%s: %s" % (field.string, value or ''))
571         return "<br/>".join(body + ['<br/>'])
572
573     def _merge_notify(self, cr, uid, opportunity_id, opportunities, context=None):
574         """
575         Create a message gathering merged leads/opps information.
576         """
577         #TOFIX: mail template should be used instead of fix body, subject text
578         details = []
579         result_type = self._merge_get_result_type(cr, uid, opportunities, context)
580         if result_type == 'lead':
581             merge_message = _('Merged leads')
582         else:
583             merge_message = _('Merged opportunities')
584         subject = [merge_message]
585         for opportunity in opportunities:
586             subject.append(opportunity.name)
587             title = "%s : %s" % (opportunity.type == 'opportunity' and _('Merged opportunity') or _('Merged lead'), opportunity.name)
588             fields = list(CRM_LEAD_FIELDS_TO_MERGE)
589             details.append(self._mail_body(cr, uid, opportunity, fields, title=title, context=context))
590
591         # Chatter message's subject
592         subject = subject[0] + ": " + ", ".join(subject[1:])
593         details = "\n\n".join(details)
594         return self.message_post(cr, uid, [opportunity_id], body=details, subject=subject, context=context)
595
596     def _merge_opportunity_history(self, cr, uid, opportunity_id, opportunities, context=None):
597         message = self.pool.get('mail.message')
598         for opportunity in opportunities:
599             for history in opportunity.message_ids:
600                 message.write(cr, uid, history.id, {
601                         'res_id': opportunity_id,
602                         'subject' : _("From %s : %s") % (opportunity.name, history.subject)
603                 }, context=context)
604
605         return True
606
607     def _merge_opportunity_attachments(self, cr, uid, opportunity_id, opportunities, context=None):
608         attach_obj = self.pool.get('ir.attachment')
609
610         # return attachments of opportunity
611         def _get_attachments(opportunity_id):
612             attachment_ids = attach_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', '=', opportunity_id)], context=context)
613             return attach_obj.browse(cr, uid, attachment_ids, context=context)
614
615         first_attachments = _get_attachments(opportunity_id)
616         #counter of all attachments to move. Used to make sure the name is different for all attachments
617         count = 1
618         for opportunity in opportunities:
619             attachments = _get_attachments(opportunity.id)
620             for attachment in attachments:
621                 values = {'res_id': opportunity_id,}
622                 for attachment_in_first in first_attachments:
623                     if attachment.name == attachment_in_first.name:
624                         name = "%s (%s)" % (attachment.name, count,),
625                 count+=1
626                 attachment.write(values)
627         return True
628
629     def merge_opportunity(self, cr, uid, ids, context=None):
630         """
631         Different cases of merge:
632         - merge leads together = 1 new lead
633         - merge at least 1 opp with anything else (lead or opp) = 1 new opp
634
635         :param list ids: leads/opportunities ids to merge
636         :return int id: id of the resulting lead/opp
637         """
638         if context is None:
639             context = {}
640
641         if len(ids) <= 1:
642             raise osv.except_osv(_('Warning!'), _('Please select more than one element (lead or opportunity) from the list view.'))
643
644         opportunities = self.browse(cr, uid, ids, context=context)
645         sequenced_opps = []
646         for opportunity in opportunities:
647             sequence = -1
648             if opportunity.stage_id and opportunity.stage_id.state != 'cancel':
649                 sequence = opportunity.stage_id.sequence
650             sequenced_opps.append(((int(sequence != -1 and opportunity.type == 'opportunity'), sequence, -opportunity.id), opportunity))
651
652         sequenced_opps.sort(reverse=True)
653         opportunities = map(itemgetter(1), sequenced_opps)
654         ids = [opportunity.id for opportunity in opportunities]
655         highest = opportunities[0]
656         opportunities_rest = opportunities[1:]
657
658         tail_opportunities = opportunities_rest
659
660         fields = list(CRM_LEAD_FIELDS_TO_MERGE)
661         merged_data = self._merge_data(cr, uid, ids, highest, fields, context=context)
662
663         # Merge messages and attachements into the first opportunity
664         self._merge_opportunity_history(cr, uid, highest.id, tail_opportunities, context=context)
665         self._merge_opportunity_attachments(cr, uid, highest.id, tail_opportunities, context=context)
666
667         # Merge notifications about loss of information
668         opportunities = [highest]
669         opportunities.extend(opportunities_rest)
670         self._merge_notify(cr, uid, highest.id, opportunities, context=context)
671         # Check if the stage is in the stages of the sales team. If not, assign the stage with the lowest sequence
672         if merged_data.get('section_id'):
673             section_stage_ids = self.pool.get('crm.case.stage').search(cr, uid, [('section_ids', 'in', merged_data['section_id']), ('type', '=', merged_data.get('type'))], order='sequence', context=context)
674             if merged_data.get('stage_id') not in section_stage_ids:
675                 merged_data['stage_id'] = section_stage_ids and section_stage_ids[0] or False
676         # Write merged data into first opportunity
677         self.write(cr, uid, [highest.id], merged_data, context=context)
678         # Delete tail opportunities 
679         # We use the SUPERUSER to avoid access rights issues because as the user had the rights to see the records it should be safe to do so
680         self.unlink(cr, SUPERUSER_ID, [x.id for x in tail_opportunities], context=context)
681
682         return highest.id
683
684     def _convert_opportunity_data(self, cr, uid, lead, customer, section_id=False, context=None):
685         crm_stage = self.pool.get('crm.case.stage')
686         contact_id = False
687         if customer:
688             contact_id = self.pool.get('res.partner').address_get(cr, uid, [customer.id])['default']
689         if not section_id:
690             section_id = lead.section_id and lead.section_id.id or False
691         val = {
692             'planned_revenue': lead.planned_revenue,
693             'probability': lead.probability,
694             'name': lead.name,
695             'partner_id': customer and customer.id or False,
696             'user_id': (lead.user_id and lead.user_id.id),
697             'type': 'opportunity',
698             'date_action': fields.datetime.now(),
699             'date_open': fields.datetime.now(),
700             'email_from': customer and customer.email or lead.email_from,
701             'phone': customer and customer.phone or lead.phone,
702         }
703         if not lead.stage_id or lead.stage_id.type=='lead':
704             val['stage_id'] = self.stage_find(cr, uid, [lead], section_id, [('state', '=', 'draft'),('type', 'in', ('opportunity','both'))], context=context)
705         return val
706
707     def convert_opportunity(self, cr, uid, ids, partner_id, user_ids=False, section_id=False, context=None):
708         customer = False
709         if partner_id:
710             partner = self.pool.get('res.partner')
711             customer = partner.browse(cr, uid, partner_id, context=context)
712         for lead in self.browse(cr, uid, ids, context=context):
713             if lead.state in ('done', 'cancel'):
714                 continue
715             vals = self._convert_opportunity_data(cr, uid, lead, customer, section_id, context=context)
716             self.write(cr, uid, [lead.id], vals, context=context)
717         self.message_post(cr, uid, ids, body=_("Lead <b>converted into an Opportunity</b>"), subtype="crm.mt_lead_convert_to_opportunity", context=context)
718
719         if user_ids or section_id:
720             self.allocate_salesman(cr, uid, ids, user_ids, section_id, context=context)
721
722         return True
723
724     def _lead_create_contact(self, cr, uid, lead, name, is_company, parent_id=False, context=None):
725         partner = self.pool.get('res.partner')
726         vals = {'name': name,
727             'user_id': lead.user_id.id,
728             'comment': lead.description,
729             'section_id': lead.section_id.id or False,
730             'parent_id': parent_id,
731             'phone': lead.phone,
732             'mobile': lead.mobile,
733             'email': tools.email_split(lead.email_from) and tools.email_split(lead.email_from)[0] or False,
734             'fax': lead.fax,
735             'title': lead.title and lead.title.id or False,
736             'function': lead.function,
737             'street': lead.street,
738             'street2': lead.street2,
739             'zip': lead.zip,
740             'city': lead.city,
741             'country_id': lead.country_id and lead.country_id.id or False,
742             'state_id': lead.state_id and lead.state_id.id or False,
743             'is_company': is_company,
744             'type': 'contact'
745         }
746         partner = partner.create(cr, uid, vals, context=context)
747         return partner
748
749     def _create_lead_partner(self, cr, uid, lead, context=None):
750         partner_id = False
751         if lead.partner_name and lead.contact_name:
752             partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
753             partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, partner_id, context=context)
754         elif lead.partner_name and not lead.contact_name:
755             partner_id = self._lead_create_contact(cr, uid, lead, lead.partner_name, True, context=context)
756         elif not lead.partner_name and lead.contact_name:
757             partner_id = self._lead_create_contact(cr, uid, lead, lead.contact_name, False, context=context)
758         elif lead.email_from and self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]:
759             contact_name = self.pool.get('res.partner')._parse_partner_name(lead.email_from, context=context)[0]
760             partner_id = self._lead_create_contact(cr, uid, lead, contact_name, False, context=context)
761         else:
762             raise osv.except_osv(
763                 _('Warning!'),
764                 _('No customer name defined. Please fill one of the following fields: Company Name, Contact Name or Email ("Name <email@address>")')
765             )
766         return partner_id
767
768     def _lead_set_partner(self, cr, uid, lead, partner_id, context=None):
769         """
770         Assign a partner to a lead.
771
772         :param object lead: browse record of the lead to process
773         :param int partner_id: identifier of the partner to assign
774         :return bool: True if the partner has properly been assigned
775         """
776         res = False
777         res_partner = self.pool.get('res.partner')
778         if partner_id:
779             res_partner.write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
780             contact_id = res_partner.address_get(cr, uid, [partner_id])['default']
781             res = lead.write({'partner_id': partner_id}, context=context)
782             message = _("<b>Partner</b> set to <em>%s</em>." % (lead.partner_id.name))
783             self.message_post(cr, uid, [lead.id], body=message, context=context)
784         return res
785
786     def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
787         """
788         Handle partner assignation during a lead conversion.
789         if action is 'create', create new partner with contact and assign lead to new partner_id.
790         otherwise assign lead to the specified partner_id
791
792         :param list ids: leads/opportunities ids to process
793         :param string action: what has to be done regarding partners (create it, assign an existing one, or nothing)
794         :param int partner_id: partner to assign if any
795         :return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
796         """
797         #TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
798         partner_ids = {}
799         # If a partner_id is given, force this partner for all elements
800         force_partner_id = partner_id
801         for lead in self.browse(cr, uid, ids, context=context):
802             # If the action is set to 'create' and no partner_id is set, create a new one
803             if action == 'create':
804                 partner_id = force_partner_id or self._create_lead_partner(cr, uid, lead, context)
805             self._lead_set_partner(cr, uid, lead, partner_id, context=context)
806             partner_ids[lead.id] = partner_id
807         return partner_ids
808
809     def allocate_salesman(self, cr, uid, ids, user_ids=None, team_id=False, context=None):
810         """
811         Assign salesmen and salesteam to a batch of leads.  If there are more
812         leads than salesmen, these salesmen will be assigned in round-robin.
813         E.g.: 4 salesmen (S1, S2, S3, S4) for 6 leads (L1, L2, ... L6).  They
814         will be assigned as followed: L1 - S1, L2 - S2, L3 - S3, L4 - S4,
815         L5 - S1, L6 - S2.
816
817         :param list ids: leads/opportunities ids to process
818         :param list user_ids: salesmen to assign
819         :param int team_id: salesteam to assign
820         :return bool
821         """
822         index = 0
823
824         for lead_id in ids:
825             value = {}
826             if team_id:
827                 value['section_id'] = team_id
828             if user_ids:
829                 value['user_id'] = user_ids[index]
830                 # Cycle through user_ids
831                 index = (index + 1) % len(user_ids)
832             if value:
833                 self.write(cr, uid, [lead_id], value, context=context)
834         return True
835
836     def schedule_phonecall(self, cr, uid, ids, schedule_time, call_summary, desc, phone, contact_name, user_id=False, section_id=False, categ_id=False, action='schedule', context=None):
837         """
838         :param string action: ('schedule','Schedule a call'), ('log','Log a call')
839         """
840         phonecall = self.pool.get('crm.phonecall')
841         model_data = self.pool.get('ir.model.data')
842         phonecall_dict = {}
843         if not categ_id:
844             try:
845                 res_id = model_data._get_id(cr, uid, 'crm', 'categ_phone2')
846                 categ_id = model_data.browse(cr, uid, res_id, context=context).res_id
847             except ValueError:
848                 pass
849         for lead in self.browse(cr, uid, ids, context=context):
850             if not section_id:
851                 section_id = lead.section_id and lead.section_id.id or False
852             if not user_id:
853                 user_id = lead.user_id and lead.user_id.id or False
854             vals = {
855                 'name': call_summary,
856                 'opportunity_id': lead.id,
857                 'user_id': user_id or False,
858                 'categ_id': categ_id or False,
859                 'description': desc or '',
860                 'date': schedule_time,
861                 'section_id': section_id or False,
862                 'partner_id': lead.partner_id and lead.partner_id.id or False,
863                 'partner_phone': phone or lead.phone or (lead.partner_id and lead.partner_id.phone or False),
864                 'partner_mobile': lead.partner_id and lead.partner_id.mobile or False,
865                 'priority': lead.priority,
866             }
867             new_id = phonecall.create(cr, uid, vals, context=context)
868             phonecall.case_open(cr, uid, [new_id], context=context)
869             if action == 'log':
870                 phonecall.case_close(cr, uid, [new_id], context=context)
871             phonecall_dict[lead.id] = new_id
872             self.schedule_phonecall_send_note(cr, uid, [lead.id], new_id, action, context=context)
873         return phonecall_dict
874
875     def redirect_opportunity_view(self, cr, uid, opportunity_id, context=None):
876         models_data = self.pool.get('ir.model.data')
877
878         # Get opportunity views
879         dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_oppor')
880         dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_oppor')
881         return {
882             'name': _('Opportunity'),
883             'view_type': 'form',
884             'view_mode': 'tree, form',
885             'res_model': 'crm.lead',
886             'domain': [('type', '=', 'opportunity')],
887             'res_id': int(opportunity_id),
888             'view_id': False,
889             'views': [(form_view or False, 'form'),
890                     (tree_view or False, 'tree'),
891                     (False, 'calendar'), (False, 'graph')],
892             'type': 'ir.actions.act_window',
893         }
894
895     def redirect_lead_view(self, cr, uid, lead_id, context=None):
896         models_data = self.pool.get('ir.model.data')
897
898         # Get lead views
899         dummy, form_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_form_view_leads')
900         dummy, tree_view = models_data.get_object_reference(cr, uid, 'crm', 'crm_case_tree_view_leads')
901         return {
902             'name': _('Lead'),
903             'view_type': 'form',
904             'view_mode': 'tree, form',
905             'res_model': 'crm.lead',
906             'domain': [('type', '=', 'lead')],
907             'res_id': int(lead_id),
908             'view_id': False,
909             'views': [(form_view or False, 'form'),
910                       (tree_view or False, 'tree'),
911                       (False, 'calendar'), (False, 'graph')],
912             'type': 'ir.actions.act_window',
913         }
914
915     def action_makeMeeting(self, cr, uid, ids, context=None):
916         """
917         Open meeting's calendar view to schedule meeting on current opportunity.
918         :return dict: dictionary value for created Meeting view
919         """
920         opportunity = self.browse(cr, uid, ids[0], context)
921         res = self.pool.get('ir.actions.act_window').for_xml_id(cr, uid, 'base_calendar', 'action_crm_meeting', context)
922         res['context'] = {
923             'default_opportunity_id': opportunity.id,
924             'default_partner_id': opportunity.partner_id and opportunity.partner_id.id or False,
925             'default_partner_ids' : opportunity.partner_id and [opportunity.partner_id.id] or False,
926             'default_user_id': uid,
927             'default_section_id': opportunity.section_id and opportunity.section_id.id or False,
928             'default_email_from': opportunity.email_from,
929             'default_name': opportunity.name,
930         }
931         return res
932
933     def write(self, cr, uid, ids, vals, context=None):
934         if vals.get('stage_id'):
935             stage = self.pool.get('crm.case.stage').browse(cr, uid, vals['stage_id'], context=context)
936             if not vals.get('probability') and stage.on_change:
937                 # change probability of lead(s) if required by stage
938                 vals['probability'] = stage.probability
939             # set closed date when won or lost
940             if not vals.get('date_closed') and (vals.get('probability', 0) >= 100 or stage.state == 'canceled'):
941                 vals['date_closed'] = fields.datetime.now()
942         return super(crm_lead, self).write(cr, uid, ids, vals, context=context)
943
944     def copy(self, cr, uid, id, default=None, context=None):
945         if not default:
946             default = {}
947         if not context:
948             context = {}
949         lead = self.browse(cr, uid, id, context=context)
950         local_context = dict(context)
951         local_context.setdefault('default_type', lead.type)
952         local_context.setdefault('default_section_id', lead.section_id)
953         if lead.type == 'opportunity':
954             default['date_open'] = fields.datetime.now()
955         else:
956             default['date_open'] = False
957         default['date_closed'] = False
958         default['stage_id'] = self._get_default_stage_id(cr, uid, local_context)
959         return super(crm_lead, self).copy(cr, uid, id, default, context=context)
960
961     def new_mail_send(self, cr, uid, ids, context=None):
962         '''
963         This function opens a window to compose an email, with the edi sale template message loaded by default
964         '''
965         assert len(ids) == 1, 'This option should only be used for a single id at a time.'
966         ir_model_data = self.pool.get('ir.model.data')
967         try:
968             template_id = ir_model_data.get_object_reference(cr, uid, 'crm', 'email_template_opportunity_mail')[1]
969         except ValueError:
970             template_id = False
971         try:
972             compose_form_id = ir_model_data.get_object_reference(cr, uid, 'mail', 'email_compose_message_wizard_form')[1]
973         except ValueError:
974             compose_form_id = False
975         if context is None:
976             context = {}
977         ctx = context.copy()
978         ctx.update({
979             'default_model': 'crm.lead',
980             'default_res_id': ids[0],
981             'default_use_template': bool(template_id),
982             'default_template_id': template_id,
983             'default_composition_mode': 'comment',
984         })
985         return {
986             'type': 'ir.actions.act_window',
987             'view_type': 'form',
988             'view_mode': 'form',
989             'res_model': 'mail.compose.message',
990             'views': [(compose_form_id, 'form')],
991             'view_id': compose_form_id,
992             'target': 'new',
993             'context': ctx,
994         }
995
996     # ----------------------------------------
997     # Mail Gateway
998     # ----------------------------------------
999
1000     def message_get_reply_to(self, cr, uid, ids, context=None):
1001         """ Override to get the reply_to of the parent project. """
1002         return [lead.section_id.message_get_reply_to()[0] if lead.section_id else False
1003                     for lead in self.browse(cr, SUPERUSER_ID, ids, context=context)]
1004
1005     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1006         recipients = super(crm_lead, self).message_get_suggested_recipients(cr, uid, ids, context=context)
1007         try:
1008             for lead in self.browse(cr, uid, ids, context=context):
1009                 if lead.partner_id:
1010                     self._message_add_suggested_recipient(cr, uid, recipients, lead, partner=lead.partner_id, reason=_('Customer'))
1011                 elif lead.email_from:
1012                     self._message_add_suggested_recipient(cr, uid, recipients, lead, email=lead.email_from, reason=_('Customer Email'))
1013         except (osv.except_osv, orm.except_orm):  # no read access rights -> just ignore suggested recipients because this imply modifying followers
1014             pass
1015         return recipients
1016
1017     def message_new(self, cr, uid, msg, custom_values=None, context=None):
1018         """ Overrides mail_thread message_new that is called by the mailgateway
1019             through message_process.
1020             This override updates the document according to the email.
1021         """
1022         if custom_values is None:
1023             custom_values = {}
1024         desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
1025         defaults = {
1026             'name':  msg.get('subject') or _("No Subject"),
1027             'description': desc,
1028             'email_from': msg.get('from'),
1029             'email_cc': msg.get('cc'),
1030             'partner_id': msg.get('author_id', False),
1031             'user_id': False,
1032         }
1033         if msg.get('author_id'):
1034             defaults.update(self.on_change_partner(cr, uid, None, msg.get('author_id'), context=context)['value'])
1035         if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1036             defaults['priority'] = msg.get('priority')
1037         defaults.update(custom_values)
1038         return super(crm_lead, self).message_new(cr, uid, msg, custom_values=defaults, context=context)
1039
1040     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
1041         """ Overrides mail_thread message_update that is called by the mailgateway
1042             through message_process.
1043             This method updates the document according to the email.
1044         """
1045         if isinstance(ids, (str, int, long)):
1046             ids = [ids]
1047         if update_vals is None: update_vals = {}
1048
1049         if msg.get('priority') in dict(crm.AVAILABLE_PRIORITIES):
1050             update_vals['priority'] = msg.get('priority')
1051         maps = {
1052             'cost':'planned_cost',
1053             'revenue': 'planned_revenue',
1054             'probability':'probability',
1055         }
1056         for line in msg.get('body', '').split('\n'):
1057             line = line.strip()
1058             res = tools.command_re.match(line)
1059             if res and maps.get(res.group(1).lower()):
1060                 key = maps.get(res.group(1).lower())
1061                 update_vals[key] = res.group(2).lower()
1062
1063         return super(crm_lead, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
1064
1065     # ----------------------------------------
1066     # OpenChatter methods and notifications
1067     # ----------------------------------------
1068
1069     def schedule_phonecall_send_note(self, cr, uid, ids, phonecall_id, action, context=None):
1070         phonecall = self.pool.get('crm.phonecall').browse(cr, uid, [phonecall_id], context=context)[0]
1071         if action == 'log':
1072             message = _('Logged a call for %(date)s. %(description)s')
1073         else:
1074             message = _('Scheduled a call for %(date)s. %(description)s')
1075         phonecall_date = datetime.strptime(phonecall.date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
1076         phonecall_usertime = fields.datetime.context_timestamp(cr, uid, phonecall_date, context=context).strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1077         html_time = "<time datetime='%s+00:00'>%s</time>" % (phonecall.date, phonecall_usertime)
1078         message = message % dict(date=html_time, description=phonecall.description)
1079         return self.message_post(cr, uid, ids, body=message, context=context)
1080
1081     def log_meeting(self, cr, uid, ids, meeting_subject, meeting_date, duration, context=None):
1082         if not duration:
1083             duration = _('unknown')
1084         else:
1085             duration = str(duration)
1086         message = _("Meeting scheduled at '%s'<br> Subject: %s <br> Duration: %s hour(s)") % (meeting_date, meeting_subject, duration)
1087         return self.message_post(cr, uid, ids, body=message, context=context)
1088
1089     def onchange_state(self, cr, uid, ids, state_id, context=None):
1090         if state_id:
1091             country_id=self.pool.get('res.country.state').browse(cr, uid, state_id, context).country_id.id
1092             return {'value':{'country_id':country_id}}
1093         return {}
1094
1095 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: