diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index 555941ffeb7..7cd61f4baea 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -531,6 +531,7 @@ +
diff --git a/addons/event_crm/__init__.py b/addons/event_crm/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/event_crm/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/event_crm/__manifest__.py b/addons/event_crm/__manifest__.py new file mode 100644 index 00000000000..04bf5ebed5c --- /dev/null +++ b/addons/event_crm/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Event CRM', + 'version': '1.0', + 'category': 'Marketing/Events', + 'website': 'https://www.odoo.com/page/events', + 'description': "Create leads from event registrations.", + 'depends': ['event', 'crm'], + 'data': [ + 'security/event_crm_security.xml', + 'security/ir.model.access.csv', + 'views/crm_lead_views.xml', + 'views/event_registration_views.xml', + 'views/event_lead_rule_views.xml', + 'views/event_event_views.xml', + ], + 'demo': [ + 'data/event_crm_demo.xml', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/event_crm/data/event_crm_demo.xml b/addons/event_crm/data/event_crm_demo.xml new file mode 100644 index 00000000000..33767f7c379 --- /dev/null +++ b/addons/event_crm/data/event_crm_demo.xml @@ -0,0 +1,27 @@ + + + + + + Rule on @example.com + + [('email','ilike','@example.com')] + + + + + + Barney Lonny + barney.lonny@example.com + +1 202 555 0122 + + + + Tom Harper + tom.harper@example.com + +1 202 555 0161 + + + + + diff --git a/addons/event_crm/models/__init__.py b/addons/event_crm/models/__init__.py new file mode 100644 index 00000000000..1ddbf2c4d6b --- /dev/null +++ b/addons/event_crm/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import event_lead_rule +from . import event_registration +from . import crm_lead +from . import event_event diff --git a/addons/event_crm/models/crm_lead.py b/addons/event_crm/models/crm_lead.py new file mode 100644 index 00000000000..e51aadc104f --- /dev/null +++ b/addons/event_crm/models/crm_lead.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + + +class Lead(models.Model): + _inherit = 'crm.lead' + + event_lead_rule_id = fields.Many2one('event.lead.rule', string="Registration Rule", help="Rule that created this lead") + event_id = fields.Many2one('event.event', string="Source Event", help="Event triggering the rule that created this lead") + registration_ids = fields.Many2many( + 'event.registration', string="Source Registrations", + groups='event.group_event_user', + help="Registrations triggering the rule that created this lead") + registration_count = fields.Integer( + string="# Registrations", compute='_compute_registration_count', + groups='event.group_event_user', + help="Counter for the registrations linked to this lead") + + @api.depends('registration_ids') + def _compute_registration_count(self): + for record in self: + record.registration_count = len(record.registration_ids) diff --git a/addons/event_crm/models/event_event.py b/addons/event_crm/models/event_event.py new file mode 100644 index 00000000000..ac71958cd93 --- /dev/null +++ b/addons/event_crm/models/event_event.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + + +class EventEvent(models.Model): + _name = "event.event" + _inherit = "event.event" + + lead_ids = fields.One2many( + 'crm.lead', 'event_id', string="Leads", groups='sales_team.group_sale_salesman', + help="Leads generated from this event") + lead_count = fields.Integer( + string="# Leads", compute='_compute_lead_count', groups='sales_team.group_sale_salesman', + help="Counter for the leads linked to this event") + + @api.depends('lead_ids') + def _compute_lead_count(self): + lead_data = self.env['crm.lead'].read_group( + [('event_id', 'in', self.ids)], + ['event_id'], ['event_id'] + ) + mapped_data = {item['event_id'][0]: item['event_id_count'] for item in lead_data} + for event in self: + event.lead_count = mapped_data.get(event.id, 0) diff --git a/addons/event_crm/models/event_lead_rule.py b/addons/event_crm/models/event_lead_rule.py new file mode 100644 index 00000000000..47c425fef26 --- /dev/null +++ b/addons/event_crm/models/event_lead_rule.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from ast import literal_eval +from collections import defaultdict + +from odoo import fields, models, _ +from odoo.osv import expression + + +class EventLeadRule(models.Model): + """ Rule model for creating / updating leads from event registrations. + + SPECIFICATIONS: CREATION TYPE + + There are two types of lead creation: + + * per attendee: create a lead for each registration; + * per order: create a lead for a group of registrations; + + The last one is only available through interface if it is possible to register + a group of attendees in one action (when event_sale or website_event are + installed). Behavior itself is implemented directly in event_crm. + + Basically a group is either a list of registrations belonging to the same + event and created in batch (website_event flow). With event_sale this + definition will be improved to be based on sale_order. + + SPECIFICATIONS: CREATION TRIGGERS + + There are three options to trigger lead creation. We consider basically that + lead quality increases if attendees confirmed or went to the event. Triggers + allow therefore to run rules: + + * at attendee creation; + * at attendee confirmation; + * at attendee venue; + + This trigger defines when the rule will run. + + SPECIFICATIONS: FILTERING REGISTRATIONS + + When a batch of registrations matches the rule trigger we filter them based + on conditions and rules defines on event_lead_rule model. Heuristic is the + following: + + * the rule is active; + * if a filter is set: filter registrations based on this filter. This is + done like a search, and filter is a domain; + * if a company is set on the rule, it must match event's company. Note + that multi-company rules apply on event_lead_rule; + * if an event category it set, it must match; + * if an event is set, it must match; + * if both event and category are set, one of them must match (OR). If none + of those are set, it is considered as OK; + + If conditions are met, leads are created with pre-filled informations defined + on the rule (type, user_id, team_id). Contact information coming from the + registrations are computed (customer, name, email, phone, mobile, contact_name). + + SPECIFICATIONS: OTHER POINTS + + Note that all rules matching their conditions are applied. This means more + than one lead can be created depending on the configuration. This is + intended in order to give more freedom to the user using the automatic + lead generation. + """ + _name = "event.lead.rule" + _description = "Event Lead Rules" + + # Definition + name = fields.Char('Rule Name', required=True, translate=True) + active = fields.Boolean('Active', default=True) + lead_ids = fields.One2many( + 'crm.lead', 'event_lead_rule_id', string='Created Leads', + groups='sales_team.group_sale_salesman') + # Triggers + lead_creation_basis = fields.Selection([ + ('attendee', 'Per Attendee'), ('order', 'Per Order')], + string='Create', default='attendee', required=True, + help='Per Attendee : A Lead is created for each Attendee (B2C).\n' + 'Per Order : A single Lead is created per Ticket Batch/Sale Order (B2B)') + lead_creation_trigger = fields.Selection([ + ('create', 'Attendees are created'), + ('confirm', 'Attendees are confirmed'), + ('done', 'Attendees attended')], + string='When', default='create', required=True, + help='Creation: at attendee creation;\n' + 'Confirmation: when attendee is confirmed, manually or automatically;\n' + 'Attended: when attendance is confirmed and registration set to done;') + # Filters + event_type_ids = fields.Many2many( + 'event.type', string='Event Categories', + help='Filter the attendees to include those of this specific event category. If not set, no event category restriction will be applied.') + event_id = fields.Many2one( + 'event.event', string='Event', + domain="[('company_id', 'in', [company_id or current_company_id, False])]", + help='Filter the attendees to include those of this specific event. If not set, no event restriction will be applied.') + company_id = fields.Many2one( + 'res.company', string='Company', + help="Restrict the trigger of this rule to events belonging to a specific company.\nIf not set, no company restriction will be applied.") + event_registration_filter = fields.Text(string="Registrations Domain", help="Filter the attendees that will or not generate leads.") + # Lead default_value fields + lead_type = fields.Selection([ + ('lead', 'Lead'), ('opportunity', 'Opportunity')], string="Lead Type", required=True, + default=lambda self: 'lead' if self.env['res.users'].has_group('crm.group_use_lead') else 'opportunity', + help="Default lead type when this rule is applied.") + lead_sales_team_id = fields.Many2one('crm.team', string='Sales Team', help="Automatically assign the created leads to this Sales Team.") + lead_user_id = fields.Many2one('res.users', string='Salesperson', help="Automatically assign the created leads to this Salesperson.") + lead_tag_ids = fields.Many2many('crm.tag', string='Tags', help="Automatically add these tags to the created leads.") + + def _run_on_registrations(self, registrations): + """ Create or update leads based on rule configuration. Two main lead + management type exists + + * per attendee: each registration creates a lead; + * per order: registrations are grouped per group and one lead is created + or updated with the batch (used mainly with sale order configuration + in event_sale); + + Heuristic + + * first, check existing lead linked to registrations to ensure no + duplication. Indeed for example attendee status change may trigger + the same rule several times; + * then for each rule, get the subset of registrations matching its + filters; + * then for each order-based rule, get the grouping information. This + give a list of registrations by group (event, sale_order), with maybe + an already-existing lead to update instead of creating a new one; + * finally apply rules. Attendee-based rules create a lead for each + attendee, group-based rules use the grouping information to create + or update leads; + + :param registrations: event.registration recordset on which rules given by + self have to run. Triggers should already be checked, only filters are + applied here. + + :return leads: newly-created leads. Updated leads are not returned. + """ + # order by ID, ensure first created wins + registrations = registrations.sorted('id') + + # first: ensure no duplicate by searching existing registrations / rule + existing_leads = self.env['crm.lead'].search([ + ('registration_ids', 'in', registrations.ids), + ('event_lead_rule_id', 'in', self.ids) + ]) + rule_to_existing_regs = defaultdict(lambda: self.env['event.registration']) + for lead in existing_leads: + rule_to_existing_regs[lead.event_lead_rule_id] += lead.registration_ids + + # second: check registrations matching rules (in batch) + new_registrations = self.env['event.registration'] + rule_to_new_regs = dict() + for rule in self: + new_for_rule = registrations.filtered(lambda reg: reg not in rule_to_existing_regs[rule]) + rule_registrations = rule._filter_registrations(new_for_rule) + new_registrations |= rule_registrations + rule_to_new_regs[rule] = rule_registrations + new_registrations.sorted('id') # as an OR was used, re-ensure order + + # third: check grouping + order_based_rules = self.filtered(lambda rule: rule.lead_creation_basis == 'order') + rule_group_info = new_registrations._get_lead_grouping(order_based_rules, rule_to_new_regs) + + lead_vals_list = [] + for rule in self: + if rule.lead_creation_basis == 'attendee': + matching_registrations = rule_to_new_regs[rule].sorted('id') + for registration in matching_registrations: + lead_vals_list.append(registration._get_lead_values(rule)) + else: + # check if registrations are part of a group, for example a sale order, to know if we update or create leads + for (toupdate_leads, group_key, group_registrations) in rule_group_info[rule]: + if toupdate_leads: + additionnal_description = group_registrations._get_lead_description(_("New registrations"), line_counter=True) + for lead in toupdate_leads: + lead.write({ + 'description': "%s\n%s" % (lead.description, additionnal_description), + 'registration_ids': [(4, reg.id) for reg in group_registrations], + }) + else: + lead_vals_list.append(group_registrations._get_lead_values(rule)) + + return self.env['crm.lead'].create(lead_vals_list) + + def _filter_registrations(self, registrations): + """ Keep registrations matching rule conditions. Those are + + * if a filter is set: filter registrations based on this filter. This is + done like a search, and filter is a domain; + * if a company is set on the rule, it must match event's company. Note + that multi-company rules apply on event_lead_rule; + * if an event category it set, it must match; + * if an event is set, it must match; + * if both event and category are set, one of them must match (OR). If none + of those are set, it is considered as OK; + + :param registrations: event.registration recordset on which rule filters + will be evaluated; + :return: subset of registrations matching rules + """ + self.ensure_one() + if self.event_registration_filter and self.event_registration_filter != '[]': + registrations = registrations.search(expression.AND([ + [('id', 'in', registrations.ids)], + literal_eval(self.event_registration_filter) + ])) + + # check from direct m2o to linked m2o / o2m to filter first without inner search + company_ok = lambda registration: registration.company_id == self.company_id if self.company_id else True + event_or_event_type_ok = \ + lambda registration: \ + registration.event_id == self.event_id or registration.event_id.event_type_id in self.event_type_ids \ + if (self.event_id or self.event_type_ids) else True + + return registrations.filtered(lambda r: company_ok(r) and event_or_event_type_ok(r)) diff --git a/addons/event_crm/models/event_registration.py b/addons/event_crm/models/event_registration.py new file mode 100644 index 00000000000..a4b2d04edcd --- /dev/null +++ b/addons/event_crm/models/event_registration.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import api, fields, models, _ + + +class EventRegistration(models.Model): + _inherit = 'event.registration' + + lead_ids = fields.Many2many( + 'crm.lead', string='Leads', copy=False, readonly=True, + groups='sales_team.group_sale_salesman', + help="Leads generated from the registration.") + lead_count = fields.Integer( + '# Leads', compute='_compute_lead_count', groups='sales_team.group_sale_salesman', + help="Counter for the leads linked to this registration") + + @api.depends('lead_ids') + def _compute_lead_count(self): + for record in self: + record.lead_count = len(record.lead_ids) + + @api.model_create_multi + def create(self, vals_list): + """ Trigger rules based on registration creation, and check state for + rules based on confirmed / done attendees. """ + registrations = super(EventRegistration, self).create(vals_list) + + # handle triggers based on creation, then those based on confirm and done + # as registrations can be automatically confirmed, or even created directly + # with a state given in values + if not self.env.context.get('event_lead_rule_skip'): + self.env['event.lead.rule'].search([('lead_creation_trigger', '=', 'create')]).sudo()._run_on_registrations(registrations) + open_registrations = registrations.filtered(lambda reg: reg.state == 'open') + if open_registrations: + self.env['event.lead.rule'].search([('lead_creation_trigger', '=', 'confirm')]).sudo()._run_on_registrations(open_registrations) + done_registrations = registrations.filtered(lambda reg: reg.state == 'done') + if done_registrations: + self.env['event.lead.rule'].search([('lead_creation_trigger', '=', 'done')]).sudo()._run_on_registrations(done_registrations) + + return registrations + + def write(self, vals): + """ Update the lead values depending on fields updated in registrations. + There are 2 main use cases + + * first is when we update the partner_id of multiple registrations. It + happens when a public user fill its information when he register to + an event; + * second is when we update specific values of one registration like + updating question answers or a contact information (email, phone); + + Also trigger rules based on confirmed and done attendees (state written + to open and done). + """ + to_update, event_lead_rule_skip = False, self.env.context.get('event_lead_rule_skip') + if not event_lead_rule_skip: + to_update = self.filtered(lambda reg: reg.lead_ids) + if to_update: + lead_tracked_vals = to_update._get_lead_tracked_values() + + res = super(EventRegistration, self).write(vals) + + if not event_lead_rule_skip and to_update: + to_update.flush() # compute notably partner-based fields if necessary + to_update.sudo()._update_leads(vals, lead_tracked_vals) + + # handle triggers based on state + if not event_lead_rule_skip: + if vals.get('state') == 'open': + self.env['event.lead.rule'].search([('lead_creation_trigger', '=', 'confirm')]).sudo()._run_on_registrations(self) + elif vals.get('state') == 'done': + self.env['event.lead.rule'].search([('lead_creation_trigger', '=', 'done')]).sudo()._run_on_registrations(self) + + return res + + def _load_records_create(self, values): + """ In import mode: do not run rules those are intended to run when customers + buy tickets, not when bootstrapping a database. """ + return super(EventRegistration, self.with_context(event_lead_rule_skip=True))._load_records_create(values) + + def _load_records_write(self, values): + """ In import mode: do not run rules those are intended to run when customers + buy tickets, not when bootstrapping a database. """ + return super(EventRegistration, self.with_context(event_lead_rule_skip=True))._load_records_write(values) + + def _update_leads(self, new_vals, lead_tracked_vals): + """ Update leads linked to some registrations. Update is based depending + on updated fields, see ``_get_lead_contact_fields()`` and ``_get_lead_ + description_fields()``. Main heuristic is + + * check attendee-based leads, for each registration recompute contact + information if necessary (changing partner triggers the whole contact + computation); update description if necessary; + * check order-based leads, for each existing group-based lead, only + partner change triggers a contact and description update. We consider + that group-based rule works mainly with the main contact and less + with further details of registrations. Those can be found in stat + button if necessary. + + :param new_vals: values given to write. Used to determine updated fields; + :param lead_tracked_vals: dict(registration_id, registration previous values) + based on new_vals; + """ + for registration in self: + leads_attendee = registration.lead_ids.filtered( + lambda lead: lead.event_lead_rule_id.lead_creation_basis == 'attendee' + ) + if not leads_attendee: + continue + + old_vals = lead_tracked_vals[registration.id] + # if partner has been updated -> update registration contact information + # as they are computed (and therefore not given to write values) + if 'partner_id' in new_vals: + new_vals.update(**dict( + (field, registration[field]) + for field in self._get_lead_contact_fields() + if field != 'partner_id') + ) + + lead_values = {} + # update contact fields: valid for all leads of registration + upd_contact_fields = [field for field in self._get_lead_contact_fields() if field in new_vals.keys()] + if any(new_vals[field] != old_vals[field] for field in upd_contact_fields): + lead_values = registration._get_lead_contact_values() + + # update description fields: each lead has to be updated, otherwise + # update in batch + upd_description_fields = [field for field in self._get_lead_description_fields() if field in new_vals.keys()] + if any(new_vals[field] != old_vals[field] for field in upd_description_fields): + for lead in leads_attendee: + lead_values['description'] = "%s\n%s" % ( + lead.description, + registration._get_lead_description(_("Updated registrations"), line_counter=True) + ) + lead.write(lead_values) + elif lead_values: + leads_attendee.write(lead_values) + + leads_order = self.lead_ids.filtered(lambda lead: lead.event_lead_rule_id.lead_creation_basis == 'order') + for lead in leads_order: + lead_values = {} + if new_vals.get('partner_id'): + lead_values.update(lead.registration_ids._get_lead_contact_values()) + if not lead.partner_id: + lead_values['description'] = lead.registration_ids._get_lead_description(_("Participants"), line_counter=True) + elif new_vals['partner_id'] != lead.partner_id.id: + lead_values['description'] = lead.description + "\n" + lead.registration_ids._get_lead_description(_("Updated registrations"), line_counter=True, line_suffix=_("(updated)")) + if lead_values: + lead.write(lead_values) + + def _get_lead_values(self, rule): + """ Get lead values from registrations. Self can contain multiple records + in which case first found non void value is taken. Note that all + registrations should belong to the same event. + + :return dict lead_values: values used for create / write on a lead + """ + lead_values = { + # from rule + 'type': rule.lead_type, + 'user_id': rule.lead_user_id.id, + 'team_id': rule.lead_sales_team_id.id, + 'tag_ids': rule.lead_tag_ids.ids, + 'event_lead_rule_id': rule.id, + # event and registration + 'event_id': self.event_id.id, + 'referred': self.event_id.name, + 'registration_ids': self.ids, + 'campaign_id': self._find_first_notnull('utm_campaign_id'), + 'source_id': self._find_first_notnull('utm_source_id'), + 'medium_id': self._find_first_notnull('utm_medium_id'), + } + lead_values.update(self._get_lead_contact_values()) + lead_values['description'] = self._get_lead_description(_("Participants"), line_counter=True) + return lead_values + + def _get_lead_contact_values(self): + """ Specific management of contact values. Rule creation basis has some + effect on contact management + + * in attendee mode: keep registration partner only if partner phone and + email match. Indeed lead are synchronized with their contact and it + would imply rewriting on partner, and therefore on other documents; + * in batch mode: if a customer is found use it as main contact. Registrations + details are included in lead description; + + :return dict: values used for create / write on a lead + """ + valid_partner = related_partner = next( + (reg.partner_id for reg in self if reg.partner_id != self.env.ref('base.public_partner')), + self.env['res.partner'] + ) # CHECKME: broader than just public partner + + # mono registration mode: keep partner only if email and phone matches, otherwise registration > partner + if len(self) == 1: + if (related_partner.phone and self.phone and related_partner.phone != self.phone) or \ + (related_partner.email and self.email and related_partner.email != self.email): + valid_partner = self.env['res.partner'] + + if valid_partner: + contact_vals = self.env['crm.lead']._prepare_values_from_partner(valid_partner) + # force email_from / phone only if not set on partner because those fields are now synchronized automatically + if not valid_partner.email: + contact_vals['email_from'] = self._find_first_notnull('email') + if not valid_partner.phone: + contact_vals['email_from'] = self._find_first_notnull('phone') + else: + # don't force email_from + partner_id because those fields are now synchronized automatically + contact_vals = { + 'contact_name': self._find_first_notnull('name'), + 'email_from': self._find_first_notnull('email'), + 'phone': self._find_first_notnull('phone'), + } + contact_vals.update({ + 'name': "%s - %s" % (self.event_id.name, valid_partner.name or self._find_first_notnull('name') or self._find_first_notnull('email')), + 'partner_id': valid_partner.id, + 'mobile': valid_partner.mobile or self._find_first_notnull('mobile'), + }) + return contact_vals + + def _get_lead_description(self, prefix='', line_counter=True, line_suffix=''): + """ Build the description for the lead using a prefix for all generated + lines. For example to enumerate participants or inform of an update in + the information of a participant. + + :return string description: complete description for a lead taking into + account all registrations contained in self + """ + reg_lines = [ + registration._get_lead_description_registration( + prefix="%s. " % (index + 1) if line_counter else "", + line_suffix=line_suffix + ) for index, registration in enumerate(self) + ] + return ("%s\n" % prefix if prefix else "") + ("\n".join(reg_lines)) + + def _get_lead_description_registration(self, prefix='', line_suffix=''): + """ Build the description line specific to a given registration. """ + self.ensure_one() + return "%s%s (%s)%s" % ( + prefix or "", + self.name or self.partner_id.name or self.email, + " - ".join(self[field] for field in ('email', 'phone') if self[field]), + " %s" % line_suffix if line_suffix else "", + ) + + def _get_lead_tracked_values(self): + """ Tracked values are based on two subset of fields to track in order + to fill or update leads. Two main use cases are + + * description fields: registration contact fields: email, phone, ... + on registration. Other fields are added by inheritance like + question answers; + * contact fields: registration contact fields + partner_id field as + contact of a lead is managed specifically. Indeed email and phone + synchronization of lead / partner_id implies paying attention to + not rewrite partner values from registration values. + + Tracked values are therefore the union of those two field sets. """ + tracked_fields = list(set(self._get_lead_contact_fields()) or set(self._get_lead_description_fields())) + return dict( + (registration.id, + dict((field, self._convert_value(registration[field], field)) for field in tracked_fields) + ) for registration in self + ) + + def _get_lead_grouping(self, rules, rule_to_new_regs): + """ Perform grouping of registrations in order to enable order-based + lead creation and update existing groups with new registrations. + + Heuristic in event is the following. Registrations created in multi-mode + are grouped by event. Customer use case: website_event flow creates + several registrations in a create-multi. + + Update is not supported as there is no way to determine if a registration + is part of an existing batch. + + :param rules: lead creation rules to run on registrations given by self; + :param rule_to_new_regs: dict: for each rule, subset of self matching + rule conditions. Used to speedup batch computation; + + :return dict: for each rule, rule (key of dict) gives a list of groups. + Each group is a tuple ( + existing_lead: existing lead to update; + group_record: record used to group; + registrations: sub record set of self, containing registrations + belonging to the same group; + ) + """ + event_to_reg_ids = defaultdict(lambda: self.env['event.registration']) + for registration in self: + event_to_reg_ids[registration.event_id] += registration + + return dict( + (rule, [(False, event, (registrations & rule_to_new_regs[rule]).sorted('id')) + for event, registrations in event_to_reg_ids.items()]) + for rule in rules + ) + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + @api.model + def _get_lead_contact_fields(self): + """ Get registration fields linked to lead contact. Those are used notably + to see if an update of lead is necessary or to fill contact values + in ``_get_lead_contact_values())`` """ + return ['name', 'email', 'phone', 'mobile', 'partner_id'] + + @api.model + def _get_lead_description_fields(self): + """ Get registration fields linked to lead description. Those are used + notablyto see if an update of lead is necessary or to fill description + in ``_get_lead_description())`` """ + return ['name', 'email', 'phone'] + + def _find_first_notnull(self, field_name): + """ Small tool to extract the first not nullvalue of a field: its value + or the ids if this is a relational field. """ + value = next((reg[field_name] for reg in self if reg[field_name]), False) + return self._convert_value(value, field_name) + + def _convert_value(self, value, field_name): + """ Small tool because convert_to_write is touchy """ + if value and self._fields[field_name].type in ['many2many', 'one2many']: + return value.ids + if value and self._fields[field_name].type == 'many2one': + return value.id + return value diff --git a/addons/event_crm/security/event_crm_security.xml b/addons/event_crm/security/event_crm_security.xml new file mode 100644 index 00000000000..6c7c9f5293f --- /dev/null +++ b/addons/event_crm/security/event_crm_security.xml @@ -0,0 +1,11 @@ + + + + + Event CRM: Multi Company + + + ['|', ('company_id','=',False), ('company_id', 'in', company_ids)] + + + diff --git a/addons/event_crm/security/ir.model.access.csv b/addons/event_crm/security/ir.model.access.csv new file mode 100644 index 00000000000..c1ed5ff7d69 --- /dev/null +++ b/addons/event_crm/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_event_crm_manager,event.crm.manager,model_event_lead_rule,event.group_event_manager,1,1,1,1 +access_event_crm_user,event.crm.user,model_event_lead_rule,event.group_event_user,1,0,0,0 +access_event_crm_salesman,event.crm.user,model_event_lead_rule,sales_team.group_sale_salesman,1,0,0,0 diff --git a/addons/event_crm/tests/__init__.py b/addons/event_crm/tests/__init__.py new file mode 100644 index 00000000000..4d35e728d96 --- /dev/null +++ b/addons/event_crm/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_event_crm_flow diff --git a/addons/event_crm/tests/common.py b/addons/event_crm/tests/common.py new file mode 100644 index 00000000000..c27b82d21c0 --- /dev/null +++ b/addons/event_crm/tests/common.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.addons.event.tests.common import TestEventCommon + + +class TestEventCrmCommon(TestCrmCommon, TestEventCommon): + + @classmethod + def setUpClass(cls): + super(TestEventCrmCommon, cls).setUpClass() + + # avoid clash with existing rules + cls.env['event.lead.rule'].search([]).write({'active': False}) + + cls.test_lead_tag = cls.env['crm.tag'].create({'name': 'TagTest'}) + + cls.test_rule_attendee = cls.env['event.lead.rule'].create({ + 'name': 'Rule Attendee', + 'lead_creation_basis': 'attendee', + 'lead_creation_trigger': 'create', + 'event_id': cls.event_0.id, + 'event_registration_filter': [['email', 'ilike', '@test.example.com']], + 'lead_type': 'lead', + 'lead_user_id': cls.user_sales_salesman.id, + 'lead_tag_ids': cls.test_lead_tag, + }) + + cls.test_rule_order = cls.env['event.lead.rule'].create({ + 'name': 'Rule Order', + 'lead_creation_basis': 'order', + 'lead_creation_trigger': 'create', + 'event_id': cls.event_0.id, + 'event_registration_filter': [['email', 'ilike', '@test.example.com']], + 'lead_type': 'opportunity', + 'lead_user_id': cls.user_sales_leads.id, + 'lead_sales_team_id': cls.sales_team_1.id, + }) + cls.test_rule_order_done = cls.env['event.lead.rule'].create({ + 'name': 'Rule Order: confirmed partner only', + 'lead_creation_basis': 'order', + 'lead_creation_trigger': 'done', + 'event_registration_filter': [['partner_id', '!=', False]], + 'lead_type': 'opportunity', + }) + + cls.batch_customer_data = [{ + 'partner_id': cls.event_customer.id, + }] + [{ + 'name': 'My Customer 00', + 'partner_id': cls.event_customer2.id, + 'email': 'email.00@test.example.com', + 'phone': '0456000000', + }] + [{ + 'name': 'My Customer %02d' % x, + 'partner_id': cls.env.ref('base.public_partner').id if x == 0 else False, + 'email': 'email.%02d@test.example.com' % x, + 'phone': '04560000%02d' % x, + } for x in range(1, 4)] + + def assertLeadConvertion(self, rule, registrations, partner=None, **expected): + """ Tool method hiding details of lead value generation and check + + :param lead: lead created through automated rule; + :param rule: event.lead.rule that created the lead; + :param event: original event; + :param registrations: source registrations (singleton or record set if done in batch); + :param partner: partner on lead; + """ + registrations = registrations.sorted('id') # currently order is forced to id ASC + lead = self.env['crm.lead'].sudo().search([ + ('registration_ids', 'in', registrations.ids), + ('event_lead_rule_id', '=', rule.id) + ]) + self.assertEqual(len(lead), 1, 'Invalid registrations -> lead creation, found %s leads where only 1 is expected.' % len(lead)) + self.assertEqual(lead.registration_ids, registrations, 'Invalid registrations -> lead creation, too much registrations on it.') + event = registrations.event_id + self.assertEqual(len(event), 1, 'Invalid registrations -> event assertion, all registrations should belong to same event') + + if partner is None: + partner = self.env['res.partner'] + expected_reg_name = partner.name or registrations._find_first_notnull('name') or registrations._find_first_notnull('email') + if partner: + expected_contact_name = partner.name if not partner.is_company else False + expected_partner_name = partner.name if partner.is_company else False + else: + expected_contact_name = registrations._find_first_notnull('name') + expected_partner_name = False + + # event information + self.assertEqual(lead.event_id, event) + self.assertEqual(lead.referred, event.name) + + # registration information + self.assertEqual(lead.partner_id, partner) + self.assertEqual(lead.name, '%s - %s' % (event.name, expected_reg_name)) + self.assertNotIn('False', lead.name) # avoid a "Dear False" like construct ^^ (this assert is serious and intended) + + self.assertEqual(lead.contact_name, expected_contact_name) + self.assertEqual(lead.partner_name, expected_partner_name) + self.assertEqual(lead.email_from, partner.email if partner else registrations._find_first_notnull('email')) + self.assertEqual(lead.phone, partner.phone if partner else registrations._find_first_notnull('phone')) + self.assertEqual(lead.mobile, partner.mobile if partner and partner.mobile else registrations._find_first_notnull('mobile')) + + # description: to improve + self.assertNotIn('False', lead.description) # avoid a "Dear False" like construct ^^ (this assert is serious and intended) + for registration in registrations: + if registration.name: + self.assertIn(registration.name, lead.description) + elif registration.partner_id.name: + self.assertIn(registration.partner_id.name, lead.description) + if registration.email: + self.assertIn(registration.email, lead.description) + if registration.phone: + self.assertIn(registration.phone, lead.description) + + # lead configuration + self.assertEqual(lead.type, rule.lead_type) + self.assertEqual(lead.user_id, rule.lead_user_id) + self.assertEqual(lead.team_id, rule.lead_sales_team_id) + self.assertEqual(lead.tag_ids, rule.lead_tag_ids) diff --git a/addons/event_crm/tests/test_event_crm_flow.py b/addons/event_crm/tests/test_event_crm_flow.py new file mode 100644 index 00000000000..6f405825fa5 --- /dev/null +++ b/addons/event_crm/tests/test_event_crm_flow.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.event_crm.tests.common import TestEventCrmCommon +from odoo.tests.common import users +from odoo.tools import mute_logger + + +class TestEventCrmFlow(TestEventCrmCommon): + + @classmethod + def setUpClass(cls): + super(TestEventCrmFlow, cls).setUpClass() + + cls.registration_values = [ + dict(customer_data, event_id=cls.event_0.id) + for customer_data in cls.batch_customer_data + ] + cls.registration_values[-1]['email'] = '"John Doe" ' + + def assert_initial_data(self): + self.assertEqual(len(self.registration_values), 5) + self.assertEqual(self.event_customer.email_normalized, 'constantin@test.example.com') + self.assertEqual(self.event_customer.phone, '0485112233') + self.assertFalse(self.event_customer.mobile) + + @users('user_eventmanager') + def test_event_crm_flow_batch_create(self): + """ Test attendee- and order-based registrations creation. Event-based + creation mimics a simplified website_event flow where grouping is done + at create. """ + new_registrations = self.env['event.registration'].create(self.registration_values) + self.assertEqual(len(self.event_0.registration_ids), 5) + + # per-attendee rule: one lead for each registration + self.assertEqual(len(self.test_rule_attendee.lead_ids), 4) + for registration in new_registrations: + lead = self.test_rule_attendee.lead_ids.filtered(lambda lead: registration in lead.registration_ids) + # test filtering out based on domain + if registration.email == '"John Doe" ': + self.assertEqual(lead, self.env['crm.lead']) + continue + + # only partner with matching email / phone is kept in mono attendee mode to avoid + # loosing registration-specific email / phone informations due to lead synchronization + expected_partner = registration.partner_id if registration.partner_id == self.event_customer else None + self.assertTrue(bool(lead)) + self.assertLeadConvertion(self.test_rule_attendee, registration, partner=expected_partner) + + # per-order rule: one lead for all registrations (same event -> same batch, website_event style) + self.assertEqual(len(self.test_rule_order.lead_ids), 1) + lead = self.test_rule_order.lead_ids + self.assertLeadConvertion( + self.test_rule_order, + new_registrations.filtered(lambda reg: reg.email != '"John Doe" '), + partner=new_registrations[0].partner_id + ) + # ensuring filtering out worked also at description level + self.assertNotIn('invalid@not.example.com', lead.description) + + @users('user_eventmanager') + def test_event_crm_flow_batch_update(self): + """ Test update of contact or description fields that leads to lead + update. """ + # initial data: create registrations in batch + new_registrations = self.env['event.registration'].create(self.registration_values) + self.assertEqual(len(self.event_0.registration_ids), 5) + self.assertEqual(len(self.test_rule_attendee.lead_ids), 4) + self.assertEqual(len(self.test_rule_order.lead_ids), 1) + + # customer is updated (like a SO setting its customer) + new_registrations.write({'partner_id': self.event_customer2.id}) + + # per-attendee rule + self.assertEqual(len(self.test_rule_attendee.lead_ids), 4) + for registration in new_registrations: + lead = self.test_rule_attendee.lead_ids.filtered(lambda lead: registration in lead.registration_ids) + # test filtering out based on domain + if registration.email == '"John Doe" ': + self.assertEqual(lead, self.env['crm.lead']) + continue + + # only partner with matching email / phone is kept in mono attendee mode to avoid + # loosing registration-specific email / phone informations due to lead synchronization + self.assertLeadConvertion(self.test_rule_attendee, registration, partner=None) + + # per-order rule + self.assertEqual(len(self.test_rule_order.lead_ids), 1) + self.assertEqual(self.test_rule_order.lead_ids.event_id, self.event_0) + lead = self.test_rule_order.lead_ids + self.assertLeadConvertion( + self.test_rule_order, + new_registrations.filtered(lambda reg: reg.email != '"John Doe" '), + partner=new_registrations[0].partner_id + ) + # ensuring filtering out worked also at description level + self.assertNotIn('invalid@not.example.com', lead.description) + + @users('user_eventmanager') + def test_event_crm_flow_per_attendee_single(self): + self.assert_initial_data() + + # test: partner-based contact information, everything synchonized + registration = self.env['event.registration'].create({ + 'partner_id': self.event_customer.id, + 'event_id': self.event_0.id, + }) + self.assertEqual(registration.email, self.event_customer.email) + self.assertEqual(registration.phone, self.event_customer.phone) + + # per-attendee rule + self.assertLeadConvertion(self.test_rule_attendee, registration, partner=registration.partner_id) + + # test: no partner and contact information + registration = self.env['event.registration'].create({ + 'name': 'My Registration', + 'partner_id': False, + 'email': 'super.email@test.example.com', + 'phone': False, + 'mobile': '0456332211', + 'event_id': self.event_0.id, + }) + self.assertEqual(len(self.event_0.registration_ids), 2) + self.assertLeadConvertion(self.test_rule_attendee, registration, partner=None) + + # test: no partner and few information + registration = self.env['event.registration'].create({ + 'name': False, + 'partner_id': False, + 'email': 'giga.email@test.example.com', + 'phone': False, + 'mobile': False, + 'event_id': self.event_0.id, + }) + self.assertEqual(len(self.event_0.registration_ids), 3) + self.assertLeadConvertion(self.test_rule_attendee, registration, partner=None) + + # test: partner but with other contact information -> registration prior + registration = self.env['event.registration'].create({ + 'partner_id': self.event_customer.id, + 'email': 'other.email@test.example.com', + 'phone': False, + 'mobile': '0456112233', + 'event_id': self.event_0.id, + }) + self.assertEqual(len(self.event_0.registration_ids), 4) + self.assertLeadConvertion(self.test_rule_attendee, registration, partner=None) diff --git a/addons/event_crm/views/crm_lead_views.xml b/addons/event_crm/views/crm_lead_views.xml new file mode 100644 index 00000000000..6109f03250a --- /dev/null +++ b/addons/event_crm/views/crm_lead_views.xml @@ -0,0 +1,60 @@ + + + + Event registrations + ir.actions.act_window + event.registration + tree,kanban,form,calendar,graph + [('lead_ids', '=', active_id)] + {'create': False} + +

+ No registration found +

+
+
+ + + crm.lead.view.form.inherit.event.crm + crm.lead + + + + + + + + + + + Leads + crm.lead + tree,kanban,graph,pivot,calendar,form,activity + [('registration_ids', 'in', active_id)] + {'create': False} + +

+ No leads found +

+
+
+ + + Leads + crm.lead + tree,kanban,graph,pivot,calendar,form,activity + [('event_id', '=', active_id)] + {'create': False} + +

+ No leads found +

+
+
+
diff --git a/addons/event_crm/views/event_event_views.xml b/addons/event_crm/views/event_event_views.xml new file mode 100644 index 00000000000..7bc29ce1ccf --- /dev/null +++ b/addons/event_crm/views/event_event_views.xml @@ -0,0 +1,30 @@ + + + + event.event.form.inherit.event.crm + event.event + + + + + + + + + + event.event.tree.inherit.event.crm + event.event + + + + + + + + diff --git a/addons/event_crm/views/event_lead_rule_views.xml b/addons/event_crm/views/event_lead_rule_views.xml new file mode 100644 index 00000000000..fb15ea6a7e3 --- /dev/null +++ b/addons/event_crm/views/event_lead_rule_views.xml @@ -0,0 +1,98 @@ + + + + + event.lead.rule.view.search + event.lead.rule + + + + + + + + + + + + + event.lead.rule.view.tree + event.lead.rule + + + + + + + + + + + + + + event.lead.rule.view.form + event.lead.rule + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Lead Generation Rule + ir.actions.act_window + event.lead.rule + tree,form + +

Create a Lead Generation Rule

+

Those automatically create leads when attendees register.

+
+
+ + +
+
diff --git a/addons/event_crm/views/event_registration_views.xml b/addons/event_crm/views/event_registration_views.xml new file mode 100644 index 00000000000..d9eda6222e6 --- /dev/null +++ b/addons/event_crm/views/event_registration_views.xml @@ -0,0 +1,19 @@ + + + + event.registration.form.inherit.event.crm + event.registration + + + + + + + + diff --git a/addons/event_crm_sale/__init__.py b/addons/event_crm_sale/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/event_crm_sale/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/event_crm_sale/__manifest__.py b/addons/event_crm_sale/__manifest__.py new file mode 100644 index 00000000000..566aea08da3 --- /dev/null +++ b/addons/event_crm_sale/__manifest__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Event CRM Sale', + 'version': '1.0', + 'category': 'Marketing/Events', + 'website': 'https://www.odoo.com/page/events', + 'description': "Add information of sale order linked to the registration for the creation of the lead.", + 'depends': ['event_crm', 'event_sale'], + 'data': [ + 'views/event_lead_rule_views.xml', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/event_crm_sale/models/__init__.py b/addons/event_crm_sale/models/__init__.py new file mode 100644 index 00000000000..20ce9ac3fb4 --- /dev/null +++ b/addons/event_crm_sale/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import event_registration diff --git a/addons/event_crm_sale/models/event_registration.py b/addons/event_crm_sale/models/event_registration.py new file mode 100644 index 00000000000..480d4a0e41f --- /dev/null +++ b/addons/event_crm_sale/models/event_registration.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import models + + +class EventRegistration(models.Model): + _inherit = 'event.registration' + + def _get_lead_grouping(self, rules, rule_to_new_regs): + """ Override to support sale-order based grouping and update. + + When checking for groups for rules, we search for existing leads linked + to same group (based on sale_order_id) and rule. Each rule can therefore + update an existing lead or create a new one, for each sale order that + makes the group. """ + so_registrations = self.filtered(lambda reg: reg.sale_order_id) + grouping_res = super(EventRegistration, self - so_registrations)._get_lead_grouping(rules, rule_to_new_regs) + + if so_registrations: + # find existing leads in batch to put them in cache and avoid multiple search / queries + related_registrations = self.env['event.registration'].search([ + ('sale_order_id', 'in', so_registrations.sale_order_id.ids) + ]) + related_leads = self.env['crm.lead'].search([ + ('event_lead_rule_id', 'in', rules.ids), + ('registration_ids', 'in', related_registrations.ids) + ]) + + for rule in rules: + rule_new_regs = rule_to_new_regs[rule] + + # for each group (sale_order), find its linked registrations + so_to_regs = defaultdict(lambda: self.env['event.registration']) + for registration in rule_new_regs & so_registrations: + so_to_regs[registration.sale_order_id] |= registration + + # for each grouped registrations, prepare result with group and existing lead + so_res = [] + for sale_order, registrations in so_to_regs.items(): + registrations = registrations.sorted('id') # as an OR was used, re-ensure order + leads = related_leads.filtered(lambda lead: lead.event_lead_rule_id == rule and lead.registration_ids.sale_order_id == sale_order) + so_res.append((leads, sale_order, registrations)) + if so_res: + grouping_res[rule] = grouping_res.get(rule, list()) + so_res + + return grouping_res diff --git a/addons/event_crm_sale/views/event_lead_rule_views.xml b/addons/event_crm_sale/views/event_lead_rule_views.xml new file mode 100644 index 00000000000..e00481c8566 --- /dev/null +++ b/addons/event_crm_sale/views/event_lead_rule_views.xml @@ -0,0 +1,23 @@ + + + + event.lead.rule.view.tree.inherit.event.crm.sale + event.lead.rule + + + + 0 + + + + + event.lead.rule.view.form.inherit.event.crm.sale + event.lead.rule + + + + 0 + + + + diff --git a/addons/event_sale/__manifest__.py b/addons/event_sale/__manifest__.py index a889c3765df..2121211f61c 100644 --- a/addons/event_sale/__manifest__.py +++ b/addons/event_sale/__manifest__.py @@ -2,7 +2,7 @@ { 'name': 'Events Sales', - 'version': '1.1', + 'version': '1.2', 'category': 'Marketing/Events', 'website': 'https://www.odoo.com/page/events', 'description': """ diff --git a/addons/event_sale/models/event_registration.py b/addons/event_sale/models/event_registration.py index 999ae7ef2f1..6ff16fa1c31 100644 --- a/addons/event_sale/models/event_registration.py +++ b/addons/event_sale/models/event_registration.py @@ -10,8 +10,8 @@ class EventRegistration(models.Model): is_paid = fields.Boolean('Is Paid') # TDE FIXME: maybe add an onchange on sale_order_id - sale_order_id = fields.Many2one('sale.order', string='Source Sales Order', ondelete='cascade') - sale_order_line_id = fields.Many2one('sale.order.line', string='Sales Order Line', ondelete='cascade') + sale_order_id = fields.Many2one('sale.order', string='Source Sales Order', ondelete='cascade', copy=False) + sale_order_line_id = fields.Many2one('sale.order.line', string='Sales Order Line', ondelete='cascade', copy=False) payment_status = fields.Selection(string="Payment Status", selection=[ ('to_pay', 'Not Paid'), ('paid', 'Paid'), diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index 28426520a18..6f82ca37a39 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -71,8 +71,9 @@ class SaleOrderLine(models.Model): order line has a product_uom_qty attribute that will be the number of registrations linked to this line. This method update existing registrations and create new one for missing one. """ - Registration = self.env['event.registration'].sudo() - registrations = Registration.search([('sale_order_line_id', 'in', self.ids)]) + RegistrationSudo = self.env['event.registration'].sudo() + registrations = RegistrationSudo.search([('sale_order_line_id', 'in', self.ids)]) + registrations_vals = [] for so_line in self.filtered('event_id'): existing_registrations = registrations.filtered(lambda self: self.sale_order_line_id.id == so_line.id) if confirm: @@ -82,7 +83,6 @@ class SaleOrderLine(models.Model): if cancel_to_draft: existing_registrations.filtered(lambda self: self.state == 'cancel').action_set_draft() - registrations_vals = [] for count in range(int(so_line.product_uom_qty) - len(existing_registrations)): values = { 'sale_order_line_id': so_line.id, @@ -92,7 +92,9 @@ class SaleOrderLine(models.Model): if registration_data: values.update(registration_data.pop()) registrations_vals.append(values) - Registration.create(registrations_vals) + + if registrations_vals: + RegistrationSudo.create(registrations_vals) return True @api.onchange('product_id') diff --git a/addons/event_sale/views/event_registration_views.xml b/addons/event_sale/views/event_registration_views.xml index 9963d2d03aa..fcaf61ebfbf 100644 --- a/addons/event_sale/views/event_registration_views.xml +++ b/addons/event_sale/views/event_registration_views.xml @@ -30,22 +30,22 @@
- + event.registration.form.inherit event.registration + + + -
- -
- + diff --git a/addons/event_sale/wizard/event_edit_registration.xml b/addons/event_sale/wizard/event_edit_registration.xml index d897db8cca5..54f9d578a0f 100644 --- a/addons/event_sale/wizard/event_edit_registration.xml +++ b/addons/event_sale/wizard/event_edit_registration.xml @@ -20,8 +20,8 @@
-
diff --git a/addons/test_event_full/__init__.py b/addons/test_event_full/__init__.py new file mode 100644 index 00000000000..66a1383e8f3 --- /dev/null +++ b/addons/test_event_full/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import tests diff --git a/addons/test_event_full/__manifest__.py b/addons/test_event_full/__manifest__.py new file mode 100644 index 00000000000..fb1d94f0dc4 --- /dev/null +++ b/addons/test_event_full/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Test Full Event Flow', + 'version': '1.0', + 'category': 'Hidden/Tests', + 'description': """ +This module will test the main event flows of Odoo, both frontend and backend. +It installs sale capabilities, front-end flow, eCommerce, questions and +automatic lead generation.. +""", + 'depends': [ + 'event', + 'event_crm', + 'event_sale', + 'website_event_crm_questions', + 'website_event_questions', + 'website_event_sale', + 'website_event_track', + ], + 'demo': [], + 'data': [], + 'installable': True, +} diff --git a/addons/test_event_full/tests/__init__.py b/addons/test_event_full/tests/__init__.py new file mode 100644 index 00000000000..b3be9a3b6f4 --- /dev/null +++ b/addons/test_event_full/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_event_crm \ No newline at end of file diff --git a/addons/test_event_full/tests/common.py b/addons/test_event_full/tests/common.py new file mode 100644 index 00000000000..baa2a396568 --- /dev/null +++ b/addons/test_event_full/tests/common.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.event_crm.tests.common import TestEventCrmCommon + + +class TestEventFullCommon(TestEventCrmCommon): + + @classmethod + def setUpClass(cls): + super(TestEventFullCommon, cls).setUpClass() + + cls.event_product = cls.env['product.product'].create({ + 'name': 'Test Registration Product', + 'description_sale': 'Mighty Description', + 'list_price': 10, + 'event_ok': True, + 'standard_price': 30.0, + 'type': 'service', + }) + + cls.event_0.write({ + 'event_ticket_ids': [ + (5, 0), + (0, 0, { + 'name': 'First Ticket', + 'product_id': cls.event_product.id, + 'seats_max': 30, + }), (0, 0, { + 'name': 'Second Ticket', + 'product_id': cls.event_product.id, + }) + ], + }) + + cls.event_question_1 = cls.env['event.question'].create({ + 'title': 'Question1', + 'question_type': 'simple_choice', + 'event_id': cls.event_0.id, + 'once_per_order': False, + 'answer_ids': [ + (0, 0, {'name': 'Q1-Answer1'}), + (0, 0, {'name': 'Q1-Answer2'}) + ], + }) + cls.event_question_2 = cls.env['event.question'].create({ + 'title': 'Question2', + 'question_type': 'simple_choice', + 'event_id': cls.event_0.id, + 'once_per_order': True, + 'answer_ids': [ + (0, 0, {'name': 'Q2-Answer1'}), + (0, 0, {'name': 'Q2-Answer2'}) + ], + }) + cls.event_question_3 = cls.env['event.question'].create({ + 'title': 'Question3', + 'question_type': 'text_box', + 'event_id': cls.event_0.id, + 'once_per_order': True, + }) + + # make a SO for a customer, selling some tickets + cls.customer_so = cls.env['sale.order'].with_user(cls.user_sales_salesman).create({ + 'partner_id': cls.event_customer.id, + }) + + cls.website_customer_data = [{ + 'name': 'My Customer %02d' % x, + 'partner_id': cls.env.ref('base.public_partner').id, + 'email': 'email.%02d@test.example.com' % x, + 'phone': '04560000%02d' % x, + 'registration_answer_ids': [ + (0, 0, { + 'question_id': cls.event_question_1.id, + 'value_answer_id': cls.event_question_1.answer_ids[(x % 2)].id, + }), (0, 0, { + 'question_id': cls.event_question_2.id, + 'value_answer_id': cls.event_question_2.answer_ids[(x % 2)].id, + }), (0, 0, { + 'question_id': cls.event_question_3.id, + 'value_text_box': 'CustomerAnswer%s' % x, + }) + ], + } for x in range(0, 4)] + + def assertLeadConvertion(self, rule, registrations, partner=None, **expected): + super(TestEventFullCommon, self).assertLeadConvertion(rule, registrations, partner=partner, **expected) + lead = self.env['crm.lead'].sudo().search([ + ('registration_ids', 'in', registrations.ids), + ('event_lead_rule_id', '=', rule.id) + ]) + + for registration in registrations: + if not registration.registration_answer_ids: + continue + for answer in registration.registration_answer_ids: + self.assertIn(answer.question_id.title, lead.description) + if answer.question_type == 'simple_choice': + self.assertIn(answer.value_answer_id.name, lead.description) + else: + self.assertIn(answer.value_text_box, lead.description) # better: check multi line diff --git a/addons/test_event_full/tests/test_event_crm.py b/addons/test_event_full/tests/test_event_crm.py new file mode 100644 index 00000000000..5da23531480 --- /dev/null +++ b/addons/test_event_full/tests/test_event_crm.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.test_event_full.tests.common import TestEventFullCommon +from odoo.tests import users + + +class TestEventCrm(TestEventFullCommon): + + @classmethod + def setUpClass(cls): + super(TestEventCrm, cls).setUpClass() + + cls.TICKET1_COUNT, cls.TICKET2_COUNT = 3, 1 + ticket1 = cls.event_0.event_ticket_ids[0] + ticket2 = cls.event_0.event_ticket_ids[1] + + # PREPARE SO DATA + # ------------------------------------------------------------ + + # adding some tickets to SO + cls.customer_so.write({ + 'order_line': [ + (0, 0, { + 'event_id': cls.event_0.id, + 'event_ticket_id': ticket1.id, + 'product_id': ticket1.product_id.id, + 'product_uom_qty': cls.TICKET1_COUNT, + }), (0, 0, { + 'event_id': cls.event_0.id, + 'event_ticket_id': ticket2.id, + 'product_id': ticket2.product_id.id, + 'product_uom_qty': cls.TICKET2_COUNT, + 'price_unit': 50, + }) + ] + }) + + @users('user_sales_salesman') + def test_event_crm_sale_customer(self): + """ Test a SO with a real customer set on it, check partner propagation + as well as group-based lead update. """ + customer_so = self.env['sale.order'].browse(self.customer_so.id) + + # adding some tickets to SO + t1_reg_vals = [ + dict(customer_data, + partner_id=customer_so.partner_id.id, + sale_order_line_id=customer_so.order_line[0].id) + for customer_data in self.website_customer_data[:self.TICKET1_COUNT] + ] + t1_registrations = self.env['event.registration'].create(t1_reg_vals) + + # check effect: registrations, leads + self.assertEqual(self.event_0.registration_ids, t1_registrations) + self.assertEqual(len(self.test_rule_order.lead_ids), 1) + self.assertEqual(self.test_rule_order_done.lead_ids, self.env['crm.lead']) + # check lead converted based on registrations + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=customer_so.partner_id) + + # SO is confirmed -> missing registrations should be automatically added + # and added to the lead as part of the same group + customer_so.action_confirm() + self.assertEqual(customer_so.state, 'sale') + self.assertEqual(len(self.event_0.registration_ids), self.TICKET1_COUNT + self.TICKET2_COUNT) + self.assertEqual(len(self.test_rule_order.lead_ids), 1) # no new lead created + self.assertEqual(self.test_rule_order_done.lead_ids, self.env['crm.lead']) # this one still not triggered + + # check existing lead has been updated with new registrations + self.assertLeadConvertion(self.test_rule_order, self.event_0.registration_ids, partner=customer_so.partner_id) + + # Confirm registrations -> trigger the "DONE" rule, one new lead linked to all + # event registrations created in this test as all belong to the same SO + self.event_0.registration_ids.write({'state': 'done'}) + self.assertLeadConvertion(self.test_rule_order_done, self.event_0.registration_ids, partner=customer_so.partner_id) + + @users('user_sales_salesman') + def test_event_crm_sale_mixed_group(self): + """ Test a mixed sale order line creation. This should not happen in a customer + use case but should be supported by the code. """ + public_partner = self.env.ref('base.public_partner') + public_so = self.env['sale.order'].create({ + 'partner_id': public_partner.id, + 'order_line': [ + (0, 0, { + 'event_id': self.event_0.id, + 'event_ticket_id': self.event_0.event_ticket_ids[0].id, + 'product_id': self.event_0.event_ticket_ids[0].product_id.id, + 'product_uom_qty': 2, + }) + ] + }) + customer_so = self.env['sale.order'].browse(self.customer_so.id) + + # make a multi-SO create + mixed_reg_vals = [ + dict(self.website_customer_data[0], + partner_id=customer_so.partner_id.id, + sale_order_line_id=customer_so.order_line[0].id), + dict(self.website_customer_data[1], + partner_id=customer_so.partner_id.id, + sale_order_line_id=customer_so.order_line[0].id), + dict(self.website_customer_data[2], + partner_id=public_so.partner_id.id, + sale_order_line_id=public_so.order_line[0].id), + dict(self.website_customer_data[3], + partner_id=public_so.partner_id.id, + sale_order_line_id=public_so.order_line[0].id), + ] + self.env['event.registration'].create(mixed_reg_vals) + + public_regs = self.event_0.registration_ids.filtered(lambda reg: reg.sale_order_id == public_so) + self.assertEqual(len(public_regs), 2) + customer_regs = self.event_0.registration_ids.filtered(lambda reg: reg.sale_order_id == customer_so) + self.assertEqual(len(customer_regs), 2) + self.assertLeadConvertion(self.test_rule_order, public_regs, partner=None) + self.assertLeadConvertion(self.test_rule_order, customer_regs, partner=customer_so.partner_id) + + @users('user_sales_salesman') + def test_event_crm_sale_public(self): + """ Test a SO with a public partner on it, then updated when SO is confirmed. + This somehow simulates a simplified website_event_sale flow. """ + public_partner = self.env.ref('base.public_partner') + customer_so = self.env['sale.order'].browse(self.customer_so.id) + customer_so.write({'partner_id': public_partner.id}) + + # adding some tickets to SO + t1_reg_vals = [ + dict(customer_data, + partner_id=public_partner.id, + sale_order_line_id=customer_so.order_line[0].id) + for customer_data in self.website_customer_data[:self.TICKET1_COUNT] + ] + t1_registrations = self.env['event.registration'].create(t1_reg_vals) + self.assertEqual(self.event_0.registration_ids, t1_registrations) + + # check lead converted based on registrations + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=None) + + # SO is confirmed -> missing registrations should be automatically added + # BUT as public user -> no email -> not taken into account by rule + customer_so.action_confirm() + self.assertEqual(customer_so.state, 'sale') + self.assertEqual(len(self.event_0.registration_ids), self.TICKET1_COUNT + self.TICKET2_COUNT) + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=None) + + # SO has a customer set -> main contact of lead is updated accordingly + customer_so.write({'partner_id': self.event_customer.id}) + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=self.event_customer) diff --git a/addons/website_event_crm/__manifest__.py b/addons/website_event_crm/__manifest__.py new file mode 100644 index 00000000000..61860e7de16 --- /dev/null +++ b/addons/website_event_crm/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Website Events CRM', + 'version': '1.0', + 'category': 'Website/Website', + 'website': 'https://www.odoo.com/page/events', + 'description': "Allow per-order lead creation mode", + 'depends': ['event_crm', 'website_event'], + 'data': [ + 'views/event_lead_rule_views.xml', + ], + 'demo': [ + 'data/event_crm_demo.xml', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/website_event_crm/data/event_crm_demo.xml b/addons/website_event_crm/data/event_crm_demo.xml new file mode 100644 index 00000000000..54d78ad5cba --- /dev/null +++ b/addons/website_event_crm/data/event_crm_demo.xml @@ -0,0 +1,13 @@ + + + + + + Rule per order + order + + + + + + diff --git a/addons/website_event_crm/views/event_lead_rule_views.xml b/addons/website_event_crm/views/event_lead_rule_views.xml new file mode 100644 index 00000000000..1c9145bea10 --- /dev/null +++ b/addons/website_event_crm/views/event_lead_rule_views.xml @@ -0,0 +1,23 @@ + + + + event.lead.rule.view.tree.inherit.website.event.crm + event.lead.rule + + + + 0 + + + + + event.lead.rule.view.form.inherit.website.event.crm + event.lead.rule + + + + 0 + + + + diff --git a/addons/website_event_crm_questions/__init__.py b/addons/website_event_crm_questions/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/website_event_crm_questions/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/website_event_crm_questions/__manifest__.py b/addons/website_event_crm_questions/__manifest__.py new file mode 100644 index 00000000000..1f4c778d074 --- /dev/null +++ b/addons/website_event_crm_questions/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Webiste Event CRM Questions', + 'version': '1.0', + 'category': 'Marketing/Events', + 'website': 'https://www.odoo.com/page/events', + 'description': """ + Add information when we build the description of a lead to include + the questions and answers linked to the registrations. + """, + 'depends': ['website_event_crm', 'website_event_questions'], + 'data': [], + 'installable': True, + 'auto_install': True, +} diff --git a/addons/website_event_crm_questions/models/__init__.py b/addons/website_event_crm_questions/models/__init__.py new file mode 100644 index 00000000000..20ce9ac3fb4 --- /dev/null +++ b/addons/website_event_crm_questions/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import event_registration diff --git a/addons/website_event_crm_questions/models/event_registration.py b/addons/website_event_crm_questions/models/event_registration.py new file mode 100644 index 00000000000..724ced14a31 --- /dev/null +++ b/addons/website_event_crm_questions/models/event_registration.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, _ + + +class EventRegistration(models.Model): + _inherit = 'event.registration' + + def _get_lead_description_registration(self, prefix='', line_suffix=''): + """Add the questions and answers linked to the registrations into the description of the lead.""" + reg_description = super(EventRegistration, self)._get_lead_description_registration(prefix=prefix, line_suffix=line_suffix) + if not self.registration_answer_ids: + return reg_description + + answer_descriptions = [] + for answer in self.registration_answer_ids: + answer_value = answer.value_answer_id.name if answer.question_type == "simple_choice" else answer.value_text_box + answer_value = "\n".join([" %s" % line for line in answer_value.split('\n')]) + answer_descriptions.append(" - %s\n%s" % (answer.question_id.title, answer_value)) + return "%s\n%s\n%s" % (reg_description, _("Questions"), '\n'.join(answer_descriptions)) + + def _get_lead_description_fields(self): + res = super(EventRegistration, self)._get_lead_description_fields() + res.append('registration_answer_ids') + return res