From 57da23665778d2a48595ed8a5d55619d5a2451f9 Mon Sep 17 00:00:00 2001 From: David Beguin Date: Fri, 24 Jul 2020 14:27:00 +0000 Subject: [PATCH] [IMP] website_event_track_online: improve agenda page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RATIONALE Events are sometimes held online, gathering a community. In this merge we improve Event application to better support full-online events with improved tracks, wishlists, chat rooms, ... PURPOSE Improve Agenda page, to make it more inline with new Online Event design. Make it more usable, with a better UX and mobile-friendly. SPECIFICATIONS: AGENDA Keep a per-day and per-room display, as we think it is a nice design. Hours should be fixed: all hours between first hour and last hour of a day's tracks should be displayed. Otherwise you have some "holes" in agenda, and each line does not hold the same time range. Display tags and reminders on tracks. Support accepted tracks, not clickable but already displayed for people to organize their venue, especially with the wishlist / reminder feature in mind. Try to make it mobile friendly, by allowing horizontal scroll instead of displaying all rooms on a small device. SPECIFICATIONS: SEARCHING Allow to search on tracks, as well as some basic filtering. Use the look from event page to have something more inlined with current website layout. Due to time limitations, currently agenda custom JS-based search is inlined in bar looking like other event search bars. However no menu based on tags or wishlist is available as everything is done in JS. Routes do not support any tag or search, and adding it would take some time we don't have anymore. KNOWN LIMITATIONS Columns still do not have same width, complicated to do with dynamic column number and overlapping tracks going outside of locations columns. Mobile layout could be improved but is already browsable. Search is still custom, and will be improved in master. LINKS Community PR #53540 Enterprise PR odoo/enterprise#11384 Task ID-2252655 (Main Online Event task) Task ID-2299857 (Event Design Review) Co-Authored-By: David Beguin Co-Authored-By: Elisabeth Dickison Co-Authored-By: Thibault Delavallée --- .../__manifest__.py | 1 + .../controllers/event_track.py | 154 +++++++++++++- .../src/scss/event_track_templates.scss | 97 +++++++++ .../views/event_track_templates_agenda.xml | 190 ++++++++++++++++++ 4 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 addons/website_event_track_online/views/event_track_templates_agenda.xml diff --git a/addons/website_event_track_online/__manifest__.py b/addons/website_event_track_online/__manifest__.py index a846a2942a7..d9f2e82d2d6 100644 --- a/addons/website_event_track_online/__manifest__.py +++ b/addons/website_event_track_online/__manifest__.py @@ -27,6 +27,7 @@ 'views/event_track_stage_views.xml', 'views/event_track_templates_reminder.xml', 'views/event_track_templates.xml', + 'views/event_track_templates_agenda.xml', 'views/event_track_templates_reminder.xml', 'views/event_track_views.xml', 'views/event_track_tag_views.xml', diff --git a/addons/website_event_track_online/controllers/event_track.py b/addons/website_event_track_online/controllers/event_track.py index 675908b678f..9e36151e09d 100644 --- a/addons/website_event_track_online/controllers/event_track.py +++ b/addons/website_event_track_online/controllers/event_track.py @@ -2,8 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from werkzeug.exceptions import Forbidden, NotFound +from datetime import timedelta +import pytz -from odoo import exceptions, http +from odoo import exceptions, http, fields from odoo.addons.website_event_track.controllers.main import WebsiteEventTrackController from odoo.http import request @@ -16,6 +18,156 @@ class EventTrackOnlineController(WebsiteEventTrackController): tracks_sudo = tracks_sudo.filtered(lambda track: track.is_published or track.stage_id.is_accepted) return tracks_sudo + def time_slot_rounder(self, time, rounded_minutes): + """ Rounds to nearest hour by adding a timedelta hour if minute >= rounded_minutes + E.g. : If rounded_minutes = 15 -> 09:26:00 becomes 09:30:00 + 09:17:00 becomes 09:15:00 + """ + return (time.replace(second=0, microsecond=0, minute=0, hour=time.hour) + + timedelta(minutes=rounded_minutes * (time.minute // rounded_minutes))) + + def _split_track_by_days(self, track, local_tz): + """ + Based on the track start_date and the duration, + split the track duration into : + start_time by day : number of time slot (15 minutes) that the track takes on that day. + E.g. : start date = 01-01-2000 10:00 PM and duration = 3 hours + return { + 01-01-2000 10:00:00 PM: 8 (2 * 4), + 01-02-2000 00:00:00 AM: 4 (1 * 4) + } + Also return a set of all the time slots + """ + start_date = fields.Datetime.from_string(track.date).replace(tzinfo=pytz.utc).astimezone(local_tz) + start_datetime = self.time_slot_rounder(start_date, 15) + end_datetime = self.time_slot_rounder(start_datetime + timedelta(hours=(track.duration or 0.25)), 15) + time_slots_count = int(((end_datetime - start_datetime).total_seconds() / 3600) * 4) + + time_slots_by_day_start_time = {start_datetime: 0} + for i in range(0, time_slots_count): + # If the new time slot is still on the current day + next_day = (start_datetime + timedelta(days=1)).date() + if (start_datetime + timedelta(minutes=15*i)).date() <= next_day: + time_slots_by_day_start_time[start_datetime] += 1 + else: + start_datetime = next_day.datetime() + time_slots_by_day_start_time[start_datetime] = 0 + + return time_slots_by_day_start_time + + def _get_occupied_cells(self, track, rowspan, locations, local_tz): + """ + In order to use only once the cells that the tracks will occupy, we need to reserve those cells + (time_slot, location) coordinate. Those coordinated will be given to the template to avoid adding + blank cells where already occupied by a track. + """ + occupied_cells = [] + + start_date = fields.Datetime.from_string(track.date).replace(tzinfo=pytz.utc).astimezone(local_tz) + start_date = self.time_slot_rounder(start_date, 15) + for i in range(0, rowspan): + time_slot = start_date + timedelta(minutes=15*i) + if track.location_id: + occupied_cells.append((time_slot, track.location_id)) + # when no location, reserve all locations + else: + occupied_cells += [(time_slot, location) for location in locations if location] + + return occupied_cells + + def _prepare_calendar_values(self, event): + """ + Override that should completely replace original method in v14. + + This methods slit the day (max end time - min start time) into 15 minutes time slots. + For each time slot, we assign the tracks that start at this specific time slot, and we add the number + of time slot that the track covers (track duration / 15 min) + The calendar will be divided into rows of 15 min, and the talks will cover the corresponding number of rows + (15 min slots). + """ + event = event.with_context(tz=event.date_tz or 'UTC') + local_tz = pytz.timezone(event.date_tz or 'UTC') + lang_code = request.env.context.get('lang') + event_track_ids = self._event_agenda_get_tracks(event) + + locations = list(set(track.location_id for track in event_track_ids)) + locations.sort(key=lambda x: x.id) + + # First split day by day (based on start time) + time_slots_by_tracks = {track: self._split_track_by_days(track, local_tz) for track in event_track_ids} + + # extract all the tracks time slots + track_time_slots = set().union(*(time_slot.keys() for time_slot in [time_slots for time_slots in time_slots_by_tracks.values()])) + + # extract unique days + days = list(set(time_slot.date() for time_slot in track_time_slots)) + days.sort() + + # Create the dict that contains the tracks at the correct time_slots / locations coordinates + tracks_by_days = dict.fromkeys(days, 0) + time_slots_by_day = dict((day, dict(start=set(), end=set())) for day in days) + tracks_by_rounded_times = dict((time_slot, dict((location, {}) for location in locations)) for time_slot in track_time_slots) + for track, time_slots in time_slots_by_tracks.items(): + start_date = fields.Datetime.from_string(track.date).replace(tzinfo=pytz.utc).astimezone(local_tz) + end_date = start_date + timedelta(hours=(track.duration or 0.25)) + + for time_slot, duration in time_slots.items(): + tracks_by_rounded_times[time_slot][track.location_id][track] = { + 'rowspan': duration, # rowspan + 'start_date': self._get_locale_time(start_date, lang_code), + 'end_date': self._get_locale_time(end_date, lang_code), + 'occupied_cells': self._get_occupied_cells(track, duration, locations, local_tz) + } + + # get all the time slots by day to determine the max duration of a day. + day = time_slot.date() + time_slots_by_day[day]['start'].add(time_slot) + time_slots_by_day[day]['end'].add(time_slot+timedelta(minutes=15*duration)) + tracks_by_days[day] += 1 + + # split days into 15 minutes time slots + global_time_slots_by_day = dict((day, {}) for day in days) + for day, time_slots in time_slots_by_day.items(): + start_time_slot = min(time_slots['start']) + end_time_slot = max(time_slots['end']) + + time_slots_count = int(((end_time_slot - start_time_slot).total_seconds() / 3600) * 4) + current_time_slot = start_time_slot + for i in range(0, time_slots_count + 1): + global_time_slots_by_day[day][current_time_slot] = tracks_by_rounded_times.get(current_time_slot, {}) + global_time_slots_by_day[day][current_time_slot]['formatted_time'] = self._get_locale_time(current_time_slot, lang_code) + current_time_slot = current_time_slot + timedelta(minutes=15) + + # count the number of tracks by days + tracks_by_days = dict.fromkeys(days, 0) + for track in event_track_ids: + track_day = fields.Datetime.from_string(track.date).replace(tzinfo=pytz.utc).astimezone(local_tz).date() + tracks_by_days[track_day] += 1 + + return { + 'days': days, + 'tracks_by_days': tracks_by_days, + 'time_slots': global_time_slots_by_day, + 'locations': locations + } + + @http.route(['''/event//agenda'''], type='http', auth="public", website=True, sitemap=False) + def event_agenda(self, event, tag=None, **post): + if not event.can_access_from_current_website(): + raise NotFound() + + event = event.with_context(tz=event.date_tz or 'UTC') + vals = { + 'event': event, + 'main_object': event, + 'tag': tag, + 'user_event_manager': request.env.user.has_group('event.group_event_manager'), + } + + vals.update(self._prepare_calendar_values(event)) + + return request.render("website_event_track_online.agenda_online", vals) + def _fetch_track(self, track_id, allow_is_accepted=False): track = request.env['event.track'].browse(track_id).exists() if not track: diff --git a/addons/website_event_track_online/static/src/scss/event_track_templates.scss b/addons/website_event_track_online/static/src/scss/event_track_templates.scss index d9fcb3786d8..1772d02ea91 100644 --- a/addons/website_event_track_online/static/src/scss/event_track_templates.scss +++ b/addons/website_event_track_online/static/src/scss/event_track_templates.scss @@ -43,6 +43,103 @@ } } +/* + * AGENDA + */ +.o_we_online_agenda { + overflow-x: hidden; + @media (max-width: map-get($grid-breakpoints, 'md')) { + overflow-x: scroll; + } + + table { + border-collapse: separate; + border-spacing: 0em 0em; + tr { + height: 15px; + line-height: 1em; + &.active { + td.active { + padding: 0em 0.5em; + font-size: smaller; + border-top: 1px solid lightgrey; + } + } + } + th.active, td:not(.active) { + background-color: #d3d3d36e; + } + th.position-sticky { + left: 0; + } + td { + height: 0px; + border: 0px; + &.active { + position: sticky; + left: 0; + min-width: 100px; + background-color: white; + } + div.o_we_agenda_card_content { + height: 100%; + span { + cursor: pointer; + } + } + .badge { + height: fit-content; + } + &.invisible { + visibility: visible !important; + opacity: 0.3; + } + &.o_we_agenda_time_slot_main { + border-top: 1px solid lightgrey; + } + &.o_we_agenda_time_slot_half { + border-top: 1px dashed lightgrey; + } + &.event_color_0 { + background-color: lightgrey; + } + &.event_color_1 { + background-color: rgba(240, 96, 80, 0.2); + } + &.event_color_2 { + background-color: rgba(244, 164, 96, 0.2); + } + &.event_color_3 { + background-color: rgba(247, 205, 31, 0.2); + } + &.event_color_4 { + background-color: rgba(108,193,237,0.2); + } + &.event_color_5 { + background-color: rgba(129,73,104,0.2); + } + &.event_color_6 { + background-color: rgba(235,126,127,0.2); + } + &.event_color_7 { + background-color: rgba(44,131,151,0.2); + } + &.event_color_8 { + background-color: rgba(71,85,119,0.2);; + } + &.event_color_9 { + background-color: rgba(214,20,95,0.2); + } + &.event_color_10 { + background-color: rgba(48,195,129,0.2); + } + &.event_color_11 { + background-color: rgba(147,101,184,0.2); + } + } + } +} + /* * EVENT TOOL: SPONSOR WIDGET */ diff --git a/addons/website_event_track_online/views/event_track_templates_agenda.xml b/addons/website_event_track_online/views/event_track_templates_agenda.xml new file mode 100644 index 00000000000..b6159f55f23 --- /dev/null +++ b/addons/website_event_track_online/views/event_track_templates_agenda.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +