import babel.dates import werkzeug from ast import literal_eval from collections import Counter from werkzeug.exceptions import NotFound from odoo import fields, http, _ from odoo.addons.website.controllers.main import QueryURL from odoo.fields import Domain from odoo.http import request from odoo.tools.misc import get_lang from odoo.tools import lazy from odoo.tools.translate import LazyTranslate from odoo.exceptions import UserError, ValidationError _lt = LazyTranslate(__name__) class WebsiteEventController(http.Controller): def sitemap_event(env, rule, qs): if not qs or qs.lower() in '/events': yield {'loc': '/events'} # ------------------------------------------------------------ # EVENT LIST # ------------------------------------------------------------ def _get_events_search_options(self, slug_tags, **post): return { 'displayDescription': True, 'displayDetail': False, 'displayExtraDetail': False, 'displayExtraLink': False, 'displayImage': False, 'allowFuzzy': not post.get('noFuzzy'), 'date': post.get('date'), 'tags': slug_tags or post.get('tags'), 'type': post.get('type'), 'country': post.get('country'), } @http.route([ path for base in ('event', 'events') for path in [ f'/{base}', f'/{base}/page/', f'/{base}/tags/', f'/{base}/tags//page/', ] ], type='http', auth="public", website=True, sitemap=sitemap_event, list_as_website_content=_lt("Events")) def events(self, page=1, slug_tags=None, **searches): if (slug_tags or searches.get('tags', '[]').count(',') > 0) and request.httprequest.method == 'GET' and not searches.get('prevent_redirect'): # Previously, the tags were searched using GET, which caused issues with crawlers (too many hits) # We replaced those with POST to avoid that, but it's not sufficient as bots "remember" crawled pages for a while # This permanent redirect is placed to instruct the bots that this page is no longer valid # Note: We allow a single tag to be GET, to keep crawlers & indexes on those pages # What we really want to avoid is combinatorial explosions # (Tags are formed as a JSON array, so we count ',' to keep it simple) # TODO: remove in a few stable versions (v19?), including the "prevent_redirect" param in templates return request.redirect('/event', code=301) Event = request.env['event.event'] SudoEventType = request.env['event.type'].sudo() searches.setdefault('search', '') searches.setdefault('date', 'scheduled') searches.setdefault('tags', '') searches.setdefault('type', 'all') searches.setdefault('country', 'all') # The previous name of the 'scheduled' filter is 'upcoming' and may still be present in URL's saved by users. if searches['date'] == 'upcoming': searches['date'] = 'scheduled' website = request.website step = 12 # Number of events per page options = self._get_events_search_options(slug_tags, **searches) order = 'date_begin' if searches.get('date', 'scheduled') == 'old': order = 'date_begin desc' order = 'is_published desc, ' + order + ', id desc' search = searches.get('search') event_count, details, fuzzy_search_term = website._search_with_fuzzy("events", search, limit=page * step, order=order, options=options) event_details = details[0] events = event_details.get('results', Event) events = events[(page - 1) * step:page * step] # count by domains without self search domain_search = Domain('name', 'ilike', fuzzy_search_term or searches['search']) if searches['search'] else Domain.TRUE no_date_domain = Domain.AND(event_details['no_date_domain']) dates = event_details['dates'] for date in dates: if date[0] not in ['all', 'old']: date[3] = Event.search_count(no_date_domain & domain_search & Domain(date[2])) no_country_domain = Domain.AND(event_details['no_country_domain']) country_groups = Event._read_group( no_country_domain & domain_search, ["country_id"], ["__count"], order="country_id") countries = [{ 'country_id_count': sum(count for __, count in country_groups), 'country_id': (0, _("All Countries")), }] for g_country, count in country_groups: countries.append({ 'country_id_count': count, 'country_id': g_country and (g_country.id, g_country.sudo().display_name), }) search_tags = self._extract_searched_event_tags(searches, slug_tags) current_date = event_details['current_date'] current_type = None current_country = None if searches["type"] != 'all': current_type = SudoEventType.browse(int(searches['type'])) if searches["country"] != 'all' and searches["country"] != 'online': current_country = request.env['res.country'].browse(int(searches['country'])) pager = website.pager( url=f"/event/tags/{slug_tags}" if slug_tags else "/event", url_args=searches, total=event_count, page=page, step=step, scope=5) keep = QueryURL('/event', ['tags'], tags=slug_tags, **{ key: value for key, value in searches.items() if ( key != 'tags' and ( key == 'search' or (value != 'scheduled' if key == 'date' else value != 'all')) ) }) searches['search'] = fuzzy_search_term or search values = { 'current_date': current_date, 'current_country': current_country, 'current_type': current_type, 'event_ids': events, # event_ids used in website_event_track so we keep name as it is 'dates': dates, 'categories': request.env['event.tag.category'].search([ ('is_published', '=', True), '|', ('website_id', '=', website.id), ('website_id', '=', False) ]), 'countries': countries, 'pager': pager, 'searches': searches, 'search_tags': search_tags, 'keep_event_url': keep, 'slugify_tags': self._slugify_tags, 'search_count': event_count, 'original_search': fuzzy_search_term and search, 'website': website } return request.render("website_event.index", values) # ------------------------------------------------------------ # EVENT PAGE # ------------------------------------------------------------ @http.route(['''/event//page/'''], type='http', auth="public", website=True, sitemap=False, readonly=True) def event_page(self, event, page, **post): values = { 'event': event, 'event_page': True, } base_page_name = page if '.' not in page: page = 'website_event.%s' % page view = request.env["website.event.menu"].sudo().search([ ("event_id", "=", event.id), '|', ("view_id.key", "ilike", page), ("view_id.key", "ilike", f'website_event.{event.name}-{base_page_name.split("/")[-1]}'), ], limit=1).view_id try: # Every event page view should have its own SEO. page = view.key if view else page values['seo_object'] = request.website.get_template(page) values['main_object'] = event except ValueError: # page not found page = 'website.page_404' return request.render(page, values) @http.route(['''/event/'''], type='http', auth="public", website=True, sitemap=True, readonly=True) def event(self, event, **post): if event.menu_id and event.menu_id.child_id: target_url = event.menu_id.child_id[0].url else: target_url = '/event/%s/register' % str(event.id) if post.get('enable_editor') == '1': target_url += '?enable_editor=1' return request.redirect(target_url) @http.route(['''/event//register'''], type='http', auth="public", website=True, sitemap=False, readonly=True) def event_register(self, event, **post): values = self._prepare_event_register_values(event, **post) return request.render("website_event.event_description_full", values) def _prepare_event_register_values(self, event, **post): """Return the require values to render the template.""" urls = lazy(event._get_event_resource_urls) return { 'event': event, 'main_object': event, 'range': range, 'google_url': lazy(lambda: urls.get('google_url')), 'iCal_url': lazy(lambda: urls.get('iCal_url')), 'registration_error_code': post.get('registration_error_code'), 'slots': event.event_slot_ids._filter_open_slots().grouped('date'), 'website_visitor_timezone': request.env['website.visitor']._get_visitor_timezone(), } def _process_tickets_form(self, event, form_details): """ Process posted data about ticket order. Generic ticket are supported for event without tickets (generic registration). :return: list of order per ticket: [{ 'id': if of ticket if any (0 if no ticket), 'ticket': browse record of ticket if any (None if no ticket), 'name': ticket name (or generic 'Registration' name if no ticket), 'quantity': number of registrations for that ticket, 'current_limit_per_order': maximum of ticket orderable }, {...}] """ ticket_order = {} for key, value in form_details.items(): registration_items = key.split('nb_register-') if len(registration_items) != 2: continue ticket_order[int(registration_items[1])] = int(value) ticket_dict = dict((ticket.id, ticket) for ticket in request.env['event.event.ticket'].sudo().search([ ('id', 'in', [tid for tid in ticket_order.keys() if tid]), ('event_id', '=', event.id) ])) tickets = request.env['event.event.ticket'].browse(ticket_dict.keys()) slot = request.env['event.slot'].browse(int(slot)) if (slot := form_details.get("event_slot_id", False)) else slot tickets_limits = tickets._get_current_limit_per_order(slot, event) return [{ 'id': tid if ticket_dict.get(tid) else 0, 'ticket': ticket_dict.get(tid), 'name': ticket_dict[tid]['name'] if ticket_dict.get(tid) else _('Registration'), 'quantity': count, 'current_limit_per_order': tickets_limits.get(tid, next(iter(tickets_limits.values()))), # next is used if the ticket id isn't known (alone event case) } for tid, count in ticket_order.items() if count] @http.route(['/event//registration/slot//tickets'], type='jsonrpc', auth="public", methods=['POST'], website=True) def registration_tickets(self, event, slot_id): """ After slot selection, render ticket selection modal. To restrict the selectable number of tickets, give the slot seats available and each slot tickets seats available to the template. """ slot = request.env['event.slot'].browse(slot_id) slot_tickets = [ (slot, ticket) for ticket in event.event_ticket_ids ] return request.env['ir.ui.view']._render_template("website_event.modal_ticket_registration", { 'event': event, 'event_slot': slot, 'seats_available_slot_tickets': { ticket.id: availability for (_, ticket), availability in zip(slot_tickets, event._get_seats_availability(slot_tickets)) } }) @http.route(['/event//registration/new'], type='jsonrpc', auth="public", methods=['POST'], website=True) def registration_new(self, event, **post): """ After (slot and) tickets selection, render attendee(s) registration form. Slot and tickets availability check already performed in the template. """ tickets = self._process_tickets_form(event, post) slot_id = post.get('event_slot_id', False) # Availability check needed as the total number of tickets can exceed the event/slot available tickets availability_check = True # Double check to verify that we are ordering fewer tickets than the limit conditions set limit_check = not any(ticket['quantity'] > ticket['current_limit_per_order'] for ticket in tickets) if event.seats_limited: ordered_seats = 0 for ticket in tickets: ordered_seats += ticket['quantity'] seats_available = event.seats_available if slot_id: seats_available = request.env['event.slot'].browse(int(slot_id)).seats_available or 0 if seats_available < ordered_seats: availability_check = False if not tickets: return False default_first_attendee = {} if not request.env.user._is_public(): default_first_attendee = { "name": request.env.user.name, "email": request.env.user.email, "phone": request.env.user.phone, } else: visitor = request.env['website.visitor']._get_visitor_from_request() if visitor.email: default_first_attendee = { "name": visitor.display_name, "email": visitor.email, "phone": visitor.mobile, } return request.env['ir.ui.view']._render_template("website_event.registration_attendee_details", { 'tickets': tickets, 'event_slot_id': slot_id, 'event': event, 'availability_check': availability_check, 'default_first_attendee': default_first_attendee, 'limit_check': limit_check, }) def _process_attendees_form(self, event, form_details): """ Process data posted from the attendee details form. Extracts question answers: - For both questions asked 'once_per_order' and questions asked to every attendee - For questions of type 'simple_choice', extracting the suggested answer id - For questions of type 'text_box', extracting the text answer of the attendee. :param form_details: posted data from frontend registration form, like {'1-name': 'r', '1-email': 'r@r.com', '1-phone': '', '1-event_slot_id': '1', '1-event_ticket_id': '1'} """ allowed_fields = request.env['event.registration']._get_website_registration_allowed_fields() registration_fields = {key: v for key, v in request.env['event.registration']._fields.items() if key in allowed_fields} for ticket_id in list(filter(lambda x: x is not None, [form_details[field] if 'event_ticket_id' in field else None for field in form_details.keys()])): if int(ticket_id) not in event.event_ticket_ids.ids and len(event.event_ticket_ids.ids) > 0: raise UserError(_("This ticket is not available for sale for this event")) registrations = {} general_answer_ids = [] general_identification_answers = {} # as we may have several questions populating the same field (e.g: the phone) # we use this to hold the fields that have already been handled # goal is to use the answer to the first question of every 'type' (aka name / phone / email / company name) already_handled_fields_data = {} for key, value in form_details.items(): if not value or '-' not in key: continue key_values = key.split('-') # Special case for handling event_ticket_id data that holds only 2 values if len(key_values) == 2: registration_index, field_name = key_values if field_name not in registration_fields: continue # Only cast when needed, as it might crash here for custom inputs in overrides if isinstance(registration_fields[field_name], (fields.Many2one, fields.Integer)): value = int(value) or False registrations.setdefault(registration_index, dict())[field_name] = value continue if len(key_values) != 3: continue registration_index, question_type, question_id = key_values answer_values = None if question_type == 'simple_choice': answer_values = { 'question_id': int(question_id), 'value_answer_id': int(value) } else: answer_values = { 'question_id': int(question_id), 'value_text_box': value } if answer_values and not int(registration_index): general_answer_ids.append((0, 0, answer_values)) elif answer_values: registrations.setdefault(registration_index, dict())\ .setdefault('registration_answer_ids', list()).append((0, 0, answer_values)) if question_type in ('name', 'email', 'phone', 'company_name')\ and question_type not in already_handled_fields_data.get(registration_index, []): if question_type not in registration_fields: continue field_name = question_type already_handled_fields_data.setdefault(registration_index, list()).append(field_name) if not int(registration_index): general_identification_answers[field_name] = value else: registrations.setdefault(registration_index, dict())[field_name] = value if general_answer_ids: for registration in registrations.values(): registration.setdefault('registration_answer_ids', list()).extend(general_answer_ids) if general_identification_answers: for registration in registrations.values(): registration.update(general_identification_answers) 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) registrations_to_create = [] for registration_values in registration_data: registration_values['event_id'] = event.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'] = False if request.env.user._is_public() else request.env.user.partner_id.id # update registration based on visitor registration_values['visitor_id'] = visitor_sudo.id registrations_to_create.append(registration_values) return request.env['event.registration'].sudo().create(registrations_to_create) @http.route(['''/event//registration/confirm'''], type='http', auth="public", methods=['POST'], website=True) def registration_confirm(self, event, **post): """ Check before creating and finalize the creation of the registrations that we have enough seats for all selected tickets. If we don't, the user is instead redirected to page to register with a formatted error message. """ try: request.env['ir.http']._verify_request_recaptcha_token('website_event_registration') except UserError: return request.redirect('/event/%s/register?registration_error_code=recaptcha_failed' % event.id) registrations_data = self._process_attendees_form(event, post) counter_per_combination = Counter((registration.get('event_slot_id', False), registration['event_ticket_id']) for registration in registrations_data) slot_ids = {slot_id for slot_id, _ in counter_per_combination if slot_id} ticket_ids = {ticket_id for _, ticket_id in counter_per_combination if ticket_id} slots_per_id = {slot.id: slot for slot in self.env['event.slot'].browse(slot_ids)} tickets_per_id = {ticket.id: ticket for ticket in self.env['event.event.ticket'].browse(ticket_ids)} try: event._verify_seats_availability(list({ (slots_per_id.get(slot_id, False), tickets_per_id.get(ticket_id, False), count) for (slot_id, ticket_id), count in counter_per_combination.items() })) except ValidationError: return request.redirect('/event/%s/register?registration_error_code=insufficient_seats' % event.id) attendees_sudo = self._create_attendees_from_registration_post(event, registrations_data) return request.redirect(('/event/%s/registration/success?' % event.id) + werkzeug.urls.url_encode({'registration_ids': ",".join([str(id) for id in attendees_sudo.ids])})) @http.route(['/event//registration/success'], type='http', auth="public", methods=['GET'], website=True, sitemap=False) def event_registration_success(self, event, registration_ids): # fetch the related registrations, make sure they belong to the correct visitor / event pair visitor = request.env['website.visitor']._get_visitor_from_request() if not visitor: raise NotFound() attendees_sudo = request.env['event.registration'].sudo().search([ ('id', 'in', [str(registration_id) for registration_id in registration_ids.split(',')]), ('event_id', '=', event.id), ('visitor_id', '=', visitor.id), ]) return request.render("website_event.registration_complete", self._get_registration_confirm_values(event, attendees_sudo)) def _get_registration_confirm_values(self, event, attendees_sudo): slot = attendees_sudo.event_slot_id urls = event._get_event_resource_urls(slot) return { 'attendees': attendees_sudo, 'event': event, 'google_url': urls.get('google_url'), 'iCal_url': urls.get('iCal_url'), 'slot': slot, 'slots': event.event_slot_ids._filter_open_slots().grouped('date'), 'website_visitor_timezone': request.env['website.visitor']._get_visitor_timezone(), } # ------------------------------------------------------------ # TOOLS (HELPERS) # ------------------------------------------------------------ def get_formated_date(self, event): start_date = fields.Datetime.from_string(event.date_begin).date() end_date = fields.Datetime.from_string(event.date_end).date() month = babel.dates.get_month_names('abbreviated', locale=get_lang(event.env).code)[start_date.month] return ('%s %s%s') % (month, start_date.strftime("%e"), (end_date != start_date and ("-" + end_date.strftime("%e")) or "")) def _extract_searched_event_tags(self, searches, slug_tags): tags = request.env['event.tag'] if slug_tags: tags = self._event_search_tags_slug(slug_tags) elif 'tags' in searches: tags = self._event_search_tags_ids(searches['tags']) return tags def _slugify_tags(self, tag_ids, toggle_tag_id=None): """ Prepares a comma separated slugified tags for the sake of readable URLs. :param toggle_tag_id: add the tag being clicked to the already selected tags as well as in URL; if tag is already selected by the user it is removed from the selected tags (and so from the URL); """ tag_ids = list(tag_ids) if toggle_tag_id and toggle_tag_id in tag_ids: tag_ids.remove(toggle_tag_id) elif toggle_tag_id: tag_ids.append(toggle_tag_id) return ','.join(request.env['ir.http']._slug(tag_id) for tag_id in request.env['event.tag'].browse(tag_ids)) or '' def _event_search_tags_ids(self, search_tags): """ Input: %5B4%5D """ EventTag = request.env['event.tag'] try: tag_ids = literal_eval(search_tags or '') except Exception: # noqa: BLE001 return EventTag return EventTag.search([('id', 'in', tag_ids)]) if tag_ids else EventTag def _event_search_tags_slug(self, search_tags): """ Input: event-1,event-2 """ EventTag = request.env['event.tag'] try: tag_ids = list(filter(None, [request.env['ir.http']._unslug(tag)[1] for tag in (search_tags or '').split(',')])) except Exception: # noqa: BLE001 return EventTag return EventTag.search([('id', 'in', tag_ids)]) if tag_ids else EventTag