summaryrefslogtreecommitdiff
path: root/addons/website_event
diff options
context:
space:
mode:
authorThibault Delavallée <tde@odoo.com>2020-08-19 09:55:26 +0000
committerThibault Delavallée <tde@odoo.com>2020-08-21 17:30:13 +0000
commitb0845c0f2b48d6c7029a34ae50fc95876630e7ed (patch)
treecab4c50786ae087fdf6dec70b0c12ddc0c39c62d /addons/website_event
parentd697966585e9686f4bfce8049c1d0a21abe8a279 (diff)
[REM] website_event_online: merge into website_event
PURPOSE Clean organization and models linked to Event Online feature introduced in semi stable saas-13.3 at odoo/odoo@981f95bbf46658eda9f2d33751b9310cc67d86aa and odoo/enterprise@14722028ae0773c3710ea48ce48ff44703e83b5a Also clean website event menu not being available in website event but only in website event track, which implies some extra-code to manage it. In short: merge website_event_online in website_event, website_event_track _(online/session) in website_event_track. RATIONALE _online modules have been added to extend content of website_event and website_event_track without having any impact on those module. First step of cleaning is to move this content directly in base module. track_session module is mainly a rewrite of track module. Second step of cleaning is to move its content directly in website_event_track. SPECIFICATIONS Move remaining content of website event online to website event. Update dependencies accordingly. LINKS Task ID-2319779 COM odoo/odoo#56067 ENT odoo/enterprise#12520 UPG odoo/upgrade#1654
Diffstat (limited to 'addons/website_event')
-rw-r--r--addons/website_event/__manifest__.py6
-rw-r--r--addons/website_event/controllers/main.py23
-rw-r--r--addons/website_event/data/event_demo.xml2
-rw-r--r--addons/website_event/data/event_registration_demo.xml18
-rw-r--r--addons/website_event/data/website_visitor_demo.xml26
-rw-r--r--addons/website_event/models/__init__.py3
-rw-r--r--addons/website_event/models/event_event.py88
-rw-r--r--addons/website_event/models/event_registration.py11
-rw-r--r--addons/website_event/models/website_visitor.py126
-rw-r--r--addons/website_event/security/event_security.xml11
-rw-r--r--addons/website_event/security/ir.model.access.csv1
-rw-r--r--addons/website_event/static/src/js/display_timer_widget.js111
-rw-r--r--addons/website_event/static/src/js/register_toaster_widget.js31
-rw-r--r--addons/website_event/static/src/scss/website_event_templates.scss122
-rw-r--r--addons/website_event/tests/__init__.py1
-rw-r--r--addons/website_event/tests/common.py57
-rw-r--r--addons/website_event/tests/test_visitor_events.py50
-rw-r--r--addons/website_event/views/assets.xml3
-rw-r--r--addons/website_event/views/event_event_views.xml2
-rw-r--r--addons/website_event/views/event_registration_views.xml36
-rw-r--r--addons/website_event/views/event_templates_list.xml10
-rw-r--r--addons/website_event/views/event_templates_page.xml6
-rw-r--r--addons/website_event/views/event_templates_page_registration.xml1
-rw-r--r--addons/website_event/views/event_templates_widgets.xml26
-rw-r--r--addons/website_event/views/website_visitor_views.xml65
25 files changed, 823 insertions, 13 deletions
diff --git a/addons/website_event/__manifest__.py b/addons/website_event/__manifest__.py
index 41fb932b683..a37438c0ed2 100644
--- a/addons/website_event/__manifest__.py
+++ b/addons/website_event/__manifest__.py
@@ -27,15 +27,19 @@
'views/event_templates_widgets.xml',
'views/website_templates.xml',
'views/event_event_views.xml',
+ 'views/event_registration_views.xml',
'views/event_type_views.xml',
'views/website_event_menu_views.xml',
+ 'views/website_visitor_views.xml',
'views/event_menus.xml',
'security/ir.model.access.csv',
'security/event_security.xml',
],
'demo': [
'data/res_partner_demo.xml',
- 'data/event_demo.xml'
+ 'data/website_visitor_demo.xml',
+ 'data/event_demo.xml',
+ 'data/event_registration_demo.xml',
],
'application': True,
}
diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py
index 0665dce85bb..c003279c12b 100644
--- a/addons/website_event/controllers/main.py
+++ b/addons/website_event/controllers/main.py
@@ -345,13 +345,34 @@ class WebsiteEventController(http.Controller):
return list(registrations.values())
def _create_attendees_from_registration_post(self, event, registration_data):
+ """ Also try to set a visitor (from request) and
+ a partner (if visitor linked to a user for example). Purpose is to gather
+ as much informations as possible, notably to ease future communications.
+ Also try to update visitor informations based on registration info. """
+ visitor_sudo = request.env['website.visitor']._get_visitor_from_request(force_create=True)
+ visitor_sudo._update_visitor_last_visit()
+ visitor_values = {}
+
registrations_to_create = []
for registration_values in registration_data:
registration_values['event_id'] = event.id
- if not registration_values.get('partner_id'):
+ if not registration_values.get('partner_id') and visitor_sudo.partner_id:
+ registration_values['partner_id'] = visitor_sudo.partner_id.id
+ elif not registration_values.get('partner_id'):
registration_values['partner_id'] = request.env.user.partner_id.id
+
+ if visitor_sudo:
+ # registration may give a name to the visitor, yay
+ if registration_values.get('name') and not visitor_sudo.name and not visitor_values.get('name'):
+ visitor_values['name'] = registration_values['name']
+ # update registration based on visitor
+ registration_values['visitor_id'] = visitor_sudo.id
+
registrations_to_create.append(registration_values)
+ if visitor_values:
+ visitor_sudo.write(visitor_values)
+
return request.env['event.registration'].sudo().create(registrations_to_create)
@http.route(['''/event/<model("event.event"):event>/registration/confirm'''], type='http', auth="public", methods=['POST'], website=True)
diff --git a/addons/website_event/data/event_demo.xml b/addons/website_event/data/event_demo.xml
index 85a46feb7a7..a894ec82128 100644
--- a/addons/website_event/data/event_demo.xml
+++ b/addons/website_event/data/event_demo.xml
@@ -41,6 +41,7 @@
<record id="event.event_1" model="event.event">
<field name="website_menu" eval="True"/>
<field name="website_published" eval="True"/>
+ <field name="menu_register_cta" eval="True"/>
<field name="subtitle">The Great Reno Balloon Race is the world's largest free hot-air ballooning event.</field>
<field name="cover_properties">{"background-image": "url('/website_event/static/src/img/event_cover_1.jpg')", "resize_class": "o_record_has_cover o_half_screen_height", "opacity": "0.4"}</field>
<field name="description" type="html">
@@ -151,6 +152,7 @@
<record id="event.event_7" model="event.event">
<field name="website_menu" eval="True"/>
<field name="website_published" eval="True"/>
+ <field name="menu_register_cta" eval="True"/>
<field name="subtitle">Our newest collection will be revealed online! Interact with us on our live streams!</field>
<field name="cover_properties">{"background-image": "url('/website_event/static/src/img/event_cover_7.jpg')", "resize_class": "o_record_has_cover o_half_screen_height", "opacity": "0.4"}</field>
<field name="description" type="html">
diff --git a/addons/website_event/data/event_registration_demo.xml b/addons/website_event/data/event_registration_demo.xml
new file mode 100644
index 00000000000..ff661e424a9
--- /dev/null
+++ b/addons/website_event/data/event_registration_demo.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo><data>
+
+ <!-- OpenWood Collection Online Reveal: Gemini (all) -->
+ <record id="event.event_registration_7_0" model="event.registration">
+ <field name="visitor_id" ref="website_event.website_visitor_event_0"/>
+ </record>
+ <record id="event.event_registration_7_1" model="event.registration">
+ <field name="visitor_id" ref="website_event.website_visitor_event_0"/>
+ </record>
+ <record id="event.event_registration_7_2" model="event.registration">
+ <field name="visitor_id" ref="website_event.website_visitor_event_1"/>
+ </record>
+ <record id="event.event_registration_7_3" model="event.registration">
+ <field name="visitor_id" ref="website_event.website_visitor_event_1"/>
+ </record>
+
+</data></odoo>
diff --git a/addons/website_event/data/website_visitor_demo.xml b/addons/website_event/data/website_visitor_demo.xml
new file mode 100644
index 00000000000..8403ae7fb0b
--- /dev/null
+++ b/addons/website_event/data/website_visitor_demo.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+ <record id="website_visitor_event_0" model="website.visitor">
+ <field name="name">Edwin Hansen</field>
+ <field name="partner_id" ref="base.res_partner_address_5"/>
+ <field name="country_id" ref="base.us"/>
+ </record>
+ <record id="website_visitor_event_1" model="website.visitor">
+ <field name="name">Soham Palmer</field>
+ <field name="partner_id" ref="base.res_partner_address_11"/>
+ <field name="country_id" ref="base.us"/>
+ </record>
+ <record id="website_visitor_event_2" model="website.visitor">
+ <field name="name">Philipe J. Fry</field>
+ <field name="partner_id" eval="False"/>
+ <field name="country_id" ref="base.be"/>
+ </record>
+ <record id="website_visitor_event_2_1" model="website.visitor">
+ <field name="name">Philipe J. Fry (old)</field>
+ <field name="parent_id" ref="website_event.website_visitor_event_2"/>
+ <field name="partner_id" eval="False"/>
+ <field name="country_id" ref="base.be"/>
+ </record>
+
+</odoo>
diff --git a/addons/website_event/models/__init__.py b/addons/website_event/models/__init__.py
index f49b644b04f..cce22a56c9e 100644
--- a/addons/website_event/models/__init__.py
+++ b/addons/website_event/models/__init__.py
@@ -1,7 +1,10 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import event_event
+from . import event_registration
from . import event_type
from . import website
from . import website_event_menu
from . import website_menu
+from . import website_visitor
diff --git a/addons/website_event/models/event_event.py b/addons/website_event/models/event_event.py
index 7cf5f4e6b05..162cdf8cd55 100644
--- a/addons/website_event/models/event_event.py
+++ b/addons/website_event/models/event_event.py
@@ -4,8 +4,11 @@
import json
import werkzeug.urls
+from pytz import utc
+
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import slug
+from odoo.osv import expression
GOOGLE_CALENDAR_URL = 'https://www.google.com/calendar/render?'
@@ -36,6 +39,9 @@ class Event(models.Model):
help="Creates menus Introduction, Location and Register on the page "
"of the event on the website.")
menu_id = fields.Many2one('website.menu', 'Event Menu', copy=False)
+ menu_register_cta = fields.Boolean(
+ 'Add Register Button', compute='_compute_menu_register_cta',
+ readonly=False, store=True)
community_menu = fields.Boolean(
"Community Menu", compute="_compute_community_menu",
readonly=False, store=True,
@@ -43,16 +49,58 @@ class Event(models.Model):
community_menu_ids = fields.One2many(
"website.event.menu", "event_id", string="Event Community Menus",
domain=[("menu_type", "=", "community")])
+ # live information
+ is_ongoing = fields.Boolean(
+ 'Is Ongoing', compute='_compute_time_data', search='_search_is_ongoing',
+ help="Whether event has begun")
+ is_done = fields.Boolean(
+ 'Is Done', compute='_compute_time_data',
+ help="Whether event is finished")
+ start_today = fields.Boolean(
+ 'Start Today', compute='_compute_time_data',
+ help="Whether event is going to start today if still not ongoing")
+ start_remaining = fields.Integer(
+ 'Remaining before start', compute='_compute_time_data',
+ help="Remaining time before event starts (minutes)")
def _compute_is_participating(self):
- # we don't allow public user to see participating label
- if self.env.user != self.env['website'].get_current_website().user_id:
- email = self.env.user.partner_id.email
- for event in self:
- domain = ['&', '|', ('email', '=', email), ('partner_id', '=', self.env.user.partner_id.id), ('event_id', '=', event.id)]
- event.is_participating = self.env['event.registration'].sudo().search_count(domain)
+ """Heuristic
+
+ * public, no visitor: not participating as we have no information;
+ * public and visitor: check visitor is linked to a registration. As
+ visitors are merged on the top parent, current visitor check is
+ sufficient even for successive visits;
+ * logged, no visitor: check partner is linked to a registration. Do
+ not check the email as it is not really secure;
+ * logged as visitor: check partner or visitor are linked to a
+ registration;
+ """
+ current_visitor = self.env['website.visitor']._get_visitor_from_request(force_create=False)
+ if self.env.user._is_public() and not current_visitor:
+ events = self.env['event.event']
+ elif self.env.user._is_public():
+ events = self.env['event.registration'].sudo().search([
+ ('event_id', 'in', self.ids),
+ ('visitor_id', '=', current_visitor.id),
+ ]).event_id
else:
- self.is_participating = False
+ if current_visitor:
+ domain = [
+ '|',
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ('visitor_id', '=', current_visitor.id)
+ ]
+ else:
+ domain = [('partner_id', '=', self.env.user.partner_id.id)]
+ events = self.env['event.registration'].sudo().search(
+ expression.AND([
+ domain,
+ [('event_id', 'in', self.ids)]
+ ])
+ ).event_id
+
+ for event in self:
+ event.is_participating = event in events
@api.depends('event_type_id')
def _compute_website_menu(self):
@@ -75,6 +123,28 @@ class Event(models.Model):
elif not event.website_menu:
event.community_menu = False
+ @api.depends('website_menu')
+ def _compute_menu_register_cta(self):
+ for event in self:
+ event.menu_register_cta = event.website_menu
+
+ @api.depends('date_begin', 'date_end')
+ def _compute_time_data(self):
+ """ Compute start and remaining time. Do everything in UTC as we compute only
+ time deltas here. """
+ now_utc = utc.localize(fields.Datetime.now().replace(microsecond=0))
+ for event in self:
+ date_begin_utc = utc.localize(event.date_begin, is_dst=False)
+ date_end_utc = utc.localize(event.date_end, is_dst=False)
+ event.is_ongoing = date_begin_utc <= now_utc <= date_end_utc
+ event.is_done = now_utc > date_end_utc
+ event.start_today = date_begin_utc.date() == now_utc.date()
+ if date_begin_utc >= now_utc:
+ td = date_begin_utc - now_utc
+ event.start_remaining = int(td.total_seconds() / 60)
+ else:
+ event.start_remaining = 0
+
@api.depends('name')
def _compute_website_url(self):
super(Event, self)._compute_website_url()
@@ -82,6 +152,10 @@ class Event(models.Model):
if event.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
event.website_url = '/event/%s' % slug(event)
+ # ------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------
+
@api.model
def create(self, vals):
res = super(Event, self).create(vals)
diff --git a/addons/website_event/models/event_registration.py b/addons/website_event/models/event_registration.py
new file mode 100644
index 00000000000..8b56c14f742
--- /dev/null
+++ b/addons/website_event/models/event_registration.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class EventRegistration(models.Model):
+ _name = 'event.registration'
+ _inherit = ['event.registration']
+
+ visitor_id = fields.Many2one('website.visitor', string='Visitor', ondelete='set null')
diff --git a/addons/website_event/models/website_visitor.py b/addons/website_event/models/website_visitor.py
new file mode 100644
index 00000000000..078e4f03296
--- /dev/null
+++ b/addons/website_event/models/website_visitor.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class WebsiteVisitor(models.Model):
+ _name = 'website.visitor'
+ _inherit = ['website.visitor']
+
+ parent_id = fields.Many2one(
+ 'website.visitor', string="Parent", ondelete='set null',
+ help="Main identity")
+ event_registration_ids = fields.One2many(
+ 'event.registration', 'visitor_id', string='Event Registrations',
+ groups="event.group_event_user")
+ event_registration_count = fields.Integer(
+ '# Registrations', compute='_compute_event_registration_count',
+ groups="event.group_event_user")
+ event_registered_ids = fields.Many2many(
+ 'event.event', string="Registered Events",
+ compute="_compute_event_registered_ids", compute_sudo=True,
+ search="_search_event_registered_ids",
+ groups="event.group_event_user")
+
+ @api.depends('event_registration_ids')
+ def _compute_event_registration_count(self):
+ if self.ids:
+ read_group_res = self.env['event.registration'].read_group(
+ [('visitor_id', 'in', self.ids)],
+ ['visitor_id'], ['visitor_id'])
+ visitor_mapping = dict(
+ (item['visitor_id'][0], item['visitor_id_count'])
+ for item in read_group_res)
+ else:
+ visitor_mapping = dict()
+ for visitor in self:
+ visitor.event_registration_count = visitor_mapping.get(visitor.id) or 0
+
+ @api.depends('event_registration_ids.email', 'event_registration_ids.mobile', 'event_registration_ids.phone')
+ def _compute_email_phone(self):
+ super(WebsiteVisitor, self)._compute_email_phone()
+ self.flush()
+
+ for visitor in self.filtered(lambda visitor: not visitor.email or not visitor.mobile):
+ linked_registrations = visitor.event_registration_ids.sorted(lambda reg: (reg.create_date, reg.id), reverse=False)
+ if not visitor.email:
+ visitor.email = next((reg.email for reg in linked_registrations if reg.email), False)
+ if not visitor.mobile:
+ visitor.mobile = next((reg.mobile or reg.phone for reg in linked_registrations if reg.mobile or reg.phone), False)
+
+ @api.depends('parent_id', 'event_registration_ids')
+ def _compute_event_registered_ids(self):
+ # include parent's registrations in a visitor o2m field. We don't add
+ # child one as child should not have registrations (moved to the parent)
+ for visitor in self:
+ all_registrations = visitor.event_registration_ids | visitor.parent_id.event_registration_ids
+ visitor.event_registered_ids = all_registrations.mapped('event_id')
+
+ def _search_event_registered_ids(self, operator, operand):
+ """ Search visitors with terms on events within their event registrations. E.g. [('event_registered_ids',
+ 'in', [1, 2])] should return visitors having a registration on events 1, 2 as
+ well as their children for notification purpose. """
+ if operator == "not in":
+ raise NotImplementedError("Unsupported 'Not In' operation on visitors registrations")
+
+ all_registrations = self.env['event.registration'].sudo().search([
+ ('event_id', operator, operand)
+ ])
+ if all_registrations:
+ # search children, even archived one, to contact them
+ visitors = all_registrations.with_context(active_test=False).mapped('visitor_id')
+ children = self.env['website.visitor'].with_context(
+ active_test=False
+ ).sudo().search([('parent_id', 'in', visitors.ids)])
+ visitor_ids = (visitors + children).ids
+ else:
+ visitor_ids = []
+
+ return [('id', 'in', visitor_ids)]
+
+ def _link_to_partner(self, partner, update_values=None):
+ """ Propagate partner update to registration records """
+ if partner:
+ registration_wo_partner = self.event_registration_ids.filtered(lambda registration: not registration.partner_id)
+ if registration_wo_partner:
+ registration_wo_partner.partner_id = partner
+ super(WebsiteVisitor, self)._link_to_partner(partner, update_values=update_values)
+
+ def _link_to_visitor(self, target, keep_unique=True):
+ """ Override linking process to link registrations to the final visitor. """
+ self.event_registration_ids.write({'visitor_id': target.id})
+
+ res = super(WebsiteVisitor, self)._link_to_visitor(target, keep_unique=False)
+
+ if keep_unique:
+ self.partner_id = False
+ self.parent_id = target.id
+ self.active = False
+
+ return res
+
+ def _get_visitor_from_request(self, force_create=False):
+ """ When fetching visitor, now that duplicates are linked to a main visitor
+ instead of unlinked, you may have more collisions issues with cookie being
+ set after a de-connection for example.
+
+ In base method, visitor associated to a partner in case of public user is
+ not taken into account. It is considered as desynchronized cookie. Here
+ we also discard if the visitor has a main visitor whose partner is set
+ (aka wrong after logout partner). """
+ visitor = super(WebsiteVisitor, self)._get_visitor_from_request(force_create=force_create)
+
+ # also check that visitor parent partner is not different from user's one (indicates duplicate due to invalid or wrong cookie)
+ if visitor and visitor.parent_id.partner_id:
+ if self.env.user._is_public():
+ visitor = self.env['website.visitor'].sudo()
+ elif not visitor.partner_id:
+ visitor = self.env['website.visitor'].sudo().with_context(active_test=False).search(
+ [('partner_id', '=', self.env.user.partner_id.id)]
+ )
+
+ if not visitor and force_create:
+ visitor = self._create_visitor()
+
+ return visitor
diff --git a/addons/website_event/security/event_security.xml b/addons/website_event/security/event_security.xml
index 857e758e9c0..1aabb8f3025 100644
--- a/addons/website_event/security/event_security.xml
+++ b/addons/website_event/security/event_security.xml
@@ -12,6 +12,17 @@
<field name="perm_unlink" eval="False"/>
</record>
+ <record id="ir_rule_event_tag_public" model="ir.rule">
+ <field name="name">Event Tag: public/portal: color = published</field>
+ <field name="model_id" ref="event.model_event_tag"/>
+ <field name="domain_force">['&amp;', ('color', '!=', False), ('color', '!=', 0)]</field>
+ <field name="groups" eval="[(4, ref('base.group_public')), (4, ref('base.group_portal'))]"/>
+ <field name="perm_read" eval="True"/>
+ <field name="perm_write" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_unlink" eval="False"/>
+ </record>
+
<record id="ir_rule_event_event_ticket_public" model="ir.rule">
<field name="name">Event Ticket: public/portal: published read</field>
<field name="model_id" ref="event.model_event_event_ticket"/>
diff --git a/addons/website_event/security/ir.model.access.csv b/addons/website_event/security/ir.model.access.csv
index e865d4a1581..49125082909 100644
--- a/addons/website_event/security/ir.model.access.csv
+++ b/addons/website_event/security/ir.model.access.csv
@@ -8,3 +8,4 @@ access_event_category_public,event.tag.category.public,event.model_event_tag_cat
access_event_tag_public,event.tag.public,event.model_event_tag,,1,0,0,0
access_website_event_menu_public,website.event.menu,model_website_event_menu,,1,0,0,0
access_website_event_menu_manager,website.event.menu,model_website_event_menu,event.group_event_manager,1,1,1,1
+website_visitor_access_event_user,website.visitor.access.event.user,model_website_visitor,event.group_event_user,1,1,0,0
diff --git a/addons/website_event/static/src/js/display_timer_widget.js b/addons/website_event/static/src/js/display_timer_widget.js
new file mode 100644
index 00000000000..5f61273661f
--- /dev/null
+++ b/addons/website_event/static/src/js/display_timer_widget.js
@@ -0,0 +1,111 @@
+odoo.define('website_event.display_timer_widget', function (require) {
+'use strict';
+
+var core = require('web.core');
+var _t = core._t;
+var publicWidget = require('web.public.widget');
+
+publicWidget.registry.displayTimerWidget = publicWidget.Widget.extend({
+ selector: '.o_display_timer',
+
+ /**
+ * This widget allows to display a dom element at the end of a certain time laps.
+ * There are 2 timers available:
+ * - The main-timer: display the DOM element (using the displayClass) at the end of this timer.
+ * - The pre-timer: additional timer to display the main-timer. This pre-timer can be invisible or visible,
+ * depending of the startCountdownDisplay option. Once the pre-timer is over,
+ the main-timer is displayed.
+ * @override
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.options = self.$target.data();
+ self.preCountdownDisplay = self.options["preCountdownDisplay"];
+ self.preCountdownTime = self.options["preCountdownTime"];
+ self.preCountdownText = self.options["preCountdownText"];
+
+ self.mainCountdownTime = self.options["mainCountdownTime"];
+ self.mainCountdownText = self.options["mainCountdownText"];
+ self.mainCountdownDisplay = self.options["mainCountdownDisplay"];
+
+ self.displayClass = self.options["displayClass"];
+
+ if (self.preCountdownDisplay) {
+ $(self.$el).parent().removeClass('d-none');
+ }
+
+ self._checkTimer();
+ self.interval = setInterval(function () { self._checkTimer(); }, 1000);
+ });
+ },
+
+ /**
+ * This method removes 1 second to the current timer (pre-timer or main-timer)
+ * and call the method to update the DOM, unless main-timer is over. In that last case,
+ * the DOM element to show is displayed.
+ *
+ * @private
+ */
+ _checkTimer: function () {
+ var now = new Date();
+
+ var remainingPreSeconds = this.preCountdownTime - (now.getTime()/1000);
+ if (remainingPreSeconds <= 1) {
+ this.$('.o_countdown_text').text(this.mainCountdownText);
+ if (this.mainCountdownDisplay) {
+ $(this.$el).parent().removeClass('d-none');
+ }
+ var remainingMainSeconds = this.mainCountdownTime - (now.getTime()/1000);
+ if (remainingMainSeconds <= 1) {
+ clearInterval(this.interval);
+ $(this.displayClass).removeClass('d-none');
+ $(this.$el).parent().addClass('d-none');
+ } else {
+ this._updateCountdown(remainingMainSeconds);
+ }
+ } else {
+ this._updateCountdown(remainingPreSeconds);
+ }
+ },
+
+ /**
+ * This method update the DOM to display the remaining time.
+ * from seconds, the method extract the number of days, hours, minutes and seconds and
+ * override the different DOM elements values.
+ *
+ * @private
+ */
+ _updateCountdown: function (remainingTime) {
+ var remainingSeconds = remainingTime;
+ var days = Math.floor(remainingSeconds / 86400);
+
+ remainingSeconds = remainingSeconds % 86400;
+ var hours = Math.floor(remainingSeconds / 3600);
+
+ remainingSeconds = remainingSeconds % 3600;
+ var minutes = Math.floor(remainingSeconds / 60);
+
+ remainingSeconds = Math.floor(remainingSeconds % 60);
+
+ this.$("span.o_timer_days").text(days);
+ this.$("span.o_timer_hours").text(this._zeroPad(hours, 2));
+ this.$("span.o_timer_minutes").text(this._zeroPad(minutes, 2));
+ this.$("span.o_timer_seconds").text(this._zeroPad(remainingSeconds, 2));
+ },
+
+ /**
+ * Small tool to add leading zéros to the given number, in function of the needed number of leading zéros.
+ *
+ * @private
+ */
+ _zeroPad: function (num, places) {
+ var zero = places - num.toString().length + 1;
+ return new Array(+(zero > 0 && zero)).join("0") + num;
+ },
+
+});
+
+return publicWidget.registry.countdownWidget;
+
+});
diff --git a/addons/website_event/static/src/js/register_toaster_widget.js b/addons/website_event/static/src/js/register_toaster_widget.js
new file mode 100644
index 00000000000..891f61554c7
--- /dev/null
+++ b/addons/website_event/static/src/js/register_toaster_widget.js
@@ -0,0 +1,31 @@
+odoo.define('website_event.register_toaster_widget', function (require) {
+'use strict';
+
+let core = require('web.core');
+let _t = core._t;
+let publicWidget = require('web.public.widget');
+
+publicWidget.registry.RegisterToasterWidget = publicWidget.Widget.extend({
+ selector: '.o_wevent_register_toaster',
+
+ /**
+ * This widget allows to display a toast message on the page.
+ *
+ * @override
+ */
+ start: function () {
+ const message = this.$el.data('message');
+ if (message && message.length) {
+ this.displayNotification({
+ title: _t("Register"),
+ message: message,
+ type: 'info'
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+});
+
+return publicWidget.registry.RegisterToasterWidget;
+
+});
diff --git a/addons/website_event/static/src/scss/website_event_templates.scss b/addons/website_event/static/src/scss/website_event_templates.scss
new file mode 100644
index 00000000000..390536d6f97
--- /dev/null
+++ b/addons/website_event/static/src/scss/website_event_templates.scss
@@ -0,0 +1,122 @@
+// Color tags according to assigned background color.
+
+.o_wevent_index, .o_wevent_event {
+
+ // Simple colored tags according to assigned background color
+ @for $size from 1 through length($o-colors) {
+ .o_tag_color_#{$size - 1} {
+ @if $size == 1 {
+ & {
+ background-color: white;
+ color: nth($o-colors, $size);
+ box-shadow: inset 0 0 0 2px nth($o-colors, $size);
+ }
+ &::after {
+ background-color: nth($o-colors, $size);
+ }
+ } @else {
+ &, &::after {
+ background-color: nth($o-colors, $size);
+ color: $white;
+ }
+ }
+ }
+ }
+
+ // Complex colored tags according to assigned background color with hover effect
+ @for $size from 1 through length($o-colors) {
+ .o_tag_color_hovered_#{$size - 1} {
+ $background-color: white;
+ // no color selected
+ @if $size == 1 {
+ & {
+ color: black;
+ background-color: $background-color;
+ box-shadow: inset 0 0 0 1px nth($o-colors, $size);
+ }
+ } @else {
+ $background-color: nth($o-colors, $size);
+ & {
+ color: white;
+ background-color: $background-color;
+ }
+ }
+ @at-root a#{&} {
+ &:hover {
+ background-color: darken($background-color, 20%);
+ }
+ }
+ }
+ }
+
+ /*
+ * COMMON TO ALL ONLINE-* PAGES
+ */
+
+ .o_wevent_online {
+ // unpublished badge: put opacity to distinguish form other badges
+ .o_wevent_online_badge_unpublished{
+ opacity: 0.4;
+ }
+
+ &.o_wevent_online_bg {
+ @if (color('body') == $o-portal-default-body-bg) {
+ @extend .bg-100;
+ }
+ }
+
+ .o_wevent_online_page_container {
+ // Widescreen mode
+ @include media-breakpoint-up(md) {
+ &:not(.container) {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ }
+ }
+
+ // Main panel
+ .o_wevent_online_page_main {
+ border: 1px solid $border-color;
+
+ @include media-breakpoint-up(md) {
+ border-top-width: 0;
+ }
+
+ .o_wevent_online_page_avatar {
+ min-width: 64px;
+
+ img {
+ max-width: 96px;
+ }
+ }
+ }
+
+ // Left panel
+ .o_wevent_online_page_aside {
+
+ // Left panel: content display
+ .o_wevent_online_page_aside_content {
+ border: 1px solid $border-color;
+
+ @include media-breakpoint-up(md) {
+ border-top-width: 0;
+ }
+
+ li:not(.nav-item) {
+ border-bottom: 1px solid $border-color;
+ &:first-child {
+ border-top: 1px solid $border-color;
+ }
+ &:last-child {
+ border-bottom: 0px;
+ }
+ }
+
+ .o_wevent_online_page_aside_collapse:not(.collapsed) {
+ transform: rotate(180deg);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/website_event/tests/__init__.py b/addons/website_event/tests/__init__.py
index 41f5bb1157f..77fa25d467c 100644
--- a/addons/website_event/tests/__init__.py
+++ b/addons/website_event/tests/__init__.py
@@ -4,5 +4,6 @@
from . import common
from . import test_event_internals
from . import test_ui
+from . import test_visitor_events
from . import test_website_event
from . import test_website_event_access
diff --git a/addons/website_event/tests/common.py b/addons/website_event/tests/common.py
index 333a2ed4789..8b447777d39 100644
--- a/addons/website_event/tests/common.py
+++ b/addons/website_event/tests/common.py
@@ -1,8 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import datetime, timedelta, time
+from unittest.mock import patch
+
from odoo.addons.event.tests.common import TestEventCommon
from odoo.addons.mail.tests.common import mail_new_test_user
+from odoo.fields import Datetime as FieldsDatetime, Date as FieldsDate
+from odoo.tests.common import SavepointCase
+
+
+class EventDtPatcher(SavepointCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(EventDtPatcher, cls).setUpClass()
+
+ cls.reference_now = datetime(2020, 7, 6, 10, 0, 0)
+ cls.reference_today = datetime(2020, 7, 6)
+
+ cls.event_dt = patch(
+ 'odoo.addons.event.models.event_event.fields.Datetime',
+ wraps=FieldsDatetime
+ )
+ cls.wevent_main_dt = patch(
+ 'odoo.addons.website_event.controllers.main.fields.Datetime',
+ wraps=FieldsDatetime
+ )
+ cls.event_date = patch(
+ 'odoo.addons.event.models.event_event.fields.Date',
+ wraps=FieldsDate
+ )
+ cls.wevent_main_date = patch(
+ 'odoo.addons.website_event.controllers.main.fields.Date',
+ wraps=FieldsDate
+ )
+ cls.mock_event_dt = cls.event_dt.start()
+ cls.mock_wevent_main_dt = cls.wevent_main_dt.start()
+ cls.mock_event_date = cls.event_date.start()
+ cls.mock_wevent_main_date = cls.wevent_main_date.start()
+ cls.mock_event_dt.now.return_value = cls.reference_now
+ cls.mock_wevent_main_dt.now.return_value = cls.reference_now
+ cls.mock_event_date.today.return_value = cls.reference_today
+ cls.mock_wevent_main_date.today.return_value = cls.reference_today
+ cls.addClassCleanup(cls.event_dt.stop)
+ cls.addClassCleanup(cls.wevent_main_dt.stop)
+ cls.addClassCleanup(cls.event_date.stop)
+ cls.addClassCleanup(cls.wevent_main_date.stop)
class TestWebsiteEventCommon(TestEventCommon):
@@ -43,3 +87,16 @@ class TestWebsiteEventCommon(TestEventCommon):
# without website.event.menu model (or crappy code based on name)
# else:
# self.assertFalse(bool(view))
+
+
+class TestEventOnlineCommon(TestEventCommon, EventDtPatcher):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestEventOnlineCommon, cls).setUpClass()
+
+ # event if 8-18 in Europe/Brussels (DST) (first day: begins at 9, last day: ends at 15)
+ cls.event_0.write({
+ 'date_begin': datetime.combine(cls.reference_now, time(7, 0)) - timedelta(days=1),
+ 'date_end': datetime.combine(cls.reference_now, time(13, 0)) + timedelta(days=1),
+ })
diff --git a/addons/website_event/tests/test_visitor_events.py b/addons/website_event/tests/test_visitor_events.py
new file mode 100644
index 00000000000..814cd531ac5
--- /dev/null
+++ b/addons/website_event/tests/test_visitor_events.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+
+from odoo import fields
+from odoo.addons.event.tests.common import TestEventCommon
+
+
+class TestVisitorEvents(TestEventCommon):
+ def test_visitor_events(self):
+ event_1 = self.env['event.event'].create({
+ 'name': 'OtherEvent',
+ 'auto_confirm': True,
+ 'date_begin': fields.Datetime.to_string(datetime.today() + timedelta(days=1)),
+ 'date_end': fields.Datetime.to_string(datetime.today() + timedelta(days=15)),
+ })
+
+ [main_visitor, child_visitor] = self.env['website.visitor'].create([{
+ 'name': 'Main Visitor',
+ 'event_registration_ids': [(0, 0, {
+ 'event_id': self.event_0.id
+ })]
+ }, {
+ 'name': 'Child Visitor',
+ 'event_registration_ids': [(0, 0, {
+ 'event_id': event_1.id
+ })]
+ }])
+
+ self.assertEqual(self.event_0, main_visitor.event_registered_ids)
+ self.assertEqual(event_1, child_visitor.event_registered_ids)
+ self.assertEqual(
+ main_visitor,
+ self.env['website.visitor'].search([('event_registered_ids', 'in', self.event_0.ids)])
+ )
+ self.assertEqual(
+ child_visitor,
+ self.env['website.visitor'].search([('event_registered_ids', 'in', event_1.ids)])
+ )
+ child_visitor._link_to_visitor(main_visitor)
+ self.assertEqual(self.event_0 | event_1, main_visitor.event_registered_ids)
+ self.assertEqual(
+ main_visitor | child_visitor,
+ self.env['website.visitor'].with_context(active_test=False).search([('event_registered_ids', 'in', self.event_0.ids)])
+ )
+ self.assertEqual(
+ main_visitor | child_visitor,
+ self.env['website.visitor'].with_context(active_test=False).search([('event_registered_ids', 'in', event_1.ids)])
+ )
diff --git a/addons/website_event/views/assets.xml b/addons/website_event/views/assets.xml
index e19a829e3ff..cbf9e23a6bf 100644
--- a/addons/website_event/views/assets.xml
+++ b/addons/website_event/views/assets.xml
@@ -10,8 +10,11 @@
<template id="assets_frontend" inherit_id="website.assets_frontend" name="Event Assets Frontend">
<xpath expr="//link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/website_event/static/src/scss/website_event.scss"/>
+ <link rel="stylesheet" type="text/scss" href="/website_event/static/src/scss/website_event_templates.scss"/>
</xpath>
<xpath expr="//script[last()]" position="after">
+ <script type="text/javascript" src="/website_event/static/src/js/display_timer_widget.js"></script>
+ <script type="text/javascript" src="/website_event/static/src/js/register_toaster_widget.js"></script>
<script type="text/javascript" src="/website_event/static/src/js/website_geolocation.js"></script>
<script type="text/javascript" src="/website_event/static/src/js/website_event.js"></script>
<script type="text/javascript" src="/website_event/static/src/js/website_event_ticket_details.js"></script>
diff --git a/addons/website_event/views/event_event_views.xml b/addons/website_event/views/event_event_views.xml
index 6b03966b5aa..f364ac129c6 100644
--- a/addons/website_event/views/event_event_views.xml
+++ b/addons/website_event/views/event_event_views.xml
@@ -23,6 +23,8 @@
<div name="event_menu_configuration" groups="base.group_no_one">
<label for="website_menu" string="Website Submenu"/>
<field name="website_menu"/>
+ <label for="menu_register_cta" string="Register Button"/>
+ <field name="menu_register_cta"/>
<label for="community_menu" string="Community"/>
<field name="community_menu"/>
</div>
diff --git a/addons/website_event/views/event_registration_views.xml b/addons/website_event/views/event_registration_views.xml
new file mode 100644
index 00000000000..27e490d6a88
--- /dev/null
+++ b/addons/website_event/views/event_registration_views.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0"?>
+<odoo><data>
+ <record id="event_registration_action_from_visitor" model="ir.actions.act_window">
+ <field name="name">Registrations</field>
+ <field name="res_model">event.registration</field>
+ <field name="view_mode">kanban,tree,form</field>
+ <field name="domain">[('visitor_id', 'in', [active_id])]</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ No registration linked to this visitor
+ </p>
+ </field>
+ </record>
+
+ <record id="event_registration_view_form" model="ir.ui.view">
+ <field name="name">event.registration.view.form.inherit.online</field>
+ <field name="model">event.registration</field>
+ <field name="inherit_id" ref="event.view_event_registration_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//field[@name='partner_id']" position="after">
+ <field name="visitor_id" groups="base.group_no_one"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="event_registration_view_tree" model="ir.ui.view">
+ <field name="name">event.registration.view.tree.inherit.online</field>
+ <field name="model">event.registration</field>
+ <field name="inherit_id" ref="event.view_event_registration_tree"/>
+ <field name="arch" type="xml">
+ <xpath expr="//field[@name='partner_id']" position="after">
+ <field name="visitor_id" optional="hide" groups="base.group_no_one"/>
+ </xpath>
+ </field>
+ </record>
+</data></odoo>
diff --git a/addons/website_event/views/event_templates_list.xml b/addons/website_event/views/event_templates_list.xml
index 89b8c1cd62f..4ad91388abe 100644
--- a/addons/website_event/views/event_templates_list.xml
+++ b/addons/website_event/views/event_templates_list.xml
@@ -97,15 +97,16 @@
<template id="event_category_tag" inherit_id="website_event.index_topbar" active="False" customize_show="True" name="Filter by Tags">
<xpath expr="//ul[hasclass('o_wevent_index_topbar_filters')]" position="inside">
<t t-foreach="categories" t-as="category">
- <li t-if="category.tag_ids" class="nav-item dropdown mr-2 my-1">
+ <li t-if="category.tag_ids and any(tag.color for tag in category.tag_ids)" class="nav-item dropdown mr-2 my-1">
<a href="#" role="button" class="btn dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-folder-open"/>
<t t-esc="category.name"/>
</a>
<div class="dropdown-menu">
<t t-foreach="category.tag_ids" t-as="tag">
- <a t-att-href="'/event?%s' % keep_query('*', tags=str((search_tags - tag).ids if tag in search_tags else (tag | search_tags).ids))"
- t-attf-class="dropdown-item d-flex align-items-center justify-content-between #{'active' if tag in search_tags else ''}">
+ <a t-if="tag.color"
+ t-att-href="'/event?%s' % keep_query('*', tags=str((search_tags - tag).ids if tag in search_tags else (tag | search_tags).ids))"
+ t-attf-class="dropdown-item d-flex align-items-center justify-content-between #{'active' if tag in search_tags else ''}">
<t t-esc="tag.name"/>
</a>
</t>
@@ -205,7 +206,8 @@
<div itemprop="location" t-field="event.address_id" t-options="{'widget': 'contact', 'fields': ['city'], 'no_marker': 'true'}"/>
<div class="mt8 d-flex align-items-center">
<t t-foreach="event.tag_ids" t-as="tag">
- <span t-attf-class="badge mr8 #{'badge-primary' if tag in search_tags else 'badge-light'}">
+ <span t-if="tag.color"
+ t-attf-class="badge mr8 #{'badge-primary' if tag in search_tags else 'badge-light'} #{'o_tag_color_%s' % tag.color if tag.color else ''}">
<span t-esc="tag.name"/>
</span>
</t>
diff --git a/addons/website_event/views/event_templates_page.xml b/addons/website_event/views/event_templates_page.xml
index 5a9b449b634..cc82788c948 100644
--- a/addons/website_event/views/event_templates_page.xml
+++ b/addons/website_event/views/event_templates_page.xml
@@ -52,6 +52,12 @@
</t>
</t>
</ul>
+ <!-- Add Register additional CTA button, in addition to menus -->
+ <a t-if="event.menu_register_cta and not event.is_participating"
+ t-att-href="'/event/%s/register' % (slug(event))"
+ class="btn btn-primary ml-auto">
+ Register
+ </a>
</div>
</div>
</nav>
diff --git a/addons/website_event/views/event_templates_page_registration.xml b/addons/website_event/views/event_templates_page_registration.xml
index e57a50212fe..6375b55c293 100644
--- a/addons/website_event/views/event_templates_page_registration.xml
+++ b/addons/website_event/views/event_templates_page_registration.xml
@@ -121,6 +121,7 @@
<i class="fa fa-pencil mr-2" role="img" aria-label="Edit" title="Edit event registration in backend"/><em>Configure Event Tickets</em>
</a>
</div>
+ <div t-if="toast_message" class="o_wevent_register_toaster d-none" t-att-data-message="toast_message"/>
<div t-if="not event.event_registrations_open" class="bg-white mb-5">
<div class="alert alert-info mb-0 d-flex justify-content-between align-items-center" role="status">
<t t-if="event.start_sale_date and event.start_sale_date &gt; datetime.date.today()">
diff --git a/addons/website_event/views/event_templates_widgets.xml b/addons/website_event/views/event_templates_widgets.xml
index 0a143d7d72b..4b994176bde 100644
--- a/addons/website_event/views/event_templates_widgets.xml
+++ b/addons/website_event/views/event_templates_widgets.xml
@@ -52,4 +52,30 @@
</div>
</template>
+<!-- Timer widget -->
+<template id="display_timer_widget" name="Display Timer Widget">
+ <t t-set="pre_countdown_display" t-value="bool(pre_countdown_text) or pre_countdown_display"/>
+ <t t-set="pre_countdown_time" t-value="datetime.datetime.now().timestamp() + int(pre_remaining_time)"/>
+
+ <div class="o_display_timer"
+ t-att-data-display-class="display_class"
+ t-att-data-main-countdown-time="datetime.datetime.now().timestamp() + int(main_remaining_time)"
+ t-att-data-main-countdown-text="main_countdown_text"
+ t-att-data-main-countdown-display="main_countdown_display"
+ t-att-data-pre-countdown-time="pre_countdown_time"
+ t-att-data-pre-countdown-display="pre_countdown_display"
+ t-att-data-pre-countdown-text="pre_countdown_text">
+ <t t-set="remaining_time" t-value="pre_remaining_time if pre_remaining_time else main_remaining_time"/>
+ <span class="o_display_timer_countdown d-flex justify-content-center">
+ <span class="o_countdown_text pr-1" t-esc="pre_countdown_text if pre_countdown_text else main_countdown_text if not pre_countdown_display else ''"/>
+ <div t-if="int(remaining_time) > 86400"
+ class="o_countdown_metric_container"><span class="o_countdown_remaining o_timer_days pr-1">0</span><span class="o_countdown_metric pr-1">days</span></div>
+ <div t-if="int(remaining_time) > 3600"
+ class="o_countdown_metric_container"><span class="o_countdown_remaining o_timer_hours">00</span><span class="o_countdown_metric">:</span></div>
+ <div class="o_countdown_metric_container"><span class="o_countdown_remaining o_timer_minutes">00</span><span class="o_countdown_metric">:</span></div>
+ <div class="o_countdown_metric_container"><span class="o_countdown_remaining o_timer_seconds">00</span><span class="o_countdown_metric"></span></div>
+ </span>
+ </div>
+</template>
+
</odoo>
diff --git a/addons/website_event/views/website_visitor_views.xml b/addons/website_event/views/website_visitor_views.xml
new file mode 100644
index 00000000000..471ab75f07b
--- /dev/null
+++ b/addons/website_event/views/website_visitor_views.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo><data>
+
+ <record id="website_visitor_view_search" model="ir.ui.view">
+ <field name="name">website.visitor.view.search.inherit.event</field>
+ <field name="model">website.visitor</field>
+ <field name="inherit_id" ref="website.website_visitor_view_search"/>
+ <field name="arch" type="xml">
+ <xpath expr="//filter[@name='group_by_lang']" position="after">
+ <filter string="Main Contact" name="group_by_parent_id" context="{'group_by': 'parent_id'}"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="website_visitor_view_tree" model="ir.ui.view">
+ <field name="name">website.visitor.view.tree.inherit.event</field>
+ <field name="model">website.visitor</field>
+ <field name="inherit_id" ref="website.website_visitor_view_tree"/>
+ <field name="arch" type="xml">
+ <xpath expr="//field[@name='page_ids']" position="after">
+ <field name="event_registration_count"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="website_visitor_view_form" model="ir.ui.view">
+ <field name="name">website.visitor.view.form.inherit.event</field>
+ <field name="model">website.visitor</field>
+ <field name="inherit_id" ref="website.website_visitor_view_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//button[@id='w_visitor_visit_counter']" position="before">
+ <button name="%(website_event.event_registration_action_from_visitor)d"
+ type="action"
+ class="oe_stat_button" icon="fa-ticket"
+ attrs="{'invisible': [('event_registration_count', '=', 0)]}">
+ <field name="event_registration_count" widget="statinfo" string="Registrations"/>
+ </button>
+ </xpath>
+ <xpath expr="//field[@name='page_ids']" position="after">
+ <field name="parent_id"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="website_visitor_view_kanban" model="ir.ui.view">
+ <field name="name">website.visitor.view.kanban.inherit.event</field>
+ <field name="model">website.visitor</field>
+ <field name="inherit_id" ref="website.website_visitor_view_kanban"/>
+ <field name="arch" type="xml">
+ <field name="page_ids" position="after">
+ <field name="event_registration_count"/>
+ </field>
+ <xpath expr="//div[@id='o_page_count']" position="after">
+ <div>Registrations<span class="float-right font-weight-bold"><field name="event_registration_count"/></span></div>
+ </xpath>
+ <xpath expr="//div[hasclass('w_visitor_kanban_actions_ungrouped')]" position="before">
+ <div class="col">
+ <b><field name="event_registration_count"/></b>
+ <div>Registrations</div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+
+</data></odoo>