diff --git a/addons/website_event_track/__manifest__.py b/addons/website_event_track/__manifest__.py index 0523f0db3a9..507dbba588c 100644 --- a/addons/website_event_track/__manifest__.py +++ b/addons/website_event_track/__manifest__.py @@ -5,7 +5,7 @@ 'name': 'Advanced Events', 'category': 'Marketing', 'summary': 'Sponsors, Tracks, Agenda, Event News', - 'version': '1.2', + 'version': '1.3', 'description': "", 'depends': ['website_event'], 'data': [ @@ -48,6 +48,8 @@ 'website_event_track/static/src/scss/event_track_templates_online.scss', 'website_event_track/static/src/scss/pwa_frontend.scss', 'website_event_track/static/src/js/website_event_track.js', + 'website_event_track/static/src/js/website_event_track_proposal_form.js', + 'website_event_track/static/src/js/website_event_track_proposal_form_tags.js', 'website_event_track/static/src/js/event_track_reminder.js', 'website_event_track/static/src/js/website_event_pwa_widget.js', 'website_event_track/static/lib/idb-keyval/idb-keyval.js', diff --git a/addons/website_event_track/controllers/event_track.py b/addons/website_event_track/controllers/event_track.py index 72ea2f2aa7e..8b3ac031330 100644 --- a/addons/website_event_track/controllers/event_track.py +++ b/addons/website_event_track/controllers/event_track.py @@ -11,7 +11,7 @@ import babel.dates import base64 import pytz -from odoo import exceptions, http, fields, _ +from odoo import exceptions, http, fields, tools, _ from odoo.http import request from odoo.osv import expression from odoo.tools import is_html_empty, plaintext2html @@ -392,30 +392,67 @@ class EventTrackController(http.Controller): @http.route(['''/event//track_proposal/post'''], type='http', auth="public", methods=['POST'], website=True) def event_track_proposal_post(self, event, **post): - tags = [] - for tag in event.allowed_track_tag_ids: - if post.get('tag_' + str(tag.id)): - tags.append(tag.id) + if not event.can_access_from_current_website(): + raise NotFound() - track = request.env['event.track'].sudo().create({ + # Only accept existing tag indices. Use search instead of browse + exists: + # this prevents users to register colorless tags if not allowed to (ACL). + input_tag_indices = [int(tag_id) for tag_id in post['tags'].split(',') if tag_id] + valid_tag_indices = request.env['event.track.tag'].search([('id', 'in', input_tag_indices)]).ids + + contact = request.env['res.partner'] + visitor_partner = request.env['website.visitor']._get_visitor_from_request().partner_id + # Contact name is required. Therefore, empty contacts are not considered here. At least one of contact_phone + # and contact_email must be filled. Email is verified. If the post tries to create contact with no valid entry, + # raise exception. If normalized email is the same as logged partner, use its partner_id on track instead. + # This prevents contact duplication. Otherwise, create new contact with contact additional info of post. + if post.get('add_contact_information'): + valid_contact_email = tools.email_normalize(post.get('contact_email')) + # Here, the phone is not formatted. To format it, one needs a country. Based on a country, from geoip for instance. + # The problem is that one could propose a track in country A with phone number of country B. Validity is therefore + # quite tricky. We accept any format of contact_phone. Could be improved with select country phone widget. + if valid_contact_email or post.get('contact_phone'): + if visitor_partner and valid_contact_email == visitor_partner.email_normalized: + contact = visitor_partner + else: + contact = request.env['res.partner'].sudo().create({ + 'email': valid_contact_email, + 'name': post.get('contact_name'), + 'phone': post.get('contact_phone'), + }) + else: + raise exceptions.ValidationError(_("Format Error : please enter a valid contact phone or contact email.")) + # If the speaker email is the same as logged user's, then also uses its partner on track, same as above. + else: + valid_speaker_email = tools.email_normalize(post['partner_email']) + if visitor_partner and valid_speaker_email == visitor_partner.email_normalized: + contact = visitor_partner + + track = request.env['event.track'].with_context({'mail_create_nosubscribe': True}).sudo().create({ 'name': post['track_name'], + 'partner_id': contact.id, 'partner_name': post['partner_name'], - 'partner_email': post['email_from'], - 'partner_phone': post['phone'], - 'partner_biography': plaintext2html(post['biography']), + 'partner_email': post['partner_email'], + 'partner_phone': post['partner_phone'], + 'partner_function': post['partner_function'], + 'contact_phone': contact.phone, + 'contact_email': contact.email, 'event_id': event.id, - 'tag_ids': [(6, 0, tags)], - 'user_id': False, + 'tag_ids': [(6, 0, valid_tag_indices)], 'description': plaintext2html(post['description']), - 'image': base64.b64encode(post['image'].read()) if post.get('image') else False + 'user_id': False, + 'image': base64.b64encode(post['image'].read()) if post.get('image') else False, }) + if request.env.user != request.website.user_id: track.sudo().message_subscribe(partner_ids=request.env.user.partner_id.ids) - else: - partner = request.env['res.partner'].sudo().search([('email', '=', post['email_from'])]) - if partner: - track.sudo().message_subscribe(partner_ids=partner.ids) - return request.render("website_event_track.event_track_proposal", {'track': track, 'event': event}) + + return request.render("website_event_track.event_track_proposal", {'track': track, 'event': event, 'main_object': event}) + + # ACL : This route is necessary since rpc search_read method in js is not accessible to all users (e.g. public user). + @http.route(['''/event/track_tag/search_read'''], type='json', auth="public", website=True) + def website_event_track_fetch_tags(self, domain, fields): + return request.env['event.track.tag'].search_read(domain, fields) # ------------------------------------------------------------ # TOOLS diff --git a/addons/website_event_track/data/mail_template_data.xml b/addons/website_event_track/data/mail_template_data.xml index 2365d4717ce..3772e6fe60a 100644 --- a/addons/website_event_track/data/mail_template_data.xml +++ b/addons/website_event_track/data/mail_template_data.xml @@ -7,7 +7,7 @@
- Dear ${object.partner_name or ''}
+ Dear ${object.partner_id.name or object.partner_name or ''}
We are pleased to inform you that your proposal ${object.name} has been accepted and confirmed for the event ${object.event_id.name}.
You will find more details here: diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index 7af875da34d..6c753bcb081 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -51,30 +51,42 @@ class Track(models.Model): " * Grey is the default situation\n" " * Red indicates something is preventing the progress of this track\n" " * Green indicates the track is ready to be pulled to the next stage") - # speaker - partner_id = fields.Many2one('res.partner', 'Speaker') + partner_id = fields.Many2one('res.partner', 'Contact', help="Contact of the track, may be different from speaker.") + # speaker information partner_name = fields.Char( string='Name', compute='_compute_partner_name', - readonly=False, store=True, tracking=10) + readonly=False, store=True, tracking=10, + help='Speaker name is used for public display and may vary from contact name') partner_email = fields.Char( string='Email', compute='_compute_partner_email', - readonly=False, store=True, tracking=20) + readonly=False, store=True, tracking=20, + help='Speaker email is used for public display and may vary from contact email') partner_phone = fields.Char( string='Phone', compute='_compute_partner_phone', - readonly=False, store=True, tracking=30) + readonly=False, store=True, tracking=30, + help='Speaker phone is used for public display and may vary from contact phone') partner_biography = fields.Html( string='Biography', compute='_compute_partner_biography', readonly=False, store=True) partner_function = fields.Char( - 'Job Position', related='partner_id.function', - compute_sudo=True, readonly=True) + 'Job Position', compute='_compute_partner_function', + store=True, readonly=False) partner_company_name = fields.Char( - 'Company Name', related='partner_id.parent_name', - compute_sudo=True, readonly=True) + 'Company Name', compute='_compute_partner_company_name', + readonly=False, store=True) image = fields.Image( string="Speaker Photo", compute="_compute_speaker_image", readonly=False, store=True, max_width=256, max_height=256) + # contact information + contact_email = fields.Char( + string='Contact Email', compute='_compute_contact_email', + readonly=False, store=True, tracking=20, + help="Contact email is private and used internally") + contact_phone = fields.Char( + string='Contact Phone', compute='_compute_contact_phone', + readonly=False, store=True, tracking=30, + help="Contact phone is private and used internally") location_id = fields.Many2one('event.track.location', 'Location') # time information date = fields.Datetime('Track Date') @@ -149,19 +161,19 @@ class Track(models.Model): @api.depends('partner_id') def _compute_partner_name(self): for track in self: - if not track.partner_name or track.partner_id: + if track.partner_id and not track.partner_name: track.partner_name = track.partner_id.name @api.depends('partner_id') def _compute_partner_email(self): for track in self: - if not track.partner_email or track.partner_id: + if track.partner_id and not track.partner_email: track.partner_email = track.partner_id.email @api.depends('partner_id') def _compute_partner_phone(self): for track in self: - if not track.partner_phone or track.partner_id: + if track.partner_id and not track.partner_phone: track.partner_phone = track.partner_id.phone @api.depends('partner_id') @@ -173,12 +185,40 @@ class Track(models.Model): not is_html_empty(track.partner_id.website_description): track.partner_biography = track.partner_id.website_description + @api.depends('partner_id') + def _compute_partner_function(self): + for track in self: + if track.partner_id and not track.partner_function: + track.partner_function = track.partner_id.function + + @api.depends('partner_id', 'partner_id.company_type') + def _compute_partner_company_name(self): + for track in self: + if track.partner_id.company_type == 'company': + track.partner_company_name = track.partner_id.name + elif not track.partner_company_name: + track.partner_company_name = track.partner_id.parent_id.name + @api.depends('partner_id') def _compute_speaker_image(self): for track in self: if not track.image: track.image = track.partner_id.image_256 + # CONTACT + + @api.depends('partner_id', 'partner_id.email') + def _compute_contact_email(self): + for track in self: + if track.partner_id: + track.contact_email = track.partner_id.email + + @api.depends('partner_id', 'partner_id.phone') + def _compute_contact_phone(self): + for track in self: + if track.partner_id: + track.contact_phone = track.partner_id.phone + # TIME @api.depends('date', 'duration') @@ -342,8 +382,6 @@ class Track(models.Model): stage = self.env['event.track.stage'].browse(vals['stage_id']) self._synchronize_with_stage(stage) res = super(Track, self).write(vals) - if vals.get('partner_id'): - self.message_subscribe([vals['partner_id']]) return res @api.model @@ -361,6 +399,48 @@ class Track(models.Model): # MESSAGING # ------------------------------------------------------------ + def _message_get_default_recipients(self): + return { + track.id: { + 'partner_ids': [], + 'email_to': track.contact_email or track.partner_email, + 'email_cc': False + } for track in self + } + + def _message_get_suggested_recipients(self): + recipients = super(Track, self)._message_get_suggested_recipients() + for track in self: + if track.partner_id: + if track.partner_id not in recipients: + track._message_add_suggested_recipient(recipients, partner=track.partner_id, reason=_('Contact')) + else: + # Priority: contact information then speaker information + if track.contact_email and track.contact_email != track.partner_id.email: + track._message_add_suggested_recipient(recipients, email=track.contact_email, reason=_('Contact Email')) + if not track.contact_email and track.partner_email and track.partner_email != track.partner_id.email: + track._message_add_suggested_recipient(recipients, email=track.partner_email, reason=_('Speaker Email')) + return recipients + + def _message_post_after_hook(self, message, msg_vals): + # OVERRIDE + # If no partner is set on track when sending a message, then we create one from suggested contact selected. + # If one or more have been created from chatter (Suggested Recipients) we search for the expected one and write the partner_id on track. + if msg_vals.get('partner_ids') and not self.partner_id: + # Contact(s) created from chatter set on track : we verify if at least one is the expected contact + # linked to the track. (created from contact_email if any, then partner_email if any) + main_email = self.contact_email or self.partner_email + if main_email: + new_partner = message.partner_ids.filtered(lambda partner: partner.email == main_email) + if new_partner: + main_email_string = 'contact_email' if self.contact_email else 'partner_email' + self.search([ + ('partner_id', '=', False), + (main_email_string, '=', new_partner.email), + ('stage_id.is_cancel', '=', False), + ]).write({'partner_id': new_partner.id}) + return super(Track, self)._message_post_after_hook(message, msg_vals) + def _track_template(self, changes): res = super(Track, self)._track_template(changes) track = self[0] @@ -381,27 +461,6 @@ class Track(models.Model): return self.env.ref('website_event_track.mt_track_ready') return super(Track, self)._track_subtype(init_values) - def _message_get_suggested_recipients(self): - recipients = super(Track, self)._message_get_suggested_recipients() - for track in self: - if track.partner_email and track.partner_email != track.partner_id.email: - track._message_add_suggested_recipient(recipients, email=track.partner_email, reason=_('Speaker Email')) - return recipients - - def _message_post_after_hook(self, message, msg_vals): - if self.partner_email and not self.partner_id: - # we consider that posting a message with a specified recipient (not a follower, a specific one) - # on a document without customer means that it was created through the chatter using - # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.partner_email) - if new_partner: - self.search([ - ('partner_id', '=', False), - ('partner_email', '=', new_partner.email), - ('stage_id.is_cancel', '=', False), - ]).write({'partner_id': new_partner.id}) - return super(Track, self)._message_post_after_hook(message, msg_vals) - # ------------------------------------------------------------ # ACTION # ------------------------------------------------------------ diff --git a/addons/website_event_track/static/src/js/website_event_track_proposal_form.js b/addons/website_event_track/static/src/js/website_event_track_proposal_form.js new file mode 100644 index 00000000000..808517b3682 --- /dev/null +++ b/addons/website_event_track/static/src/js/website_event_track_proposal_form.js @@ -0,0 +1,169 @@ +odoo.define('website_event_track.website_event_track_proposal_form', function (require) { +'use strict'; + +var core = require('web.core'); +var publicWidget = require('web.public.widget'); + +var _t = core._t; + +publicWidget.registry.websiteEventTrackProposalForm = publicWidget.Widget.extend({ + +selector: '.o_website_event_track_proposal_form', +events: { + 'click .o_wetrack_add_contact_information_checkbox': '_onAdvancedContactToggle', + 'input input[name="partner_name"]': '_onPartnerNameInput', + 'click .o_wetrack_proposal_submit_button': '_onProposalFormSubmit', +}, + +/** + * @override + */ +init: function () { + this._super(...arguments); + this.useAdvancedContact = false; +}, + +//-------------------------------------------------------------------------- +// Private +//-------------------------------------------------------------------------- + +/** + * Evaluate and return validity of form input fields: + * - 1) error 'invalidFormInputs' : Invalid ones are marked as is-invalid and o_wetrack_input_error. + * - 2) error 'noContactMean' : Contact mean fields are marked as is-invalid and contact + * section as o_wetrack_no_contact_mean_error if none of them is filled. + * + * @private + * @returns {Boolean} - True if no error remain, false otherwise + */ +_isFormValid: function () { + var formErrors = []; + + // 1) Valid Form Inputs + this.$('.form-group').each(function (index, field) { + var $field = $(field); + // Validate current input, if not select2 field. + var inputs = $field.find('.form-control').not('.o_wetrack_select2_tags'); + var invalidInputs = inputs.toArray().filter(function (input) { + return !input.checkValidity(); + }); + + $field.find('.form-control').removeClass('o_wetrack_input_error is-invalid'); + if (invalidInputs.length) { + $field.find('.form-control').addClass('o_wetrack_input_error is-invalid'); + formErrors.push('invalidFormInputs'); + } + }); + + // 2) Advanced Contact Must Have a Contact Mean + if (this.useAdvancedContact) { + var hasContactMean = this.$('.o_wetrack_contact_phone_input').val() || + this.$('.o_wetrack_contact_email_input').val(); + if (!hasContactMean) { + this.$('.o_wetrack_contact_information').addClass('o_wetrack_no_contact_mean_error'); + this.$('.o_wetrack_contact_mean').addClass('is-invalid'); + formErrors.push('noContactMean'); + } else { + this.$('.o_wetrack_contact_information').removeClass('o_wetrack_no_contact_mean_error'); + this.$('.o_wetrack_contact_mean:not(".o_wetrack_input_error")').removeClass('is-invalid'); + } + } + + // Form Validity and Error Display + this._updateErrorDisplay(formErrors); + return formErrors.length === 0; +}, + +/** + * If there are still errors in form, display the error section and + * compose the error message accordingly. + * + * @private + * @param {Array} errors - Names of errors still present in form. + */ +_updateErrorDisplay: function(errors) { + + this.$('.o_wetrack_proposal_error_section').toggleClass('d-none', !errors.length); + + var errorMessage = ''; + var $errorElement = this.$('.o_wetrack_proposal_error_message'); + + if (errors.includes('invalidFormInputs')) { errorMessage += _t(' Please fill out the form correctly.'); } + if (errors.includes('noContactMean')) { errorMessage += _t(' Please enter either a contact email address or a contact phone number.'); } + + $errorElement.text(errorMessage).change(); +}, + +//-------------------------------------------------------------------------- +// Handlers +//-------------------------------------------------------------------------- + +/** + * Display / Hide Additional Contact Information section when toggling + * the checkbox on the form o_wetrack_add_contact_information_checkbox. + * Also empty the email to prevent hidden email format error. + * + * @private + * @param {Event} ev + */ +_onAdvancedContactToggle: function(ev) { + this.useAdvancedContact = !this.useAdvancedContact; + var $contactName = this.$(".o_wetrack_contact_name_input")[0]; + var $advancedInformation = this.$('.o_wetrack_contact_information'); + + if (this.useAdvancedContact) { + $advancedInformation.removeClass('d-none'); + $contactName.setAttribute("required", "True"); + } else { + this.$('.o_wetrack_contact_email_input').val('').change(); + $advancedInformation.addClass('d-none'); + $contactName.removeAttribute("required"); + } +}, + +/** + * Propagates the new input on speaker name to contact name, as long as the latter + * is the start of partner name. Otherwise, do not modify existing contact name. + * + * @private + * @param {Event} ev + */ +_onPartnerNameInput: function(ev) { + var partnerNameText = $(ev.currentTarget).val(); + var contactNameText = this.$(".o_wetrack_contact_name_input").val(); + if (partnerNameText.startsWith(contactNameText)) { + this.$(".o_wetrack_contact_name_input").val(partnerNameText).change(); + } +}, + +/** + * Submits the form if no errors are present in the form after validation. + * + * @private + * @param {Event} ev + */ +_onProposalFormSubmit: function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + + // Prevent further clicking + this.$target.find('.o_wetrack_proposal_submit_button') + .addClass('disabled') + .attr('disabled', 'disabled'); + + // Submission of the form if no errors remain + if (this._isFormValid()) { + this.$el.submit(); + this.$target[0].reset(); + } + + // Restore button + this.$target.find('.o_wetrack_proposal_submit_button') + .removeAttr('disabled') + .removeClass('disabled'); +}, +}); + +return publicWidget.registry.websiteEventTrackProposalForm; + +}); diff --git a/addons/website_event_track/static/src/js/website_event_track_proposal_form_tags.js b/addons/website_event_track/static/src/js/website_event_track_proposal_form_tags.js new file mode 100644 index 00000000000..fa3dc9267c3 --- /dev/null +++ b/addons/website_event_track/static/src/js/website_event_track_proposal_form_tags.js @@ -0,0 +1,101 @@ +odoo.define('website_event_track.website_event_track_proposal_form_tags', function (require) { +'use strict'; + +var core = require('web.core'); +var publicWidget = require('web.public.widget'); + +var _t = core._t; + +publicWidget.registry.websiteEventTrackProposalFormTags = publicWidget.Widget.extend({ + +selector: '.o_website_event_track_proposal_form_tags', + +start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._bindSelect2Dropdown(); + }); +}, + +/** + * Handler for select2 on tags added to the proposal track form. + * + * @private + */ +_bindSelect2Dropdown: function () { + var self = this; + this.$('.o_wetrack_select2_tags').select2(this._select2Wrapper(_t('Select categories'), + function () { + return self._rpc({ + route: "/event/track_tag/search_read", + params: { + fields: ['name', 'category_id'], + domain: [], + } + }); + }) + ); +}, + +/** + * Wrapper for select2. Load data from server once and store it. + * Tags are sorted in alphabetical order and have format "tag.category.name : tag.name" + * Or "tag.name" if tag does not belong to any category. + * + * @private + * @param {String} tag - Placeholder for element. + * @param {Function} fetchFNC - Fetch data from remote location. Should return a Promise. + * Resolved data should be array of objects with id and name. eg. [{'id': id, 'name': 'text'}, ...] + * @param {String} nameKey - (optional) the name key of the returned record + * ('name' if not provided) + * @returns {Object} select2 wrapper object +*/ +_select2Wrapper: function (tag, fetchFNC, nameKey) { + nameKey = nameKey || 'name'; + + var values = { + placeholder: tag, + allowClear: true, + formatNoMatches: _t('No results found'), + selection_data: false, + fetch_rpc_fnc: fetchFNC, + multiple: 'multiple', + sorter: data => data.sort((a, b) => a.text.localeCompare(b.text)), + + // category_id structure : [id, tag category name] + fill_data: function (query, data) { + var that = this, + tags = {results: []}; + _.each(data, function (obj) { + // select tags matching either category or tag name + if (that.matcher(query.term, obj[nameKey]) || that.matcher(query.term, obj.category_id[1])) { + if (obj.category_id[1]) { + tags.results.push({id: obj.id, text: obj.category_id[1] + " : " + obj[nameKey]}); + } else { + tags.results.push({id: obj.id, text: obj[nameKey]}); + } + } + }); + query.callback(tags); + }, + + query: function (query) { + var that = this; + // fetch data only once and store it + if (!this.selection_data) { + this.fetch_rpc_fnc().then(function (data) { + that.fill_data(query, data); + that.selection_data = data; + }); + } else { + this.fill_data(query, this.selection_data); + } + } + }; + return values; +}, +}); + +return publicWidget.registry.websiteEventTrackProposalFormTags; + +}); diff --git a/addons/website_event_track/tests/test_track_internals.py b/addons/website_event_track/tests/test_track_internals.py index d6ebc697659..f882100f525 100644 --- a/addons/website_event_track/tests/test_track_internals.py +++ b/addons/website_event_track/tests/test_track_internals.py @@ -9,7 +9,6 @@ from odoo.addons.website.models.website_visitor import WebsiteVisitor from odoo.addons.website_event.tests.common import TestEventOnlineCommon from odoo.tests.common import users - class TestTrackData(TestEventOnlineCommon): @users('user_eventmanager') @@ -70,15 +69,31 @@ class TestTrackData(TestEventOnlineCommon): new_track.write({'partner_id': customer.id}) self.assertEqual(new_track.partner_id, customer) self.assertEqual( - new_track.partner_name, customer.name, - 'Track customer should take over existing value') + new_track.partner_name, 'Nibbler In Space', + 'Track customer should not take over existing value') self.assertEqual( new_track.partner_email, customer.email, - 'Track customer should take over existing value') + 'Track customer should take over empty value') self.assertEqual( - new_track.partner_phone, customer.phone, - 'Track customer should take over existing value') + new_track.partner_phone, test_phone, + 'Track customer should not take over existing value') + # contacts fields should be updated with track customer + new_track = self.env['event.track'].create({ + 'event_id': event.id, + 'name': 'Mega Track', + 'contact_phone': test_phone, + }) + self.assertEqual(new_track.contact_email, False) + self.assertEqual(new_track.contact_phone, test_phone) + new_track.write({'partner_id': customer.id}) + self.assertEqual(new_track.partner_id, customer) + self.assertEqual( + new_track.contact_email, customer.email, + 'Track customer should take over empty contact email value') + self.assertEqual( + new_track.contact_phone, customer.phone, + 'Track customer should take over existing contact phone value') class TestTrackSuggestions(TestEventOnlineCommon): diff --git a/addons/website_event_track/views/event_track_tag_views.xml b/addons/website_event_track/views/event_track_tag_views.xml index 780b9814b8b..9a6ae42e23a 100644 --- a/addons/website_event_track/views/event_track_tag_views.xml +++ b/addons/website_event_track/views/event_track_tag_views.xml @@ -62,8 +62,8 @@ - + diff --git a/addons/website_event_track/views/event_track_templates_proposal.xml b/addons/website_event_track/views/event_track_templates_proposal.xml index 794c5e54fe4..b308968dfcd 100644 --- a/addons/website_event_track/views/event_track_templates_proposal.xml +++ b/addons/website_event_track/views/event_track_templates_proposal.xml @@ -15,96 +15,91 @@ This event does not accept proposals.

-
+
+
+
+

+ Introduction +

+

+ We will accept a broad range of + presentations, from reports on academic and + commercial projects to tutorials and case + studies. As long as the presentation is + interesting and potentially useful to the + audience, it will be considered for + inclusion in the programme. +

+
+
-
-
-

- Introduction -

-

- We will accept a broad range of - presentations, from reports on academic and - commercial projects to tutorials and case - studies. As long as the presentation is - interesting and potentially useful to the - audience, it will be considered for - inclusion in the programme. -

-
-
-
-

Application

-

- Fill this form to propose your talk. -

- -
-
-
- -
- -
- +
+ + +
+

Talk Intro

+
+ What is your talk about?
-
-
- -
-