diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website_event_track/controllers | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_event_track/controllers')
| -rw-r--r-- | addons/website_event_track/controllers/__init__.py | 6 | ||||
| -rw-r--r-- | addons/website_event_track/controllers/event.py | 12 | ||||
| -rw-r--r-- | addons/website_event_track/controllers/event_track.py | 478 | ||||
| -rw-r--r-- | addons/website_event_track/controllers/webmanifest.py | 68 |
4 files changed, 564 insertions, 0 deletions
diff --git a/addons/website_event_track/controllers/__init__.py b/addons/website_event_track/controllers/__init__.py new file mode 100644 index 00000000..41b126da --- /dev/null +++ b/addons/website_event_track/controllers/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import event +from . import event_track +from . import webmanifest diff --git a/addons/website_event_track/controllers/event.py b/addons/website_event_track/controllers/event.py new file mode 100644 index 00000000..895f9fd3 --- /dev/null +++ b/addons/website_event_track/controllers/event.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.website_event.controllers.main import WebsiteEventController + + +class EventOnlineController(WebsiteEventController): + + def _get_registration_confirm_values(self, event, attendees_sudo): + values = super(EventOnlineController, self)._get_registration_confirm_values(event, attendees_sudo) + values['hide_sponsors'] = True + return values diff --git a/addons/website_event_track/controllers/event_track.py b/addons/website_event_track/controllers/event_track.py new file mode 100644 index 00000000..290521a7 --- /dev/null +++ b/addons/website_event_track/controllers/event_track.py @@ -0,0 +1,478 @@ +# -*- 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 +import babel.dates +import base64 +import pytz + +from odoo import exceptions, http, fields, _ +from odoo.http import request +from odoo.osv import expression +from odoo.tools import is_html_empty, plaintext2html +from odoo.tools.misc import babel_locale_parse + + +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 + # ------------------------------------------------------------ + + @http.route([ + '''/event/<model("event.event"):event>/track''', + '''/event/<model("event.event"):event>/track/tag/<model("event.track.tag"):tag>''' + ], type='http', auth="public", website=True, sitemap=False) + 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() + + return request.render( + "website_event_track.tracks_session", + self._event_tracks_get_values(event, tag=tag, **searches) + ) + + 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 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, + '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'), + } + + # ------------------------------------------------------------ + # AGENDA VIEW + # ------------------------------------------------------------ + + @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.agenda_online", vals) + + 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 + } + + def _event_agenda_get_tracks(self, event): + tracks_sudo = event.sudo().track_ids.filtered(lambda track: track.date) + if not request.env.user.has_group('event.group_event_manager'): + tracks_sudo = tracks_sudo.filtered(lambda track: track.is_published or track.stage_id.is_accepted) + return tracks_sudo + + def _get_locale_time(self, dt_time, lang_code): + """ Get locale time from datetime object + + :param dt_time: datetime object + :param lang_code: language code (eg. en_US) + """ + locale = babel_locale_parse(lang_code) + return babel.dates.format_time(dt_time, format='short', locale=locale) + + 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 + + # ------------------------------------------------------------ + # TRACK PAGE VIEW + # ------------------------------------------------------------ + + @http.route('''/event/<model("event.event", "[('website_track', '=', True)]"):event>/track/<model("event.track", "[('event_id', '=', event.id)]"):track>''', + type='http', auth="public", website=True, sitemap=True) + def event_track_page(self, event, track, **options): + track = self._fetch_track(track.id, allow_is_accepted=False) + + return request.render( + "website_event_track.event_track_main", + self._event_track_page_get_values(event, track.sudo(), **options) + ) + + def _event_track_page_get_values(self, event, track, **options): + track = track.sudo() + + option_widescreen = options.get('widescreen', False) + option_widescreen = bool(option_widescreen) if option_widescreen != '0' else False + # search for tracks list + tracks_other = track._get_track_suggestions( + restrict_domain=self._get_event_tracks_base_domain(track.event_id), + limit=10 + ) + + return { + # event information + 'event': event, + 'main_object': track, + 'track': track, + # sidebar + 'tracks_other': tracks_other, + # options + 'option_widescreen': option_widescreen, + # 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'), + } + + @http.route("/event/track/toggle_reminder", type="json", auth="public", website=True) + def track_reminder_toggle(self, track_id, set_reminder_on): + """ Set a reminder a track for current visitor. Track visitor is created or updated + if it already exists. Exception made if un-wishlisting and no track_visitor + record found (should not happen unless manually done). + + :param boolean set_reminder_on: + If True, set as a wishlist, otherwise un-wishlist track; + If the track is a Key Track (wishlisted_by_default): + if set_reminder_on = False, blacklist the track_partner + otherwise, un-blacklist the track_partner + """ + track = self._fetch_track(track_id, allow_is_accepted=True) + force_create = set_reminder_on or track.wishlisted_by_default + event_track_partner = track._get_event_track_visitors(force_create=force_create) + visitor_sudo = event_track_partner.visitor_id + + if not track.wishlisted_by_default: + if not event_track_partner or event_track_partner.is_wishlisted == set_reminder_on: # ignore if new state = old state + return {'error': 'ignored'} + event_track_partner.is_wishlisted = set_reminder_on + else: + if not event_track_partner or event_track_partner.is_blacklisted != set_reminder_on: # ignore if new state = old state + return {'error': 'ignored'} + event_track_partner.is_blacklisted = not set_reminder_on + + result = {'reminderOn': set_reminder_on} + if request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token: + result['visitor_uuid'] = visitor_sudo.access_token + + return result + + # ------------------------------------------------------------ + # TRACK PROPOSAL + # ------------------------------------------------------------ + + @http.route(['''/event/<model("event.event"):event>/track_proposal'''], type='http', auth="public", website=True, sitemap=False) + def event_track_proposal(self, event, **post): + if not event.can_access_from_current_website(): + raise NotFound() + + return request.render("website_event_track.event_track_proposal", {'event': event, 'main_object': event}) + + @http.route(['''/event/<model("event.event"):event>/track_proposal/post'''], type='http', auth="public", methods=['POST'], website=True) + def event_track_proposal_post(self, event, **post): + if not event.can_access_from_current_website(): + raise NotFound() + + tags = [] + for tag in event.allowed_track_tag_ids: + if post.get('tag_' + str(tag.id)): + tags.append(tag.id) + + track = request.env['event.track'].sudo().create({ + 'name': post['track_name'], + 'partner_name': post['partner_name'], + 'partner_email': post['email_from'], + 'partner_phone': post['phone'], + 'partner_biography': plaintext2html(post['biography']), + 'event_id': event.id, + 'tag_ids': [(6, 0, tags)], + 'user_id': False, + 'description': plaintext2html(post['description']), + 'image': base64.b64encode(post['image'].read()) if post.get('image') else False + }) + if request.env.user != request.website.user_id: + track.sudo().message_subscribe(partner_ids=request.env.user.partner_id.ids) + else: + partner = request.env['res.partner'].sudo().search([('email', '=', post['email_from'])]) + if partner: + track.sudo().message_subscribe(partner_ids=partner.ids) + return request.render("website_event_track.event_track_proposal", {'track': track, 'event': event}) + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + def _fetch_track(self, track_id, allow_is_accepted=False): + track = request.env['event.track'].browse(track_id).exists() + if not track: + raise NotFound() + try: + track.check_access_rights('read') + track.check_access_rule('read') + except exceptions.AccessError: + track_sudo = track.sudo() + if allow_is_accepted and track_sudo.is_accepted: + track = track_sudo + else: + raise Forbidden() + + event = track.event_id + # JSON RPC have no website in requests + if hasattr(request, 'website_id') and not event.can_access_from_current_website(): + raise NotFound() + try: + event.check_access_rights('read') + event.check_access_rule('read') + except exceptions.AccessError: + raise Forbidden() + + return track + + def _get_search_tags(self, tag_search): + # TDE FIXME: make me generic (slides, event, ...) + try: + tag_ids = literal_eval(tag_search) + except Exception: + tags = request.env['event.track.tag'].sudo() + else: + # perform a search to filter on existing / valid tags implicitly + tags = request.env['event.track.tag'].sudo().search([('id', 'in', tag_ids)]) + return tags + + def _get_dt_in_event_tz(self, datetimes, event): + tz_name = event.date_tz + return [ + utc.localize(dt, is_dst=False).astimezone(timezone(tz_name)) + for dt in datetimes + ] diff --git a/addons/website_event_track/controllers/webmanifest.py b/addons/website_event_track/controllers/webmanifest.py new file mode 100644 index 00000000..627c2581 --- /dev/null +++ b/addons/website_event_track/controllers/webmanifest.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import pytz + +from odoo import http +from odoo.addons.http_routing.models.ir_http import url_for +from odoo.http import request +from odoo.modules.module import get_module_resource +from odoo.tools import ustr +from odoo.tools.translate import _ + + +class TrackManifest(http.Controller): + + @http.route('/event/manifest.webmanifest', type='http', auth='public', methods=['GET'], website=True, sitemap=False) + def webmanifest(self): + """ Returns a WebManifest describing the metadata associated with a web application. + Using this metadata, user agents can provide developers with means to create user + experiences that are more comparable to that of a native application. + """ + website = request.website + manifest = { + 'name': website.events_app_name, + 'short_name': website.events_app_name, + 'description': _('%s Online Events Application') % website.company_id.name, + 'scope': url_for('/event'), + 'start_url': url_for('/event'), + 'display': 'standalone', + 'background_color': '#ffffff', + 'theme_color': '#875A7B', + } + icon_sizes = ['192x192', '512x512'] + manifest['icons'] = [{ + 'src': website.image_url(website, 'app_icon', size=size), + 'sizes': size, + 'type': 'image/png', + } for size in icon_sizes] + body = json.dumps(manifest, default=ustr) + response = request.make_response(body, [ + ('Content-Type', 'application/manifest+json'), + ]) + return response + + @http.route('/event/service-worker.js', type='http', auth='public', methods=['GET'], website=True, sitemap=False) + def service_worker(self): + """ Returns a ServiceWorker javascript file scoped for website_event + """ + sw_file = get_module_resource('website_event_track', 'static/src/js/service_worker.js') + with open(sw_file, 'r') as fp: + body = fp.read() + js_cdn_url = 'undefined' + if request.website.cdn_activated: + cdn_url = request.website.cdn_url.replace('"','%22').replace('\x5c','%5C') + js_cdn_url = '"%s"' % cdn_url + body = body.replace('__ODOO_CDN_URL__', js_cdn_url) + response = request.make_response(body, [ + ('Content-Type', 'text/javascript'), + ('Service-Worker-Allowed', url_for('/event')), + ]) + return response + + @http.route('/event/offline', type='http', auth='public', methods=['GET'], website=True, sitemap=False) + def offline(self): + """ Returns the offline page used by the 'website_event' PWA + """ + return request.render('website_event_track.pwa_offline') |
