Rearrange agenda customization UI and add customization UI to upcoming meetings
- Legacy-Id: 18513
This commit is contained in:
parent
4c709bbfa5
commit
7bee3020fd
|
@ -4,20 +4,28 @@
|
|||
|
||||
import time
|
||||
import datetime
|
||||
import shutil
|
||||
import os
|
||||
from pyquery import PyQuery
|
||||
from unittest import skipIf
|
||||
|
||||
import django
|
||||
from django.urls import reverse as urlreverse
|
||||
from django.utils.text import slugify
|
||||
from django.db.models import F
|
||||
#from django.test.utils import override_settings
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.doc.factories import DocumentFactory
|
||||
from ietf.group import colors
|
||||
from ietf.group.models import Group
|
||||
from ietf.meeting.factories import SessionFactory
|
||||
from ietf.meeting.test_data import make_meeting_test_data
|
||||
from ietf.meeting.models import Schedule, SchedTimeSessAssignment, Session, Room, TimeSlot, Constraint, ConstraintName
|
||||
from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session,
|
||||
Room, TimeSlot, Constraint, ConstraintName,
|
||||
Meeting)
|
||||
from ietf.meeting.utils import add_event_info_to_session_qs
|
||||
from ietf.utils.test_runner import IetfLiveServerTestCase
|
||||
from ietf.utils.pipe import pipe
|
||||
from ietf import settings
|
||||
|
@ -314,25 +322,33 @@ class AgendaTests(MeetingTestCase):
|
|||
def test_agenda_view_js_func_parse_query_params(self):
|
||||
"""Test parse_query_params() function"""
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda'))
|
||||
|
||||
|
||||
parse_query_params = 'return agenda_filter_for_testing.parse_query_params'
|
||||
|
||||
# Only 'show' param
|
||||
result = self.driver.execute_script(
|
||||
'return parse_query_params("?show=group1,group2,group3");'
|
||||
parse_query_params + '("?show=group1,group2,group3");'
|
||||
)
|
||||
self.assertEqual(result, dict(show='group1,group2,group3'))
|
||||
|
||||
# Only 'hide' param
|
||||
result = self.driver.execute_script(
|
||||
'return parse_query_params("?hide=group4,group5,group6");'
|
||||
parse_query_params + '("?hide=group4,group5,group6");'
|
||||
)
|
||||
self.assertEqual(result, dict(hide='group4,group5,group6'))
|
||||
|
||||
|
||||
# Both 'show' and 'hide'
|
||||
result = self.driver.execute_script(
|
||||
'return parse_query_params("?show=group1,group2,group3&hide=group4,group5,group6");'
|
||||
parse_query_params + '("?show=group1,group2,group3&hide=group4,group5,group6");'
|
||||
)
|
||||
self.assertEqual(result, dict(show='group1,group2,group3', hide='group4,group5,group6'))
|
||||
|
||||
# Encoded
|
||||
result = self.driver.execute_script(
|
||||
parse_query_params + '("?show=%20group1,%20group2,%20group3&hide=group4,group5,group6");'
|
||||
)
|
||||
self.assertEqual(result, dict(show=' group1, group2, group3', hide='group4,group5,group6'))
|
||||
|
||||
def test_agenda_view_js_func_toggle_list_item(self):
|
||||
"""Test toggle_list_item() function"""
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda'))
|
||||
|
@ -341,30 +357,30 @@ class AgendaTests(MeetingTestCase):
|
|||
"""
|
||||
// start empty, add item
|
||||
var list0=[];
|
||||
toggle_list_item(list0, 'item');
|
||||
%(toggle_list_item)s(list0, 'item');
|
||||
|
||||
// one item, remove it
|
||||
var list1=['item'];
|
||||
toggle_list_item(list1, 'item');
|
||||
%(toggle_list_item)s(list1, 'item');
|
||||
|
||||
// one item, add another
|
||||
var list2=['item1'];
|
||||
toggle_list_item(list2, 'item2');
|
||||
%(toggle_list_item)s(list2, 'item2');
|
||||
|
||||
// multiple items, remove first
|
||||
var list3=['item1', 'item2', 'item3'];
|
||||
toggle_list_item(list3, 'item1');
|
||||
%(toggle_list_item)s(list3, 'item1');
|
||||
|
||||
// multiple items, remove middle
|
||||
var list4=['item1', 'item2', 'item3'];
|
||||
toggle_list_item(list4, 'item2');
|
||||
%(toggle_list_item)s(list4, 'item2');
|
||||
|
||||
// multiple items, remove last
|
||||
var list5=['item1', 'item2', 'item3'];
|
||||
toggle_list_item(list5, 'item3');
|
||||
%(toggle_list_item)s(list5, 'item3');
|
||||
|
||||
return [list0, list1, list2, list3, list4, list5];
|
||||
"""
|
||||
""" % {'toggle_list_item': 'agenda_filter_for_testing.toggle_list_item'}
|
||||
)
|
||||
self.assertEqual(result[0], ['item'], 'Adding item to empty list failed')
|
||||
self.assertEqual(result[1], [], 'Removing only item in a list failed')
|
||||
|
@ -385,11 +401,20 @@ class AgendaTests(MeetingTestCase):
|
|||
self.driver.switch_to.frame(weekview_iframe)
|
||||
self.assert_weekview_item_visibility(visible_groups)
|
||||
self.driver.switch_to.default_content()
|
||||
|
||||
|
||||
def test_agenda_view_filter_show_none(self):
|
||||
"""Filtered agenda view should display only matching rows (no group selected)"""
|
||||
self.do_agenda_view_filter_test('?show=', [])
|
||||
|
||||
def test_agenda_view_filter_show_one(self):
|
||||
"""Filtered agenda view should display only matching rows (one group selected)"""
|
||||
self.do_agenda_view_filter_test('?show=mars', ['mars'])
|
||||
|
||||
def test_agenda_view_filter_show_area(self):
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
self.do_agenda_view_filter_test('?show=%s' % area.acronym, ['ames', 'mars'])
|
||||
|
||||
def test_agenda_view_filter_show_two(self):
|
||||
"""Filtered agenda view should display only matching rows (two groups selected)"""
|
||||
self.do_agenda_view_filter_test('?show=mars,ames', ['mars', 'ames'])
|
||||
|
@ -401,6 +426,11 @@ class AgendaTests(MeetingTestCase):
|
|||
def test_agenda_view_filter_hide(self):
|
||||
self.do_agenda_view_filter_test('?hide=ietf', [])
|
||||
|
||||
def test_agenda_view_filter_hide_area(self):
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
self.do_agenda_view_filter_test('?show=mars&hide=%s' % area.acronym, [])
|
||||
|
||||
def test_agenda_view_filter_show_and_hide(self):
|
||||
self.do_agenda_view_filter_test('?show=mars&hide=ietf', ['mars'])
|
||||
|
||||
|
@ -551,7 +581,186 @@ class AgendaTests(MeetingTestCase):
|
|||
WebDriverWait(self.driver, 2).until(expected_conditions.url_to_be(expected_url))
|
||||
# no assertion here - if WebDriverWait raises an exception, the test will fail.
|
||||
# We separately test whether this URL will filter correctly.
|
||||
|
||||
|
||||
|
||||
@skipIf(skip_selenium, skip_message)
|
||||
class InterimTests(MeetingTestCase):
|
||||
def setUp(self):
|
||||
super(InterimTests, self).setUp()
|
||||
self.materials_dir = self.tempdir('materials')
|
||||
self.saved_agenda_path = settings.AGENDA_PATH
|
||||
settings.AGENDA_PATH = self.materials_dir
|
||||
self.meeting = make_meeting_test_data(create_interims=True)
|
||||
|
||||
def tearDown(self):
|
||||
settings.AGENDA_PATH = self.saved_agenda_path
|
||||
shutil.rmtree(self.materials_dir)
|
||||
super(InterimTests, self).tearDown()
|
||||
|
||||
def tempdir(self, label):
|
||||
# Borrowed from test_utils.TestCase
|
||||
slug = slugify(self.__class__.__name__.replace('.','-'))
|
||||
dirname = "tmp-{label}-{slug}-dir".format(**locals())
|
||||
if 'VIRTUAL_ENV' in os.environ:
|
||||
dirname = os.path.join(os.environ['VIRTUAL_ENV'], dirname)
|
||||
path = os.path.abspath(dirname)
|
||||
if not os.path.exists(path):
|
||||
os.mkdir(path)
|
||||
return path
|
||||
|
||||
def displayed_interims(self, groups=None):
|
||||
sessions = add_event_info_to_session_qs(
|
||||
Session.objects.filter(
|
||||
meeting__type_id='interim',
|
||||
timeslotassignments__schedule=F('meeting__schedule'),
|
||||
timeslotassignments__timeslot__time__gte=datetime.datetime.today()
|
||||
)
|
||||
).filter(current_status__in=('sched','canceled'))
|
||||
meetings = []
|
||||
for s in sessions:
|
||||
if groups is None or s.group.acronym in groups:
|
||||
s.meeting.calendar_label = s.group.acronym # annotate with group
|
||||
meetings.append(s.meeting)
|
||||
return meetings
|
||||
|
||||
def all_ietf_meetings(self):
|
||||
meetings = Meeting.objects.filter(
|
||||
type_id='ietf',
|
||||
date__gte=datetime.datetime.today()-datetime.timedelta(days=7)
|
||||
)
|
||||
for m in meetings:
|
||||
m.calendar_label = 'IETF %s' % m.number
|
||||
return meetings
|
||||
|
||||
def assert_upcoming_meeting_visibility(self, visible_meetings=None):
|
||||
"""Assert that correct items are visible in current browser window
|
||||
|
||||
If visible_meetings is None (the default), expects all items to be visible.
|
||||
"""
|
||||
expected = {mtg.number for mtg in visible_meetings}
|
||||
not_visible = set()
|
||||
unexpected = set()
|
||||
entries = self.driver.find_elements_by_css_selector(
|
||||
'table#upcoming-meeting-table > tbody > tr.entry'
|
||||
)
|
||||
for entry in entries:
|
||||
nums = [n for n in expected if n in entry.text]
|
||||
self.assertLessEqual(len(nums), 1, 'Multiple matching meeting numbers')
|
||||
if len(nums) > 0: # asserted that it's at most 1, so if it's not 0, it's 1.
|
||||
expected.remove(nums[0])
|
||||
if not entry.is_displayed():
|
||||
not_visible.add(nums[0])
|
||||
continue
|
||||
# Found an unexpected row - this is ok as long as it's hidden
|
||||
if entry.is_displayed():
|
||||
unexpected.add(entry.text)
|
||||
|
||||
self.assertEqual(expected, set(), "Missing entries for expected iterim meetings.")
|
||||
self.assertEqual(not_visible, set(), "Hidden rows for expected interim meetings.")
|
||||
self.assertEqual(unexpected, set(), "Unexpected row visible")
|
||||
|
||||
def assert_upcoming_meeting_calendar(self, visible_meetings=None):
|
||||
"""Assert that correct items are sent to the calendar"""
|
||||
def advance_month():
|
||||
button = WebDriverWait(self.driver, 2).until(
|
||||
expected_conditions.element_to_be_clickable(
|
||||
(By.CSS_SELECTOR, 'div#calendar button.fc-next-button')))
|
||||
button.click()
|
||||
|
||||
seen = set()
|
||||
not_visible = set()
|
||||
unexpected = set()
|
||||
|
||||
# Test that we see all the expected meetings when we scroll through the
|
||||
# entire year. We only check the group names / IETF numbers. This should
|
||||
# be good enough to catch filtering errors but does not validate the
|
||||
# details of what's shown on the calendar. Need 13 iterations instead of
|
||||
# 12 in order to check the starting month of the following year, which
|
||||
# will usually contain the day 1 year from the start date.
|
||||
for _ in range(13):
|
||||
entries = self.driver.find_elements_by_css_selector(
|
||||
'div#calendar div.fc-content'
|
||||
)
|
||||
for entry in entries:
|
||||
meetings = [m for m in visible_meetings if m.calendar_label in entry.text]
|
||||
if len(meetings) > 0:
|
||||
seen.add(meetings[0])
|
||||
if not entry.is_displayed():
|
||||
not_visible.add(meetings[0])
|
||||
continue
|
||||
# Found an unexpected row - this is ok as long as it's hidden
|
||||
if entry.is_displayed():
|
||||
unexpected.add(entry.text)
|
||||
advance_month()
|
||||
|
||||
self.assertEqual(seen, visible_meetings, "Expected calendar entries not shown.")
|
||||
self.assertEqual(not_visible, set(), "Hidden calendar entries for expected interim meetings.")
|
||||
self.assertEqual(unexpected, set(), "Unexpected calendar entries visible")
|
||||
|
||||
def do_upcoming_view_filter_test(self, querystring, visible_meetings=()):
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.upcoming') + querystring)
|
||||
self.assert_upcoming_meeting_visibility(visible_meetings)
|
||||
self.assert_upcoming_meeting_calendar(visible_meetings)
|
||||
|
||||
# Check the ical links
|
||||
simplified_querystring = querystring.replace(' ', '%20') # encode spaces'
|
||||
ics_link = self.driver.find_element_by_link_text('Download as .ics')
|
||||
self.assertIn(simplified_querystring, ics_link.get_attribute('href'))
|
||||
webcal_link = self.driver.find_element_by_link_text('Subscribe with webcal')
|
||||
self.assertIn(simplified_querystring, webcal_link.get_attribute('href'))
|
||||
|
||||
def test_upcoming_view_displays_all_interims(self):
|
||||
"""By default, all upcoming interims and IETF meetings should be displayed"""
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
meetings.update(self.displayed_interims())
|
||||
self.do_upcoming_view_filter_test('', meetings)
|
||||
|
||||
def test_upcoming_view_filter_show_none(self):
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
self.do_upcoming_view_filter_test('?show=', meetings)
|
||||
|
||||
def test_upcoming_view_filter_show_one(self):
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
meetings.update(self.displayed_interims(groups=['mars']))
|
||||
self.do_upcoming_view_filter_test('?show=mars', meetings)
|
||||
|
||||
def test_upcoming_view_filter_show_area(self):
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
meetings.update(self.displayed_interims(groups=['mars', 'ames']))
|
||||
self.do_upcoming_view_filter_test('?show=%s' % area.acronym, meetings)
|
||||
|
||||
def test_upcoming_view_filter_show_two(self):
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
meetings.update(self.displayed_interims(groups=['mars', 'ames']))
|
||||
self.do_upcoming_view_filter_test('?show=mars,ames', meetings)
|
||||
|
||||
def test_upcoming_view_filter_whitespace(self):
|
||||
"""Whitespace in filter lists should be ignored"""
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
meetings.update(self.displayed_interims(groups=['mars']))
|
||||
self.do_upcoming_view_filter_test('?show=mars , ames &hide= ames', meetings)
|
||||
|
||||
def test_upcoming_view_filter_hide(self):
|
||||
# Not a useful application, but test for completeness...
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
self.do_upcoming_view_filter_test('?hide=mars', meetings)
|
||||
|
||||
def test_upcoming_view_filter_hide_area(self):
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
self.do_upcoming_view_filter_test('?show=mars&hide=%s' % area.acronym, meetings)
|
||||
|
||||
def test_upcoming_view_filter_show_and_hide_same_group(self):
|
||||
meetings = set(self.all_ietf_meetings())
|
||||
meetings.update(self.displayed_interims(groups=['mars']))
|
||||
self.do_upcoming_view_filter_test('?show=mars,ames&hide=ames', meetings)
|
||||
|
||||
# The upcoming meetings view does not handle showtypes / hidetypes
|
||||
|
||||
# The following are useful debugging tools
|
||||
|
||||
# If you add this to a LiveServerTestCase and run just this test, you can browse
|
||||
|
|
|
@ -738,6 +738,29 @@ class MeetingTests(TestCase):
|
|||
expected_session_summaries=[]
|
||||
)
|
||||
|
||||
def test_ical_filter_show_area(self):
|
||||
meeting = make_meeting_test_data()
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
self.do_ical_filter_test(
|
||||
meeting,
|
||||
querystring='?show=%s' % area.acronym,
|
||||
expected_session_summaries=[
|
||||
'ames - Asteroid Mining Equipment Standardization Group',
|
||||
'mars - Martian Special Interest Group',
|
||||
]
|
||||
)
|
||||
|
||||
def test_ical_filter_hide_area(self):
|
||||
meeting = make_meeting_test_data()
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
self.do_ical_filter_test(
|
||||
meeting,
|
||||
querystring='?show=mars&hide=%s' % area.acronym,
|
||||
expected_session_summaries=[]
|
||||
)
|
||||
|
||||
def test_ical_filter_show_and_hide(self):
|
||||
meeting = make_meeting_test_data()
|
||||
self.do_ical_filter_test(
|
||||
|
@ -1861,40 +1884,26 @@ class InterimTests(TestCase):
|
|||
q = PyQuery(r.content)
|
||||
self.assertIn('CANCELLED', q('tr>td.text-right>span').text())
|
||||
|
||||
def test_upcoming_filter_show(self):
|
||||
r, interims = self.do_upcoming_test('show=ames')
|
||||
self.assertNotContains(r, interims['mars'].number)
|
||||
self.assertContains(r, interims['ames'].number)
|
||||
self.assertContains(r, 'IETF 72')
|
||||
# cancelled session
|
||||
q = PyQuery(r.content)
|
||||
self.assertIn('CANCELLED', q('tr>td.text-right>span').text())
|
||||
|
||||
def test_upcoming_filter_show_area(self):
|
||||
make_meeting_test_data(create_interims=True)
|
||||
area = Group.objects.get(acronym='mars').parent
|
||||
self.assertEqual(area,
|
||||
Group.objects.get(acronym='ames').parent,
|
||||
'The mars and ames groups have different areas; this breaks this test')
|
||||
r, interims = self.do_upcoming_test('show=%s' % area.acronym, create_meeting=False)
|
||||
def test_upcoming_filters_ignored(self):
|
||||
"""The upcoming view should ignore filter querystrings"""
|
||||
r, interims = self.do_upcoming_test()
|
||||
self.assertContains(r, interims['mars'].number)
|
||||
self.assertContains(r, interims['ames'].number)
|
||||
self.assertContains(r, 'IETF 72')
|
||||
|
||||
def test_upcoming_filter_hide(self):
|
||||
r, interims = self.do_upcoming_test('hide=mars')
|
||||
self.assertNotContains(r, interims['mars'].number)
|
||||
self.assertNotContains(r, interims['ames'].number)
|
||||
self.assertContains(r, 'IETF 72')
|
||||
|
||||
def test_upcoming_filter_show_and_hide(self):
|
||||
r, interims = self.do_upcoming_test('show=mars,ames&hide=ames')
|
||||
r, interims = self.do_upcoming_test('show=ames', create_meeting=False)
|
||||
self.assertContains(r, interims['mars'].number)
|
||||
self.assertNotContains(r, interims['ames'].number)
|
||||
self.assertContains(r, interims['ames'].number)
|
||||
self.assertContains(r, 'IETF 72')
|
||||
|
||||
def do_upcoming_ical_test(self, querystring=None):
|
||||
make_meeting_test_data(create_interims=True)
|
||||
r, interims = self.do_upcoming_test('show=ames&hide=ames,mars', create_meeting=False)
|
||||
self.assertContains(r, interims['mars'].number)
|
||||
self.assertContains(r, interims['ames'].number)
|
||||
self.assertContains(r, 'IETF 72')
|
||||
|
||||
def do_upcoming_ical_test(self, querystring=None, create_meeting=True):
|
||||
if create_meeting:
|
||||
make_meeting_test_data(create_interims=True)
|
||||
|
||||
# Create a group with a plenary interim session for testing type filters
|
||||
somegroup = GroupFactory(acronym='sg', name='Some Group')
|
||||
|
@ -2010,6 +2019,27 @@ class InterimTests(TestCase):
|
|||
'mars - Martian Special Interest Group',
|
||||
])
|
||||
|
||||
def test_upcoming_ical_filter_show_area(self):
|
||||
make_meeting_test_data(create_interims=True)
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
r = self.do_upcoming_ical_test('show=%s' % area.acronym,
|
||||
create_meeting=False)
|
||||
assert_ical_response_is_valid(self, r,
|
||||
expected_event_summaries=[
|
||||
'ames - Asteroid Mining Equipment Standardization Group',
|
||||
'mars - Martian Special Interest Group',
|
||||
])
|
||||
|
||||
def test_upcoming_ical_filter_hide_area(self):
|
||||
make_meeting_test_data(create_interims=True)
|
||||
mars = Group.objects.get(acronym='mars')
|
||||
area = mars.parent
|
||||
r = self.do_upcoming_ical_test('show=mars&hide=%s' % area.acronym,
|
||||
create_meeting=False)
|
||||
assert_ical_response_is_valid(self, r,
|
||||
expected_event_summaries=[])
|
||||
|
||||
def test_upcoming_json(self):
|
||||
make_meeting_test_data(create_interims=True)
|
||||
url = urlreverse("ietf.meeting.views.upcoming_json")
|
||||
|
|
|
@ -1379,7 +1379,9 @@ def should_include_assignment(filter_params, assignment):
|
|||
session_type = assignment.timeslot.type_id
|
||||
|
||||
# Hide if wg or type hide lists apply
|
||||
if (group_acronym in filter_params['hide']) or (session_type in filter_params['hidetypes']):
|
||||
if ((group_acronym in filter_params['hide']) or
|
||||
(parent_acronym in filter_params['hide']) or
|
||||
(session_type in filter_params['hidetypes'])):
|
||||
return False
|
||||
|
||||
# Show if any of the show lists apply, including showing by parent group
|
||||
|
@ -2774,14 +2776,8 @@ def past(request):
|
|||
})
|
||||
|
||||
def upcoming(request):
|
||||
"""List of upcoming meetings
|
||||
|
||||
Only querystring filters by wg name are supported. Always includes IETF meetings;
|
||||
filters 'interim' type meetings by wg name as requested. The showtypes/hidetypes
|
||||
filters are ignored..
|
||||
"""
|
||||
"""List of upcoming meetings"""
|
||||
today = datetime.date.today()
|
||||
filter_params = parse_agenda_filter_params(request.GET)
|
||||
|
||||
# Get ietf meetings starting 7 days ago, and interim meetings starting today
|
||||
ietf_meetings = Meeting.objects.filter(type_id='ietf', date__gte=today-datetime.timedelta(days=7))
|
||||
|
@ -2796,22 +2792,21 @@ def upcoming(request):
|
|||
timeslotassignments__timeslot__time__gte=today
|
||||
)
|
||||
).filter(current_status__in=('sched','canceled'))
|
||||
if filter_params is not None:
|
||||
group_shown = interim_sessions.filter(
|
||||
group__acronym__in=filter_params['show']
|
||||
)
|
||||
parent_group_shown = interim_sessions.filter(
|
||||
group__parent__acronym__in=filter_params['show']
|
||||
)
|
||||
# The '|' combines querysets with OR - qs.filter(x=1) | qs.filter(y=2)
|
||||
# translates to a 'WHERE x=1 OR y=2' in the SQL.
|
||||
interim_sessions = (
|
||||
group_shown | parent_group_shown
|
||||
).exclude(
|
||||
# N.B., we only consider parent group (area) for show, not for hide.
|
||||
# This is consistent with previous behavior but is worth revisiting.
|
||||
group__acronym__in=filter_params['hide']
|
||||
)
|
||||
|
||||
# get groups for group UI display - same algorithm as in agenda(), but
|
||||
# using group / parent instead of historic_group / historic_parent
|
||||
groups = [s.group for s in interim_sessions
|
||||
if s.group
|
||||
and s.group.type_id in ('wg', 'rg', 'ag', 'rag', 'iab', 'program')
|
||||
and s.group.parent]
|
||||
group_parents = {g.parent for g in groups if g.parent}
|
||||
seen = set()
|
||||
for p in group_parents:
|
||||
p.group_list = []
|
||||
for g in groups:
|
||||
if g.acronym not in seen and g.parent.acronym == p.acronym:
|
||||
p.group_list.append(g)
|
||||
seen.add(g.acronym)
|
||||
|
||||
for session in interim_sessions:
|
||||
session.historic_group = session.group
|
||||
|
@ -2825,15 +2820,25 @@ def upcoming(request):
|
|||
# add menu actions
|
||||
actions = []
|
||||
if can_request_interim_meeting(request.user):
|
||||
actions.append(('Request new interim meeting',
|
||||
reverse('ietf.meeting.views.interim_request')))
|
||||
actions.append(('Download as .ics',
|
||||
reverse('ietf.meeting.views.upcoming_ical')))
|
||||
actions.append(('Subscribe with webcal',
|
||||
'webcal://'+request.get_host()+reverse('ietf.meeting.views.upcoming_ical')))
|
||||
actions.append(dict(
|
||||
label='Request new interim meeting',
|
||||
url=reverse('ietf.meeting.views.interim_request'),
|
||||
append_filter=False)
|
||||
)
|
||||
actions.append(dict(
|
||||
label='Download as .ics',
|
||||
url=reverse('ietf.meeting.views.upcoming_ical'),
|
||||
append_filter=True)
|
||||
)
|
||||
actions.append(dict(
|
||||
label='Subscribe with webcal',
|
||||
url='webcal://'+request.get_host()+reverse('ietf.meeting.views.upcoming_ical'),
|
||||
append_filter=True)
|
||||
)
|
||||
|
||||
return render(request, 'meeting/upcoming.html', {
|
||||
'entries': entries,
|
||||
'group_parents': group_parents,
|
||||
'menu_actions': actions,
|
||||
'menu_entries': menu_entries,
|
||||
'selected_menu_entry': selected_menu_entry,
|
||||
|
|
317
ietf/static/ietf/js/agenda/agenda_filter.js
Normal file
317
ietf/static/ietf/js/agenda/agenda_filter.js
Normal file
|
@ -0,0 +1,317 @@
|
|||
var agenda_filter_for_testing = {}; // methods to be accessed for automated testing
|
||||
var agenda_filter = function () {
|
||||
'use strict'
|
||||
|
||||
var update_callback // function(filter_params)
|
||||
var enable_non_area = false // if true, show the non-area filters
|
||||
|
||||
/* Add to list without duplicates */
|
||||
function add_list_item (list, item) {
|
||||
if (list.indexOf(item) === -1) {
|
||||
list.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove from list, if present */
|
||||
function remove_list_item (list, item) {
|
||||
var item_index = list.indexOf(item);
|
||||
if (item_index !== -1) {
|
||||
list.splice(item_index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* Add to list if not present, remove if present */
|
||||
function toggle_list_item (list, item) {
|
||||
var item_index = list.indexOf(item);
|
||||
if (item_index === -1) {
|
||||
list.push(item)
|
||||
} else {
|
||||
list.splice(item_index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function parse_query_params (qs) {
|
||||
var params = {}
|
||||
qs = decodeURI(qs).replace(/^\?/, '').toLowerCase()
|
||||
if (qs) {
|
||||
var param_strs = qs.split('&')
|
||||
for (var ii = 0; ii < param_strs.length; ii++) {
|
||||
var toks = param_strs[ii].split('=', 2)
|
||||
params[toks[0]] = toks[1] || true
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/* filt = 'show' or 'hide' */
|
||||
function get_filter_from_qparams (qparams, filt) {
|
||||
if (!qparams[filt] || (qparams[filt] === true)) {
|
||||
return [];
|
||||
}
|
||||
return $.map(qparams[filt].split(','), function(s){return s.trim();});
|
||||
}
|
||||
|
||||
function get_filter_params (qparams) {
|
||||
var enabled = !!(qparams.show || qparams.hide || qparams.showtypes || qparams.hidetypes);
|
||||
return {
|
||||
enabled: enabled,
|
||||
show_groups: get_filter_from_qparams(qparams, 'show'),
|
||||
hide_groups: get_filter_from_qparams(qparams, 'hide'),
|
||||
show_types: get_filter_from_qparams(qparams, 'showtypes'),
|
||||
hide_types: get_filter_from_qparams(qparams, 'hidetypes'),
|
||||
}
|
||||
}
|
||||
|
||||
function filtering_is_enabled (filter_params) {
|
||||
return filter_params['enabled'];
|
||||
}
|
||||
|
||||
function get_area_items (area) {
|
||||
var types = [];
|
||||
var groups = [];
|
||||
var neg_groups = [];
|
||||
|
||||
$('.view.' + area).find('button').each(function (index, elt) {
|
||||
elt = $(elt) // jquerify
|
||||
var item = elt.text().trim().toLowerCase()
|
||||
if (elt.hasClass('picktype')) {
|
||||
types.push(item)
|
||||
} else if (elt.hasClass('pickview')) {
|
||||
groups.push(item);
|
||||
} else if (elt.hasClass('pickviewneg')) {
|
||||
neg_groups.push(item)
|
||||
}
|
||||
});
|
||||
return { 'groups': groups, 'neg_groups': neg_groups, 'types': types };
|
||||
}
|
||||
|
||||
// Update the filter / customization UI to match the current filter parameters
|
||||
function update_filter_ui (filter_params) {
|
||||
var area_group_buttons = $('.view .pickview, .pick-area');
|
||||
var non_area_header_button = $('button.pick-non-area');
|
||||
var non_area_type_buttons = $('.view.non-area .picktype');
|
||||
var non_area_group_buttons = $('.view.non-area button.pickviewneg');
|
||||
|
||||
if (!filtering_is_enabled(filter_params)) {
|
||||
// Not filtering - set everything to defaults and exit
|
||||
area_group_buttons.removeClass('active');
|
||||
non_area_header_button.removeClass('active');
|
||||
non_area_type_buttons.removeClass('active');
|
||||
non_area_group_buttons.removeClass('active');
|
||||
non_area_group_buttons.addClass('disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// show the customizer - it will stay visible even if filtering is disabled
|
||||
$('#customize').collapse('show')
|
||||
|
||||
// Group and area buttons - these are all positive selections
|
||||
area_group_buttons.each(function (index, elt) {
|
||||
elt = $(elt);
|
||||
var item = elt.text().trim().toLowerCase();
|
||||
var area = elt.attr('data-group-area');
|
||||
if ((filter_params['hide_groups'].indexOf(item) === -1) // not hidden...
|
||||
&& ((filter_params['show_groups'].indexOf(item) !== -1) // AND shown...
|
||||
|| (area && (filter_params['show_groups'].indexOf(area.trim().toLowerCase()) !== -1))) // OR area shown
|
||||
) {
|
||||
elt.addClass('active');
|
||||
} else {
|
||||
elt.removeClass('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Non-area buttons need special handling. Only have positive type and negative group buttons.
|
||||
// Assume non-area heading is disabled, then enable if one of the types is active
|
||||
non_area_header_button.removeClass('active');
|
||||
non_area_group_buttons.addClass('disabled');
|
||||
non_area_type_buttons.each(function (index, elt) {
|
||||
// Positive type selection buttons
|
||||
elt = $(elt);
|
||||
var item = elt.text().trim().toLowerCase();
|
||||
if ((filter_params['show_types'].indexOf(item) !== -1)
|
||||
&& (filter_params['hide_types'].indexOf(item) === -1)){
|
||||
elt.addClass('active');
|
||||
non_area_header_button.addClass('active');
|
||||
non_area_group_buttons.removeClass('disabled');
|
||||
} else {
|
||||
elt.removeClass('active');
|
||||
}
|
||||
});
|
||||
|
||||
non_area_group_buttons.each(function (index, elt) {
|
||||
// Negative group selection buttons
|
||||
elt = $(elt);
|
||||
var item = elt.text().trim().toLowerCase();
|
||||
if (filter_params['hide_groups'].indexOf(item) === -1) {
|
||||
elt.addClass('active');
|
||||
} else {
|
||||
elt.removeClass('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Update state of the view to match the filters
|
||||
*
|
||||
* Calling the individual update_* functions outside of this method will likely cause
|
||||
* various parts of the page to get out of sync.
|
||||
*/
|
||||
function update_view () {
|
||||
var filter_params = get_filter_params(parse_query_params(window.location.search))
|
||||
update_filter_ui(filter_params)
|
||||
if (update_callback) {
|
||||
update_callback(filter_params)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Trigger an update so the user will see the page appropriate for given filter_params
|
||||
*
|
||||
* Updates the URL to match filter_params, then updates the history / display to match
|
||||
* (if supported) or loads the new URL.
|
||||
*/
|
||||
function update_filters (filter_params) {
|
||||
var qparams = []
|
||||
var search = ''
|
||||
if (filter_params['show_groups'].length > 0) {
|
||||
qparams.push('show=' + filter_params['show_groups'].join())
|
||||
}
|
||||
if (filter_params['hide_groups'].length > 0) {
|
||||
qparams.push('hide=' + filter_params['hide_groups'].join())
|
||||
}
|
||||
if (filter_params['show_types'].length > 0) {
|
||||
qparams.push('showtypes=' + filter_params['show_types'].join())
|
||||
}
|
||||
if (filter_params['hide_types'].length > 0) {
|
||||
qparams.push('hidetypes=' + filter_params['hide_types'].join())
|
||||
}
|
||||
if (qparams.length > 0) {
|
||||
search = '?' + qparams.join('&')
|
||||
}
|
||||
|
||||
// strip out the search / hash, then add back
|
||||
var new_url = window.location.href.replace(/(\?.*)?(#.*)?$/, search + window.location.hash)
|
||||
if (window.history && window.history.replaceState) {
|
||||
// Keep current origin, replace search string, no page reload
|
||||
history.replaceState({}, document.title, new_url)
|
||||
update_view()
|
||||
} else {
|
||||
// No window.history.replaceState support, page reload required
|
||||
window.location = new_url
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper for pick group/type button handlers - toggles the appropriate parameter entry
|
||||
* elt - the jquery element that was clicked
|
||||
* param_type - key of the filter param to update (show_groups, show_types, etc)
|
||||
*/
|
||||
function handle_pick_button (elt, param_type) {
|
||||
var area = elt.attr('data-group-area');
|
||||
var item = elt.text().trim().toLowerCase();
|
||||
var fp = get_filter_params(parse_query_params(window.location.search));
|
||||
var neg_param_type = {
|
||||
show_groups: 'hide_groups',
|
||||
hide_groups: 'show_groups',
|
||||
show_types: 'hide_types',
|
||||
hide_types: 'show_types'
|
||||
}[param_type];
|
||||
|
||||
if (area && (fp[param_type].indexOf(area.trim().toLowerCase()) !== -1)) {
|
||||
// Area is shown - toggle hide list
|
||||
toggle_list_item(fp[neg_param_type], item);
|
||||
remove_list_item(fp[param_type], item);
|
||||
} else {
|
||||
toggle_list_item(fp[param_type], item);
|
||||
remove_list_item(fp[neg_param_type], item);
|
||||
}
|
||||
return fp;
|
||||
}
|
||||
|
||||
function is_disabled(elt) {
|
||||
return elt.hasClass('disabled');
|
||||
}
|
||||
|
||||
// Various "pick" button handlers
|
||||
$('.pickview').click(function () {
|
||||
if (is_disabled($(this))) { return; }
|
||||
update_filters(handle_pick_button($(this), 'show_groups'))
|
||||
});
|
||||
|
||||
$('.pickviewneg').click(function () {
|
||||
if (is_disabled($(this))) { return; }
|
||||
update_filters(handle_pick_button($(this), 'hide_groups'))
|
||||
});
|
||||
|
||||
$('.picktype').click(function () {
|
||||
if (is_disabled($(this))) { return; }
|
||||
var fp = handle_pick_button($(this), 'show_types')
|
||||
// If we just disabled the last non-area type, clear out the hide groups list.
|
||||
var items = get_area_items('non-area')
|
||||
var any_left = false
|
||||
$.each(items.types, function (index, session_type) {
|
||||
if (fp['show_types'].indexOf(session_type) !== -1) {
|
||||
any_left = true
|
||||
}
|
||||
})
|
||||
if (!any_left) {
|
||||
fp['hide_groups'] = []
|
||||
}
|
||||
update_filters(fp);
|
||||
});
|
||||
|
||||
// Click handler for an area header button
|
||||
$('.pick-area').click(function() {
|
||||
if (is_disabled($(this))) { return; }
|
||||
var fp = handle_pick_button($(this), 'show_groups');
|
||||
var items = get_area_items($(this).text().trim().toLowerCase());
|
||||
|
||||
// Clear all the individual group show/hide options
|
||||
$.each(items.groups, function(index, group) {
|
||||
remove_list_item(fp['show_groups'], group);
|
||||
remove_list_item(fp['hide_groups'], group);
|
||||
});
|
||||
update_filters(fp);
|
||||
});
|
||||
|
||||
// Click handler for the "Non-Area" header button
|
||||
$('.pick-non-area').click(function () {
|
||||
var items = get_area_items('non-area');
|
||||
|
||||
var fp = get_filter_params(parse_query_params(window.location.search))
|
||||
if ($(this).hasClass('active')) {
|
||||
// Were active - disable or hide everything
|
||||
$.each(items.types, function (index, session_type) {
|
||||
remove_list_item(fp['show_types'], session_type)
|
||||
})
|
||||
// When no types are shown, no need to hide groups. Empty hide_groups list.
|
||||
fp['hide_groups'] = []
|
||||
} else {
|
||||
// Were not active - enable or stop hiding everything
|
||||
$.each(items.types, function (index, session_type) {
|
||||
add_list_item(fp['show_types'], session_type)
|
||||
})
|
||||
$.each(items.neg_groups, function (index, group) {
|
||||
remove_list_item(fp['hide_groups'], group)
|
||||
})
|
||||
}
|
||||
update_filters(fp);
|
||||
});
|
||||
|
||||
// Entry point to filtering code when page loads
|
||||
function enable () {
|
||||
$(document).ready(function () {
|
||||
update_view()
|
||||
})
|
||||
}
|
||||
|
||||
// Make private functions available for unit testing
|
||||
agenda_filter_for_testing.toggle_list_item = toggle_list_item;
|
||||
agenda_filter_for_testing.parse_query_params = parse_query_params;
|
||||
|
||||
// Public interface methods
|
||||
return {
|
||||
enable: enable,
|
||||
filtering_is_enabled: filtering_is_enabled,
|
||||
include_non_area_selectors: function () {enable_non_area = true},
|
||||
set_update_callback: function (cb) {update_callback = cb}
|
||||
}
|
||||
}();
|
|
@ -62,76 +62,7 @@
|
|||
{% endif %}
|
||||
|
||||
|
||||
<div class="panel-group" id="accordion">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" data-parent="#accordion" href="#customize">
|
||||
<span class="fa fa-caret-down"></span> Customize the agenda view...
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="customize" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
|
||||
<p>
|
||||
You can customize the agenda view to show only selected sessions,
|
||||
by clicking on groups and areas in the table below.
|
||||
To be able to return to the customized view later, bookmark the resulting URL.
|
||||
</p>
|
||||
|
||||
{% if group_parents|length %}
|
||||
<p>Groups displayed in <b><i>italics</i></b> are BOFs.</p>
|
||||
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for p in group_parents %}
|
||||
<th style="width:{% widthratio 1 group_parents|length 100 %}%">
|
||||
<button class="btn btn-default btn-block pickview {{p.acronym|lower}}">{{p.acronym|upper}}</button>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{% for p in group_parents %}
|
||||
<td class="view {{p.acronym|lower}}">
|
||||
<div class="btn-group-vertical btn-block">
|
||||
{% for group in p.group_list %}
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickview {{group.acronym}}">
|
||||
{% if group.is_bof %}
|
||||
<i>{{group.acronym}}</i>
|
||||
{% else %}
|
||||
{{group.acronym}}
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<blockquote><i>No WG / RG data available -- no WG / RG sessions have been scheduled yet.</i></blockquote>
|
||||
{% endif %}
|
||||
<p>Also show special sessions of these groups:</p>
|
||||
<div class="btn-group btn-group-justified">
|
||||
<div class="btn-group"><button class="btn btn-default pickview iepg"> IEPG</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview tools"> Tools</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview edu"> EDU</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview ietf"> IETF</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview iesg"> IESG</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview iab"> IAB</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview secretariat"> Secretariat</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "meeting/agenda_filter.html" with group_parents=group_parents non_area_filters=True only %}
|
||||
|
||||
<h2>Download as .ics</h2>
|
||||
<p class="buttonlist">
|
||||
|
@ -193,7 +124,10 @@
|
|||
{% endif %}
|
||||
|
||||
{% if item.timeslot.type.slug == 'break' or item.timeslot.type.slug == 'reg' or item.timeslot.type.slug == 'other' %}
|
||||
<tr id="row-{{ item.slug }}" data-item-group={{ item.session.group.acronym }} data-timeslot-type="{{item.timeslot.type.slug}}">
|
||||
<tr id="row-{{ item.slug }}"
|
||||
data-item-group="{% if item.session.historic_group %}{{ item.session.historic_group.acronym }}{% endif %}"
|
||||
data-item-area="{% if item.session.historic_group and item.session.historic_group.historic_parent %}{{ item.session.historic_group.historic_parent.acronym }}{% endif %}"
|
||||
data-timeslot-type="{{item.timeslot.type.slug}}">
|
||||
<td class="text-nowrap text-right">
|
||||
{% if "-utc" in request.path %}
|
||||
{{item.timeslot.utc_start_time|date:"G:i"}}-{{item.timeslot.utc_end_time|date:"G:i"}}
|
||||
|
@ -251,7 +185,11 @@
|
|||
|
||||
{% if item.timeslot.type_id == 'regular' or item.timeslot.type.slug == 'plenary' %}
|
||||
{% if item.session.historic_group %}
|
||||
<tr id="row-{{item.slug}}" data-item-group={{ item.session.group.acronym }} data-timeslot-type="{{item.timeslot.type.slug}}" data-ske="row-{{ item.slug }}" {% if item.timeslot.type.slug == 'plenary' %}class="{{item.timeslot.type.slug}}danger"{% endif %}>
|
||||
<tr id="row-{{item.slug}}"
|
||||
data-item-group="{{ item.session.historic_group.acronym }}"
|
||||
data-item-area="{% if item.session.historic_group.historic_parent %}{{ item.session.historic_group.historic_parent.acronym }}{% endif %}"
|
||||
data-timeslot-type="{{item.timeslot.type.slug}}"
|
||||
data-ske="row-{{ item.slug }}" {% if item.timeslot.type.slug == 'plenary' %}class="{{item.timeslot.type.slug}}danger"{% endif %}>
|
||||
{% if item.timeslot.type.slug == 'plenary' %}
|
||||
<th class="text-nowrap text-right">
|
||||
{% if "-utc" in request.path %}
|
||||
|
@ -359,161 +297,86 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
<script src="{% static 'ietf/js/agenda/agenda_filter.js' %}"></script>
|
||||
<script>
|
||||
function parse_query_params(qs) {
|
||||
var params = {};
|
||||
qs = qs.replace(/^\?/, '').toLowerCase();
|
||||
if (qs) {
|
||||
var param_strs = qs.split('&');
|
||||
for (var ii = 0; ii < param_strs.length; ii++) {
|
||||
var toks = param_strs[ii].split('=', 2)
|
||||
params[toks[0]] = toks[1] || true;
|
||||
}
|
||||
// Update the agenda display with specified filters
|
||||
function update_agenda_display(filter_params) {
|
||||
var agenda_rows=$('[id^="row-"]')
|
||||
|
||||
if (!agenda_filter.filtering_is_enabled(filter_params)) {
|
||||
// When filtering is not enabled, show all sessions
|
||||
agenda_rows.show();
|
||||
return;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/* filt = 'show' or 'hide' */
|
||||
function get_filter_from_qparams(qparams, filt) {
|
||||
return qparams[filt] ? qparams[filt].split(',') : [];
|
||||
|
||||
// if groups were selected for filtering, hide all rows by default
|
||||
agenda_rows.hide();
|
||||
|
||||
// loop through the has items and change the UI element and row visibilities accordingly
|
||||
$.each(filter_params['show_groups'], function (i, v) {
|
||||
// this is a regular item by wg: when present, show these rows
|
||||
agenda_rows.filter('[data-item-group="'+ v +'"]').show();
|
||||
agenda_rows.filter('[data-item-area="'+ v +'"]').show();
|
||||
});
|
||||
$.each(filter_params['show_types'], function (i, v) {
|
||||
// this is a regular item by type: when present, show these rows
|
||||
agenda_rows.filter('[data-timeslot-type*="' + v + '"]').show();
|
||||
});
|
||||
$.each(filter_params['hide_groups'], function (i, v) {
|
||||
// this is a "negative" item by wg: when present, hide these rows
|
||||
agenda_rows.filter('[data-item-group="'+ v +'"]').hide();
|
||||
agenda_rows.filter('[data-item-area="'+ v +'"]').hide();
|
||||
});
|
||||
$.each(filter_params['hide_types'], function (i, v) {
|
||||
// this is a "negative" item by type: when present, hide these rows
|
||||
agenda_rows.filter('[data-timeslot-type*="' + v + '"]').hide();
|
||||
});
|
||||
}
|
||||
|
||||
function get_filter_params(qparams) {
|
||||
return {
|
||||
show_groups: get_filter_from_qparams(qparams, 'show'),
|
||||
hide_groups: get_filter_from_qparams(qparams, 'hide'),
|
||||
show_types: get_filter_from_qparams(qparams, 'showtypes'),
|
||||
hide_types: get_filter_from_qparams(qparams, 'hidetypes'),
|
||||
};
|
||||
}
|
||||
|
||||
function toggle_visibility(filter_params) {
|
||||
// reset UI elements to default state
|
||||
$(".pickview").removeClass("active disabled");
|
||||
$(".pickviewneg").addClass("active");
|
||||
|
||||
if (filter_params['show_groups'].length ||
|
||||
filter_params['hide_groups'].length ||
|
||||
filter_params['show_types'].length ||
|
||||
filter_params['hide_types'].length
|
||||
) {
|
||||
// if groups were selected for filtering, hide all rows by default
|
||||
$('[id^="row-"]').hide();
|
||||
|
||||
// show the customizer
|
||||
$("#customize").collapse("show");
|
||||
|
||||
// loop through the has items and change the UI element and row visibilities accordingly
|
||||
$.each(filter_params['show_groups'], function (i, v) {
|
||||
// this is a regular item by wg: when present, show these rows
|
||||
$('[id^="row-"]').filter('[data-item-group="'+ v +'"]').show();
|
||||
$(".view." + v).find("button").addClass("active disabled");
|
||||
$("button.pickview." + v).addClass("active");
|
||||
});
|
||||
$.each(filter_params['show_types'], function (i, v) {
|
||||
// this is a regular item by type: when present, show these rows
|
||||
$('[id^="row-"]').filter('[data-timeslot-type*="' + v + '"]').show();
|
||||
});
|
||||
$.each(filter_params['hide_groups'], function (i, v) {
|
||||
// this is a "negative" item by wg: when present, hide these rows
|
||||
$('[id^="row-"]').filter('[data-item-group="'+ v +'"]').hide();
|
||||
$(".view." + v).find("button").removeClass("active disabled");
|
||||
$("button.pickviewneg." + v).removeClass("active");
|
||||
});
|
||||
$.each(filter_params['hide_types'], function (i, v) {
|
||||
// this is a "negative" item by type: when present, hide these rows
|
||||
$('[id^="row-"]').filter('[data-timeslot-type*="' + v + '"]').hide();
|
||||
});
|
||||
|
||||
// show the week view
|
||||
update_weekview();
|
||||
$("#weekview").removeClass("hidden");
|
||||
|
||||
// show the custom .ics link
|
||||
$("#ical-link").attr("href",$("#ical-link").attr("href").split("?")[0]+window.location.search);
|
||||
$("#ical-link").removeClass("hidden");
|
||||
|
||||
function update_ical_links(filter_params) {
|
||||
var ical_link = $("#ical-link");
|
||||
if (agenda_filter.filtering_is_enabled(filter_params)) {
|
||||
// Replace the query string in the ical link
|
||||
var orig_link_href = ical_link.attr("href").split("?")[0];
|
||||
ical_link.attr("href", orig_link_href+window.location.search);
|
||||
ical_link.removeClass("hidden");
|
||||
} else {
|
||||
// if the hash is empty, show all and hide weekview / custom ical link
|
||||
$('[id^="row-"]').show();
|
||||
$("#ical-link, #weekview").addClass("hidden");
|
||||
ical_link.addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
$(".pickview, .pickviewneg").click(function () {
|
||||
// Get clicked item label
|
||||
var item = $(this).text().trim().toLowerCase();
|
||||
var fp = get_filter_params(parse_query_params(window.location.search));
|
||||
function update_weekview(filter_params) {
|
||||
var weekview = $("#weekview");
|
||||
|
||||
if ($(this).hasClass("pickviewneg")) {
|
||||
toggle_list_item(fp['hide_groups'], item);
|
||||
} else {
|
||||
toggle_list_item(fp['show_groups'], item);
|
||||
}
|
||||
update_filters(fp);
|
||||
});
|
||||
|
||||
/* Add to list if not present, remove if present */
|
||||
function toggle_list_item(list, item) {
|
||||
var item_index = $.inArray(item, list);
|
||||
if (item_index === -1) {
|
||||
list.push(item);
|
||||
} else {
|
||||
list.splice(item_index, 1);
|
||||
}
|
||||
if (!agenda_filter.filtering_is_enabled(filter_params)) {
|
||||
weekview.addClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Filtering is enabled
|
||||
weekview.removeClass("hidden");
|
||||
|
||||
var wv_iframe = document.getElementById('weekview');
|
||||
var wv_window = wv_iframe.contentWindow;
|
||||
var new_url = 'week-view.html' + window.location.search;
|
||||
if (wv_iframe.src && wv_window.history && wv_window.history.replaceState) {
|
||||
wv_window.history.replaceState({}, '', new_url);
|
||||
wv_window.draw_calendar()
|
||||
} else {
|
||||
// ho history.replaceState, page reload required
|
||||
wv_iframe.src = new_url;
|
||||
}
|
||||
}
|
||||
|
||||
function update_view(filter_params) {
|
||||
update_agenda_display(filter_params);
|
||||
update_weekview(filter_params)
|
||||
update_ical_links(filter_params)
|
||||
}
|
||||
|
||||
function update_filters(filter_params) {
|
||||
var qparams = [];
|
||||
var search = '';
|
||||
if (filter_params['show_groups'].length > 0) {
|
||||
qparams.push('show=' + filter_params['show_groups'].join());
|
||||
}
|
||||
if (filter_params['hide_groups'].length > 0) {
|
||||
qparams.push('hide=' + filter_params['hide_groups'].join());
|
||||
}
|
||||
if (filter_params['show_types'].length > 0) {
|
||||
qparams.push('showtypes=' + filter_params['show_types'].join());
|
||||
}
|
||||
if (filter_params['hide_types'].length > 0) {
|
||||
qparams.push('hidetypes=' + filter_params['hide_types'].join());
|
||||
}
|
||||
if (qparams.length > 0) {
|
||||
search = '?' + qparams.join('&');
|
||||
}
|
||||
|
||||
// strip out the search / hash, then add back
|
||||
var new_url = window.location.href.replace(/(\?.*)?(#.*)?$/, search + window.location.hash);
|
||||
if (window.history && window.history.replaceState) {
|
||||
// Keep current origin, replace search string, no page reload
|
||||
history.replaceState({}, document.title, new_url);
|
||||
toggle_visibility(filter_params);
|
||||
} else {
|
||||
// No window.history.replaceState support, page reload required
|
||||
window.location = new_url;
|
||||
}
|
||||
}
|
||||
|
||||
function update_weekview() {
|
||||
var wv_iframe = document.getElementById('weekview');
|
||||
var wv_window = wv_iframe.contentWindow;
|
||||
var new_url = 'week-view.html' + window.location.search;
|
||||
if (wv_iframe.src && wv_window.history && wv_window.history.replaceState) {
|
||||
wv_window.history.replaceState({}, '', new_url);
|
||||
wv_window.draw_calendar()
|
||||
} else {
|
||||
// ho history.replaceState, page reload required
|
||||
wv_iframe.src = new_url;
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
toggle_visibility(
|
||||
get_filter_params(
|
||||
parse_query_params(window.location.search)
|
||||
)
|
||||
);
|
||||
});
|
||||
agenda_filter.set_update_callback(update_view);
|
||||
agenda_filter.enable();
|
||||
|
||||
$(".modal").on("show.bs.modal", function () {
|
||||
var i = $(this).find(".frame");
|
||||
|
|
105
ietf/templates/meeting/agenda_filter.html
Normal file
105
ietf/templates/meeting/agenda_filter.html
Normal file
|
@ -0,0 +1,105 @@
|
|||
<div class="panel-group" id="accordion">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" data-parent="#accordion" href="#customize">
|
||||
<span class="fa fa-caret-down"></span> Customize the agenda view...
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="customize" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
|
||||
<p>
|
||||
You can customize the agenda view to show only selected sessions,
|
||||
by clicking on groups and areas in the table below.
|
||||
To be able to return to the customized view later, bookmark the resulting URL.
|
||||
</p>
|
||||
|
||||
{% if group_parents|length %}
|
||||
<p>Groups displayed in <b><i>italics</i></b> are BOFs.</p>
|
||||
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for p in group_parents %}
|
||||
<th style="width:{% widthratio 1 group_parents|length|add:1 100 %}%">
|
||||
<button class="btn btn-default btn-block pick-area {{ p.acronym|lower }}">{{ p.acronym|upper }}</button>
|
||||
</th>
|
||||
{% endfor %}
|
||||
{% if non_area_filters %}
|
||||
<th style="width:{% widthratio 1 group_parents|length|add:1 100 %}">
|
||||
<button class="btn btn-default btn-block pick-non-area">Non-Area</button>
|
||||
</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{% for p in group_parents %}
|
||||
<td class="view {{ p.acronym|lower }}">
|
||||
<div class="btn-group-vertical btn-block">
|
||||
{% for group in p.group_list %}
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickview {{ group.acronym }}"
|
||||
data-group-area="{{ p.acronym|lower }}">
|
||||
{% if group.is_bof %}
|
||||
<i>{{ group.acronym }}</i>
|
||||
{% else %}
|
||||
{{ group.acronym }}
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% if non_area_filters %}
|
||||
<!-- Non-Area buttons -->
|
||||
<td class="view non-area">
|
||||
<div class="btn-group-vertical btn-block">
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default picktype plenary">Plenary</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default picktype other">Other</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group-vertical btn-block">
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg edu"> EDU</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg hackathon"> Hackathon</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg iab"> IAB</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg iepg"> IEPG</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg iesg"> IESG</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg ietf"> IETF</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg secretariat"> Secretariat
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-xs btn-group-justified">
|
||||
<button class="btn btn-default pickviewneg tools"> Tools</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<blockquote><i>No WG / RG data available -- no WG / RG sessions have been scheduled yet.</i>
|
||||
</blockquote>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{# Copyright The IETF Trust 2015, 2020, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load cache %}
|
||||
{% load ietf_filters static classname %}
|
||||
|
@ -31,7 +31,9 @@
|
|||
|
||||
<p>For more on regular IETF meetings see <a href="https://www.ietf.org/meeting/upcoming.html">here</a></p>
|
||||
|
||||
{% if menu_entries %}
|
||||
{% include 'meeting/agenda_filter.html' with group_parents=group_parents only%}
|
||||
|
||||
{% if menu_entries %}
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
{% for name, url in menu_entries %}
|
||||
<li {% if selected_menu_entry == name.lower %}class="active"{% endif %}>
|
||||
|
@ -42,16 +44,18 @@
|
|||
{% endif %}
|
||||
|
||||
{% if menu_actions %}
|
||||
<div class="buttonlist">
|
||||
{% for name, url in menu_actions %}
|
||||
<a class="btn btn-default" href="{{ url }}">{{ name }}</a>
|
||||
<div id="menu-actions" class="buttonlist">
|
||||
{% for action in menu_actions %}
|
||||
<a class="btn btn-default"
|
||||
data-append-filter="{{ action.append_filter }}"
|
||||
href="{{ action.url }}">{{ action.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% cache 600 upcoming-meetings entries.count %}
|
||||
{% if entries %}
|
||||
<table class="table table-condensed table-striped tablesorter">
|
||||
<table id="upcoming-meeting-table" class="table table-condensed table-striped tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
|
@ -62,7 +66,9 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<tr class="entry"
|
||||
data-item-group="{% if entry.group %}{{ entry.group.acronym|lower }}{% endif %}"
|
||||
data-item-area="{% if entry.group and entry.group.parent %}{{ entry.group.parent.acronym|lower }}{% endif %}">
|
||||
{% if entry|classname == 'Meeting' %}
|
||||
{% with meeting=entry %}
|
||||
<td>{{ meeting.date }} - {{ meeting.end }}</td>
|
||||
|
@ -110,47 +116,152 @@
|
|||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
<script src="{% static 'fullcalendar/core/main.js' %}"></script>
|
||||
<script src="{% static 'fullcalendar/daygrid/main.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/agenda/agenda_filter.js' %}"></script>
|
||||
<script>
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
var glue = calendarEl.clientWidth > 720 ? ' ' : '\n';
|
||||
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
plugins: [ 'dayGrid' ],
|
||||
displayEventTime: false,
|
||||
events: [
|
||||
{% for entry in entries %}
|
||||
{% if entry|classname == 'Meeting' %}
|
||||
// List of all events with meta-info needed for filtering
|
||||
var all_event_list = [{% for entry in entries %}
|
||||
{% if entry|classname == 'Meeting' %}
|
||||
{% with meeting=entry %}
|
||||
{
|
||||
title: 'IETF {{ meeting.number }}',
|
||||
start: '{{meeting.date}}',
|
||||
end: '{{meeting.end}}',
|
||||
url: '{% url 'ietf.meeting.views.agenda' num=meeting.number %}'
|
||||
}{% if not forloop.last %}, {% endif %}
|
||||
{
|
||||
title: 'IETF {{ meeting.number }}',
|
||||
start: '{{meeting.date}}',
|
||||
end: '{{meeting.end}}',
|
||||
url: '{% url 'ietf.meeting.views.agenda' num=meeting.number %}'
|
||||
}{% if not forloop.last %}, {% endif %}
|
||||
{% endwith %}
|
||||
{% else %} {# if it's not a Meeting, it's a Session #}
|
||||
{% else %} {# if it's not a Meeting, it's a Session #}
|
||||
{% with session=entry %}
|
||||
{
|
||||
title: '{{session.official_timeslotassignment.timeslot.utc_start_time|date:"H:i"}}-{{session.official_timeslotassignment.timeslot.utc_end_time|date:"H:i"}}'+glue+'{{session.group.acronym}}',
|
||||
start: '{{session.official_timeslotassignment.timeslot.utc_start_time | date:"Y-m-d H:i"}}',
|
||||
end: '{{session.official_timeslotassignment.timeslot.utc_end_time | date:"Y-m-d H:i"}}',
|
||||
url: '{% url 'ietf.meeting.views.session_details' num=session.meeting.number acronym=session.group.acronym %}'
|
||||
}
|
||||
{
|
||||
title: '{{session.official_timeslotassignment.timeslot.utc_start_time|date:"H:i"}}-{{session.official_timeslotassignment.timeslot.utc_end_time|date:"H:i"}}',
|
||||
group: '{% if session.group %}{{session.group.acronym}}{% endif %}',
|
||||
area: '{% if session.group and session.group.parent %}{{ session.group.parent.acronym }}{% endif %}',
|
||||
start: '{{session.official_timeslotassignment.timeslot.utc_start_time | date:"Y-m-d H:i"}}',
|
||||
end: '{{session.official_timeslotassignment.timeslot.utc_end_time | date:"Y-m-d H:i"}}',
|
||||
url: '{% url 'ietf.meeting.views.session_details' num=session.meeting.number acronym=session.group.acronym %}'
|
||||
}
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}, {% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
eventRender: function(info) {
|
||||
$(info.el).tooltip({title: info.event.title});
|
||||
},
|
||||
timeFormat: 'H:mm',
|
||||
});
|
||||
{% endif %}
|
||||
{% endfor %}];
|
||||
var filtered_event_list = []; // currently visible list
|
||||
var event_calendar; // handle on the calendar object
|
||||
|
||||
calendar.render();
|
||||
});
|
||||
// Test whether an event should be visible given a set of filter parameters
|
||||
function calendar_event_visible(filter_params, event) {
|
||||
// Visible if filtering is disabled or event has no group
|
||||
if (!agenda_filter.filtering_is_enabled(filter_params) || !event.group) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exclude if group or area is in the hide_groups list
|
||||
if (filter_params['hide_groups'].indexOf(event.group) !== -1) {
|
||||
return false;
|
||||
}
|
||||
if (event.area && (filter_params['hide_groups'].indexOf(event.area) !== -1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include if group or area is in the show_groups list
|
||||
if (filter_params['show_groups'].indexOf(event.group) !== -1) {
|
||||
return true;
|
||||
}
|
||||
if (event.area && (filter_params['show_groups'].indexOf(event.area) !== -1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not selected, exclude by default
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply filter_params to the event list and format data for the calendar
|
||||
function filter_calendar_events(filter_params, event_list) {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
var glue = calendarEl.clientWidth > 720 ? ' ' : '\n';
|
||||
var filtered_output = [];
|
||||
for (var ii = 0; ii < event_list.length; ii++) {
|
||||
var this_event = event_list[ii];
|
||||
if (calendar_event_visible(filter_params, this_event)) {
|
||||
filtered_output.push({
|
||||
title: this_event.title + (this_event.group ? (glue + this_event.group) : ''),
|
||||
start: this_event.start,
|
||||
end: this_event.end,
|
||||
url: this_event.url
|
||||
})
|
||||
}
|
||||
}
|
||||
return filtered_output;
|
||||
}
|
||||
|
||||
// Initialize or update the calendar, updating the filtered event list
|
||||
function update_calendar(filter_params) {
|
||||
filtered_event_list = filter_calendar_events(filter_params, all_event_list);
|
||||
if (event_calendar) {
|
||||
event_calendar.refetchEvents()
|
||||
} else {
|
||||
/* Initialize the calendar object.
|
||||
* The event source is a function that simply returns the current global list of
|
||||
* filtered events.
|
||||
*/
|
||||
var calendarEl = document.getElementById('calendar')
|
||||
event_calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
plugins: ['dayGrid'],
|
||||
displayEventTime: false,
|
||||
events: function (fInfo, success) {success(filtered_event_list)},
|
||||
eventRender: function (info) {
|
||||
$(info.el).tooltip({ title: info.event.title })
|
||||
},
|
||||
timeFormat: 'H:mm',
|
||||
})
|
||||
event_calendar.render()
|
||||
}
|
||||
}
|
||||
|
||||
function update_meeting_display(filter_params) {
|
||||
var meeting_rows = $("#upcoming-meeting-table tr.entry")
|
||||
if (!agenda_filter.filtering_is_enabled(filter_params)) {
|
||||
meeting_rows.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// hide everything that has a group
|
||||
meeting_rows.filter("[data-item-group!='']").hide();
|
||||
|
||||
$.each(filter_params['show_groups'], function (i, v) {
|
||||
// this is a regular item by wg: when present, show these rows
|
||||
meeting_rows.filter('[data-item-group="'+ v +'"]').show();
|
||||
meeting_rows.filter('[data-item-area="'+ v +'"]').show();
|
||||
});
|
||||
$.each(filter_params['hide_groups'], function (i, v) {
|
||||
// this is a "negative" item by wg: when present, hide these rows
|
||||
meeting_rows.filter('[data-item-group="'+ v +'"]').hide();
|
||||
meeting_rows.filter('[data-item-area="'+ v +'"]').hide();
|
||||
});
|
||||
}
|
||||
|
||||
function update_links(filter_params) {
|
||||
var filtered_links = $("#menu-actions [data-append-filter='True']");
|
||||
var filtering_enabled = agenda_filter.filtering_is_enabled(filter_params);
|
||||
filtered_links.each(function(index, elt) {
|
||||
var orig_link_href = $(elt).attr("href").split("?")[0];
|
||||
if (filtering_enabled) {
|
||||
// append new querystring
|
||||
$(elt).attr("href", orig_link_href+window.location.search);
|
||||
} else {
|
||||
// remove querystring
|
||||
$(elt).attr("href", orig_link_href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function update_view(filter_params) {
|
||||
update_meeting_display(filter_params);
|
||||
update_links(filter_params);
|
||||
update_calendar(filter_params);
|
||||
}
|
||||
|
||||
// Set up the filtering - the callback will be called when the page loads and on any filter changes
|
||||
agenda_filter.set_update_callback(update_view);
|
||||
agenda_filter.enable();
|
||||
|
||||
$(".modal").on("show.bs.modal", function () {
|
||||
var i = $(this).find(".frame");
|
||||
|
|
|
@ -153,7 +153,8 @@
|
|||
var item_area = (item.area || '').toLowerCase();
|
||||
var item_state = (item.state || '').toLowerCase();
|
||||
|
||||
if ((fp['hide_groups'].indexOf(item_group) >= 0) ||
|
||||
if ((fp['hide_groups'].indexOf(item_group) >= 0) ||
|
||||
(fp['hide_groups'].indexOf(item_area) >= 0) ||
|
||||
(fp['hide_types'].indexOf(item_type) >= 0)) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ django-tastypie>=0.14.2 # Django 2.1 will require 0.14.2; Django 3.0 wil
|
|||
django-webtest>=1.9.7
|
||||
django-widget-tweaks>=1.4.2
|
||||
docutils>=0.12,!=0.15
|
||||
factory-boy>=2.9.0
|
||||
factory-boy>=2.9.0,<3
|
||||
Faker>=0.8.8,!=0.8.9,!=0.8.10 # from factory-boy # Faker 0.8.9,0.8.10 sometimes return string names instead of unicode.
|
||||
hashids>=1.1.0
|
||||
html2text>=2019.8.11
|
||||
|
|
Loading…
Reference in a new issue