[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: David Beguin <dbe@odoo.com> Co-Authored-By: Elisabeth Dickison <edi@odoo.com> Co-Authored-By: Thibault Delavallée <tde@odoo.com>
This commit is contained in:
committed by
Thibault Delavallée
co-authored by
Elisabeth Dickison
Thibault Delavallée
parent
19c6f11bf2
commit
57da236657
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<?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>
|
||||
Reference in New Issue
Block a user