Skip to content
Snippets Groups Projects
Commit 57da2366 authored by David Beguin's avatar David Beguin Committed by Thibault Delavallée
Browse files

[IMP] website_event_track_online: improve agenda page


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: default avatarDavid Beguin <dbe@odoo.com>
Co-Authored-By: default avatarElisabeth Dickison <edi@odoo.com>
Co-Authored-By: default avatarThibault Delavallée <tde@odoo.com>
parent 19c6f11b
Branches
Tags
No related merge requests found
......@@ -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',
......
......@@ -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/<model("event.event"):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:
......
......@@ -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
*/
......
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Revamped agenda : Will need to replace agenda_online from website_event_track in master -->
<template id="agenda_online" name="Track Online: Agenda">
<t t-call="website_event.layout">
<div class="o_wevent_online o_weagenda_index">
<!-- Options -->
<t t-set="option_track_wishlist" t-value="not event.is_done and request.website.viewref('website_event_track_online.agenda_topbar_wishlist').active"/>
<!-- Topbar -->
<t t-call="website_event_track_online.agenda_topbar"/>
<!-- Drag/Drop Area -->
<div class="oe_structure" id="oe_structure_website_event_track_agenda_1"/>
<!-- Content -->
<div class="container">
<div class="row mb-5">
<t t-call="website_event_track_online.agenda_main"/>
</div>
</div>
<!-- Drag/Drop Area -->
<div class="oe_structure" id="oe_structure_website_event_track_agenda_2"/>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- TOPBAR: BASE NAVIGATION -->
<!-- ============================================================ -->
<!-- Main topbar -->
<template id="agenda_topbar" name="Agenda Tools">
<nav class="navbar navbar-light border-top shadow-sm d-print-none">
<div class="container">
<div class="d-flex flex-column flex-sm-row justify-content-between w-100">
<ul class="o_weagenda_topbar_filters o_wevent_index_topbar_filters nav">
</ul>
<div class="form-inline pl-sm-3 pr-0">
<label class="invisible text-muted mr-2" id="search_summary"><span id="search_number" class="mr-1">0</span>Results</label>
<input type="text" class="form-control" placeholder="Filter Tracks..." id="event_track_search"/>
</div>
</div>
</div>
</nav>
</template>
<!-- Option: Tracks display: optional wishlist -->
<template id="agenda_topbar_wishlist"
inherit_id="website_event_track_online.agenda_topbar"
name="Allow Wishlists"
active="True"
customize_show="True">
<xpath expr="//ul[hasclass('o_weagenda_topbar_filters')]" position="inside">
</xpath>
</template>
<!-- ============================================================ -->
<!-- CONTENT: MAIN TEMPLATES -->
<!-- ============================================================ -->
<!-- Agenda Main Display -->
<template id="agenda_main" name="Tracks: Main Display">
<!-- No tracks -->
<div class="col-12" t-if="not tracks_by_days">
<div class="h2 mb-3">No track found.</div>
<div t-if="search_key" class="alert alert-info text-center">
<p class="m-0">We did not find any track matching your <strong t-esc="search_key"/> search.</p>
</div>
<div t-else="" class="alert alert-info text-center" groups="event.group_event_manager">
<p class="m-0">Schedule some tracks to get started !</p>
</div>
</div>
<section t-else="" class="col-12" t-foreach="days" t-as="day">
<!-- Day Title -->
<div class="o_page_hader d-flex justify-content-between align-items-center mt-3 mb-0 border-bottom border-dark">
<h3 class="d-flex">
<span class="mr-2" t-esc="day.strftime('%A')"/> <span t-esc="day.day"/>
<div class="ml-2 d-flex flex-column" style="line-height: 0.8; font-size: 0.5em;">
<span class="pt-1 pb-1" t-esc="day.strftime('%B').upper()"/>
<span t-esc="day.year"/>
</div>
</h3>
<small class="float-right text-muted"><t t-esc="tracks_by_days[day]"/> tracks</small>
</div>
<!-- Day Agenda -->
<div class="o_we_online_agenda">
<table id="table_search" class="table table-sm border-0">
<!--Header-->
<tr>
<th class="border-0 bg-white position-sticky"/>
<t t-foreach="locations" t-as="location">
<th t-if="location" class="active text-center border-0">
<span t-esc="location and location.name or 'Unknown'"/>
</th>
</t>
</tr>
<!-- Time Slots -->
<t t-set="used_cells" t-value="[]"/>
<t t-foreach="time_slots[day]" t-as="time_slot">
<t t-set="is_round_hour" t-value="time_slot == time_slot.replace(minute=0)"/>
<t t-set="is_half_hour" t-value="time_slot == time_slot.replace(minute=30)"/>
<tr t-att-class="'%s' % ('active' if is_round_hour else '')">
<td class="active">
<b t-if="is_round_hour" t-esc="time_slots[day][time_slot]['formatted_time']"/>
</td>
<t t-foreach="locations" t-as="location">
<t t-set="tracks" t-value="time_slots[day][time_slot].get(location, {})"/>
<t t-if="tracks">
<t t-foreach="tracks" t-as="track">
<t t-if="track.location_id and track.location_id == location">
<td t-att-rowspan="tracks[track]['rowspan']"
t-attf-class="text-center event_color_#{track.color} #{track and 'event_track' or ''}">
<t t-call="website_event_track_online.agenda_main_track"/>
</td>
</t>
<t t-else="">
<td t-att-colspan="len(locations)-1"
t-att-rowspan="tracks[track]['rowspan']"
t-attf-class="text-center event_color_#{track.color} #{track and 'event_track' or ''}">
<t t-call="website_event_track_online.agenda_main_track"/>
</td>
</t>
<t t-set="used_cells" t-value="used_cells + tracks[track]['occupied_cells']"/>
</t>
</t>
<t t-elif="location and (time_slot, location) not in used_cells">
<td t-att-rowspan="1"
t-att-class="'%s' % (
'o_we_agenda_time_slot_half' if is_half_hour else
'o_we_agenda_time_slot_main' if is_round_hour else
''
)"/>
</t>
</t>
</tr>
</t>
</table>
</div>
</section>
</template>
<template id="agenda_main_track" name="Track Agenda: Track">
<div class="d-flex flex-column h-100">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted">
<small t-if="track.partner_id"><i class="fa fa-user mr-2"/><t t-esc="track.partner_id.sudo().name"/></small>
<small t-if="not track.partner_id"><i class="fa fa-user mr-2"/><t t-esc="track.partner_name"/></small>
</div>
<div class="d-flex o_weagenda_track_badges">
<small t-if="not track.website_published and user_event_manager and track.stage_id.is_accepted"
class="ml-1 badge badge-danger o_wevent_online_badge_unpublished">Unpublished</small>
<small t-if="not track.stage_id.is_accepted"
class="ml-1 badge badge-danger o_wevent_online_badge_unpublished">Not Accepted</small>
<span t-if="option_track_wishlist">
<t t-call="website_event_track_online.track_widget_reminder">
<t t-set="reminder_light" t-value="True"/>
<t t-set="reminder_small" t-value="True"/>
</t>
</span>
</div>
</div>
<div class="o_we_agenda_card_content d-flex flex-column justify-content-center my-1">
<div t-att-class="'text-black' if track.website_published or user_event_manager else 'text-muted'"
t-att-onclick="'window.location=\'/event/%s/track/%s\'' % (slug(event), slug(track))
if track.website_published or user_event_manager else ''">
<span class="text-bold" t-esc="track.name"/>
</div>
<div class="d-flex justify-content-center flex-wrap">
<span t-foreach="track.tag_ids" t-as="tag"
t-attf-class="mr-1 mt-1 badge #{'o_tag_color_'+str(tag.color)}" t-esc="tag.name"
t-attf-onclick="
var value = '#{tag.name}' ;
var target = $('#event_track_search');
if (target.val() == value) { target.val(''); } else { target.val(value); }
target.trigger('input');
"
/>
</div>
</div>
</div>
</template>
</odoo>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment