diff --git a/addons/test_event_full/__manifest__.py b/addons/test_event_full/__manifest__.py index bd0263e341c..8423fc06d7f 100644 --- a/addons/test_event_full/__manifest__.py +++ b/addons/test_event_full/__manifest__.py @@ -21,7 +21,6 @@ automatic lead generation, full Online support, ... 'website_event_sale', 'website_event_track', 'website_event_track_exhibitor', - 'website_event_track_session', 'website_event_track_live', 'website_event_track_quiz', ], diff --git a/addons/website_event_track/controllers/event_track.py b/addons/website_event_track/controllers/event_track.py index e338f384580..782b1e839ee 100644 --- a/addons/website_event_track/controllers/event_track.py +++ b/addons/website_event_track/controllers/event_track.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from ast import literal_eval from datetime import timedelta +from pytz import timezone, utc from werkzeug.exceptions import Forbidden, NotFound import babel @@ -9,13 +11,28 @@ import babel.dates import base64 import pytz -from odoo import exceptions, http, fields +from odoo import exceptions, http, fields, _ from odoo.http import request -from odoo.tools import plaintext2html, html2plaintext +from odoo.osv import expression +from odoo.tools import is_html_empty, plaintext2html class EventTrackController(http.Controller): + def _get_event_tracks_base_domain(self, event): + """ Base domain for displaying tracks. Restrict to accepted or published + tracks for people not managing events. Unpublished tracks may be displayed + but not reachable for teasing purpose. """ + search_domain_base = [ + ('event_id', '=', event.id), + ] + if not request.env.user.has_group('event.group_event_user'): + search_domain_base = expression.AND([ + search_domain_base, + ['|', ('is_published', '=', True), ('is_accepted', '=', True)] + ]) + return search_domain_base + # ------------------------------------------------------------ # TRACK LIST VIEW # ------------------------------------------------------------ @@ -24,27 +41,110 @@ class EventTrackController(http.Controller): '''/event//track''', '''/event//track/tag/''' ], type='http', auth="public", website=True, sitemap=False) - def event_tracks(self, event, tag=None, **post): - if not event.can_access_from_current_website() or (tag and tag.color == 0): + def event_tracks(self, event, tag=None, **searches): + """ Main route + + :param event: event whose tracks are about to be displayed; + :param tag: deprecated: search for a specific tag + :param searches: frontend search dict, containing + + * 'search': search string; + * 'tags': list of tag IDs for filtering; + """ + if not event.can_access_from_current_website(): raise NotFound() - event = event.with_context(tz=event.date_tz or 'UTC') - searches = {} - if tag: - searches.update(tag=tag.id) - tracks = event.track_ids.filtered(lambda track: tag in track.tag_ids) - else: - tracks = event.track_ids + return request.render( + "website_event_track.tracks_session", + self._event_tracks_get_values(event, tag=tag, **searches) + ) - values = { + def _event_tracks_get_values(self, event, tag=None, **searches): + # init and process search terms + searches.setdefault('search', '') + searches.setdefault('search_wishlist', '') + searches.setdefault('tags', '') + search_domain = self._get_event_tracks_base_domain(event) + + # search on content + if searches.get('search'): + search_domain = expression.AND([ + search_domain, + [('name', 'ilike', searches['search'])] + ]) + + # search on tags + search_tags = self._get_search_tags(searches['tags']) + if not search_tags and tag: # backward compatibility + search_tags = tag + if search_tags: + # Example: You filter on age: 10-12 and activity: football. + # Doing it this way allows to only get events who are tagged "age: 10-12" AND "activity: football". + # Add another tag "age: 12-15" to the search and it would fetch the ones who are tagged: + # ("age: 10-12" OR "age: 12-15") AND "activity: football + grouped_tags = dict() + for search_tag in search_tags: + grouped_tags.setdefault(search_tag.category_id, list()).append(search_tag) + search_domain_items = [ + [('tag_ids', 'in', [tag.id for tag in grouped_tags[group]])] + for group in grouped_tags + ] + search_domain = expression.AND([ + search_domain, + *search_domain_items + ]) + + # fetch data to display with TZ set for both event and tracks + now_tz = utc.localize(fields.Datetime.now().replace(microsecond=0), is_dst=False).astimezone(timezone(event.date_tz)) + today_tz = now_tz.date() + event = event.with_context(tz=event.date_tz or 'UTC') + tracks_sudo = event.env['event.track'].sudo().search(search_domain, order='date asc') + tag_categories = request.env['event.track.tag.category'].sudo().search([]) + + # filter on wishlist (as post processing due to costly search on is_reminder_on) + if searches.get('search_wishlist'): + tracks_sudo = tracks_sudo.filtered(lambda track: track.is_reminder_on) + + # organize categories for display: announced, live, soon and day-based + tracks_announced = tracks_sudo.filtered(lambda track: not track.date) + tracks_wdate = tracks_sudo - tracks_announced + date_begin_tz_all = list(set( + dt.date() + for dt in self._get_dt_in_event_tz(tracks_wdate.mapped('date'), event) + )) + date_begin_tz_all.sort() + tracks_sudo_live = tracks_wdate.filtered(lambda track: track.is_published and track.is_track_live) + tracks_sudo_soon = tracks_wdate.filtered(lambda track: track.is_published and not track.is_track_live and track.is_track_soon) + tracks_by_day = [] + for display_date in date_begin_tz_all: + matching_tracks = tracks_wdate.filtered(lambda track: self._get_dt_in_event_tz([track.date], event)[0].date() == display_date) + tracks_by_day.append({'date': display_date, 'name': display_date, 'tracks': matching_tracks}) + if tracks_announced: + tracks_announced = tracks_announced.sorted('wishlisted_by_default', reverse=True) + tracks_by_day.append({'date': False, 'name': _('Coming soon'), 'tracks': tracks_announced}) + + # return rendering values + return { + # event information 'event': event, 'main_object': event, - 'tracks': tracks, - 'tags': event.tracks_tag_ids, + # tracks display information + 'tracks': tracks_sudo, + 'tracks_by_day': tracks_by_day, + 'tracks_live': tracks_sudo_live, + 'tracks_soon': tracks_sudo_soon, + 'today_tz': today_tz, + # search information 'searches': searches, - 'html2plaintext': html2plaintext + 'search_key': searches['search'], + 'search_wishlist': searches['search_wishlist'], + 'search_tags': search_tags, + 'tag_categories': tag_categories, + # environment + 'is_html_empty': is_html_empty, + 'hostname': request.httprequest.host.split(':')[0], + 'user_event_manager': request.env.user.has_group('event.group_event_manager'), } - return request.render("website_event_track.tracks", values) # ------------------------------------------------------------ # AGENDA VIEW @@ -219,14 +319,41 @@ class EventTrackController(http.Controller): # TRACK PAGE VIEW # ------------------------------------------------------------ - @http.route(['''/event//track/2 + + Try Now + http://www.example.com + 10 First Day Wrapup @@ -387,6 +391,10 @@ 2 + + Try Now + http://www.example.com + 10 diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index 2ec57bddde4..9c44d2d290d 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -2,6 +2,8 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import timedelta +from pytz import utc +from random import randint from odoo import api, fields, models from odoo.addons.http_routing.models.ir_http import slug @@ -78,6 +80,27 @@ class Track(models.Model): date = fields.Datetime('Track Date') date_end = fields.Datetime('Track End Date', compute='_compute_end_date', store=True) duration = fields.Float('Duration', default=1.5, help="Track duration in hours.") + is_track_live = fields.Boolean( + 'Is Track Live', compute='_compute_track_time_data', + help="Track has started and is ongoing") + is_track_soon = fields.Boolean( + 'Is Track Soon', compute='_compute_track_time_data', + help="Track begins soon") + is_track_today = fields.Boolean( + 'Is Track Today', compute='_compute_track_time_data', + help="Track begins today") + is_track_upcoming = fields.Boolean( + 'Is Track Upcoming', compute='_compute_track_time_data', + help="Track is not yet started") + is_track_done = fields.Boolean( + 'Is Track Done', compute='_compute_track_time_data', + help="Track is finished") + track_start_remaining = fields.Integer( + 'Minutes before track starts', compute='_compute_track_time_data', + help="Remaining time before track starts (seconds)") + track_start_relative = fields.Integer( + 'Minutes compare to track start', compute='_compute_track_time_data', + help="Relative time compared to track start (seconds)") # frontend description website_image = fields.Image(string="Website Image", max_width=1024, max_height=1024) website_image_url = fields.Char( @@ -100,6 +123,18 @@ class Track(models.Model): wishlisted_by_default = fields.Boolean( string='Always Wishlisted', help="""If set, the talk will be starred for each attendee registered to the event. The attendee won't be able to un-star this talk.""") + # Call to action + website_cta = fields.Boolean('Magic Button') + website_cta_title = fields.Char('Button Title') + website_cta_url = fields.Char('Button Target URL') + website_cta_delay = fields.Integer('Button appears') + # time information for CTA + is_website_cta_live = fields.Boolean( + 'Is CTA Live', compute='_compute_cta_time_data', + help="CTA button is available") + website_cta_start_remaining = fields.Integer( + 'Minutes before CTA starts', compute='_compute_cta_time_data', + help="Remaining time before CTA starts (seconds)") @api.depends('name') def _compute_website_url(self): @@ -219,12 +254,61 @@ class Track(models.Model): ]) return [('id', 'in', track_visitors.track_id.ids)] + # TIME + + @api.depends('date', 'date_end') + def _compute_track_time_data(self): + """ Compute start and remaining time for track itself. Do everything in + UTC as we compute only time deltas here. """ + now_utc = utc.localize(fields.Datetime.now().replace(microsecond=0)) + for track in self: + if not track.date: + track.is_track_live = track.is_track_soon = track.is_track_today = track.is_track_upcoming = track.is_track_done = False + track.track_start_relative = track.track_start_remaining = 0 + continue + date_begin_utc = utc.localize(track.date, is_dst=False) + date_end_utc = utc.localize(track.date_end, is_dst=False) + track.is_track_live = date_begin_utc <= now_utc < date_end_utc + track.is_track_soon = (date_begin_utc - now_utc).total_seconds() < 30*60 if date_begin_utc > now_utc else False + track.is_track_today = date_begin_utc.date() == now_utc.date() + track.is_track_upcoming = date_begin_utc > now_utc + track.is_track_done = date_end_utc <= now_utc + if date_begin_utc >= now_utc: + track.track_start_relative = int((date_begin_utc - now_utc).total_seconds()) + track.track_start_remaining = track.track_start_relative + else: + track.track_start_relative = int((now_utc - date_begin_utc).total_seconds()) + track.track_start_remaining = 0 + + @api.depends('date', 'date_end', 'website_cta', 'website_cta_delay') + def _compute_cta_time_data(self): + """ Compute start and remaining time for track itself. Do everything in + UTC as we compute only time deltas here. """ + now_utc = utc.localize(fields.Datetime.now().replace(microsecond=0)) + for track in self: + if not track.website_cta: + track.is_website_cta_live = track.website_cta_start_remaining = False + continue + + date_begin_utc = utc.localize(track.date, is_dst=False) + timedelta(minutes=track.website_cta_delay or 0) + date_end_utc = utc.localize(track.date_end, is_dst=False) + track.is_website_cta_live = date_begin_utc <= now_utc <= date_end_utc + if date_begin_utc >= now_utc: + td = date_begin_utc - now_utc + track.website_cta_start_remaining = int(td.total_seconds()) + else: + track.website_cta_start_remaining = 0 + # ------------------------------------------------------------ # CRUD # ------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): + for values in vals_list: + if values.get('website_cta_url'): + values['website_cta_url'] = self.env['res.partner']._clean_website(values['website_cta_url']) + tracks = super(Track, self).create(vals_list) for track in tracks: @@ -239,6 +323,8 @@ class Track(models.Model): return tracks def write(self, vals): + if vals.get('website_cta_url'): + vals['website_cta_url'] = self.env['res.partner']._clean_website(vals['website_cta_url']) if 'stage_id' in vals and 'kanban_state' not in vals: vals['kanban_state'] = 'normal' if vals.get('stage_id'): @@ -317,6 +403,9 @@ class Track(models.Model): 'type': 'ir.actions.act_window', } + def get_backend_menu_id(self): + return self.env.ref('event.event_main_menu').id + # ------------------------------------------------------------ # TOOLS # ------------------------------------------------------------ @@ -352,3 +441,52 @@ class Track(models.Model): } for track in missing]) return track_visitors + + def _get_track_suggestions(self, restrict_domain=None, limit=None): + """ Returns the next tracks suggested after going to the current one + given by self. Tracks always belong to the same event. + + Heuristic is + + * live first; + * then ordered by start date, finished being sent to the end; + * wishlisted (manually or by default); + * tag matching with current track; + * location matching with current track; + * finally a random to have an "equivalent wave" randomly given; + + :param restrict_domain: an additional domain to restrict candidates; + :param limit: number of tracks to return; + """ + self.ensure_one() + + base_domain = [ + '&', + ('event_id', '=', self.event_id.id), + ('id', '!=', self.id), + ] + if restrict_domain: + base_domain = expression.AND([ + base_domain, + restrict_domain + ]) + + track_candidates = self.search(base_domain, limit=None, order='date asc') + if not track_candidates: + return track_candidates + + track_candidates = track_candidates.sorted( + lambda track: + (track.is_published, + track.is_track_live, + track.track_start_remaining > 0, + -1 * track.track_start_remaining, + track.is_reminder_on, + not track.wishlisted_by_default, + len(track.tag_ids & self.tag_ids), + track.location_id == self.location_id, + randint(0, 20), + ), reverse=True + ) + + return track_candidates[:limit] diff --git a/addons/website_event_track_session/static/src/scss/event_track_templates.scss b/addons/website_event_track/static/src/scss/event_track_templates_online.scss similarity index 100% rename from addons/website_event_track_session/static/src/scss/event_track_templates.scss rename to addons/website_event_track/static/src/scss/event_track_templates_online.scss diff --git a/addons/website_event_track/tests/__init__.py b/addons/website_event_track/tests/__init__.py index 12b9da864c8..aac974d7656 100644 --- a/addons/website_event_track/tests/__init__.py +++ b/addons/website_event_track/tests/__init__.py @@ -2,4 +2,5 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import common +from . import test_track_internals from . import test_website_event diff --git a/addons/website_event_track_session/tests/test_track_internals.py b/addons/website_event_track/tests/test_track_internals.py similarity index 100% rename from addons/website_event_track_session/tests/test_track_internals.py rename to addons/website_event_track/tests/test_track_internals.py diff --git a/addons/website_event_track/views/assets.xml b/addons/website_event_track/views/assets.xml index 120f828839d..c94ee9ece00 100644 --- a/addons/website_event_track/views/assets.xml +++ b/addons/website_event_track/views/assets.xml @@ -5,6 +5,7 @@ + diff --git a/addons/website_event_track/views/event_track_templates_agenda.xml b/addons/website_event_track/views/event_track_templates_agenda.xml index d7ddf00da0b..1608924587c 100644 --- a/addons/website_event_track/views/event_track_templates_agenda.xml +++ b/addons/website_event_track/views/event_track_templates_agenda.xml @@ -150,6 +150,9 @@