Skip to content

Commit 7738319

Browse files
fix: render meeting schedule editor icons using bootstrap-icons (ietf-tools#3777)
Uses template tags to render `ConstraintName` instances as HTML. Fixes ietf-tools#3557.
1 parent b651821 commit 7738319

8 files changed

Lines changed: 148 additions & 39 deletions

File tree

ietf/meeting/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,12 @@ class Constraint(models.Model):
879879
- time_relation: preference for a time difference between sessions
880880
- wg_adjacent: request for source WG to be adjacent (directly before or after,
881881
no breaks, same room) the target WG
882-
882+
883+
In the schedule editor, run-time, a couple non-persistent ConstraintName instances
884+
are created for rendering purposes. This is done in
885+
meeting.utils.preprocess_constraints_for_meeting_schedule_editor(). This adds:
886+
- joint_with_groups
887+
- responsible_ad
883888
"""
884889
TIME_RELATION_CHOICES = (
885890
('subsequent-days', 'Schedule the sessions on subsequent days'),
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Copyright The IETF Trust 2022, All Rights Reserved
2+
# -*- coding: utf-8 -*-
3+
4+
"""Custom tags for the schedule editor"""
5+
import debug # pyflakes: ignore
6+
7+
from django import template
8+
from django.utils.html import format_html
9+
10+
register = template.Library()
11+
12+
13+
@register.simple_tag
14+
def constraint_icon_for(constraint_name, count=None):
15+
# icons must be valid HTML and kept up to date with tests.EditorTagTests.test_constraint_icon_for()
16+
icons = {
17+
'conflict': '<span class="encircled">{reversed}1</span>',
18+
'conflic2': '<span class="encircled">{reversed}2</span>',
19+
'conflic3': '<span class="encircled">{reversed}3</span>',
20+
'bethere': '<i class="bi bi-person"></i>{count}',
21+
'timerange': '<i class="bi bi-calendar"></i>',
22+
'time_relation': '&Delta;',
23+
'wg_adjacent': '{reversed}<i class="bi bi-skip-end"></i>',
24+
'chair_conflict': '{reversed}<i class="bi bi-person-circle"></i>',
25+
'tech_overlap': '{reversed}<i class="bi bi-link"></i>',
26+
'key_participant': '{reversed}<i class="bi bi-key"></i>',
27+
'joint_with_groups': '<i class="bi bi-merge"></i>',
28+
'responsible_ad': '<span class="encircled">AD</span>',
29+
}
30+
reversed_suffix = '-reversed'
31+
if constraint_name.slug.endswith(reversed_suffix):
32+
reversed = True
33+
cn = constraint_name.slug[: -len(reversed_suffix)]
34+
else:
35+
reversed = False
36+
cn = constraint_name.slug
37+
return format_html(
38+
icons[cn],
39+
count=count or '',
40+
reversed='-' if reversed else '',
41+
)

ietf/meeting/templatetags/tests.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# Copyright The IETF Trust 2009-2020, All Rights Reserved
22
# -*- coding: utf-8 -*-
3+
import debug # pyflakes: ignore
34

45
from django.template import Context, Template
6+
from pyquery import PyQuery
57

68
from ietf.meeting.factories import FloorPlanFactory, RoomFactory, TimeSlotFactory
79
from ietf.meeting.templatetags.agenda_custom_tags import AnchorNode
10+
from ietf.meeting.templatetags.editor_tags import constraint_icon_for
11+
from ietf.name.models import ConstraintName
812
from ietf.utils.test_utils import TestCase
913

1014

@@ -43,3 +47,80 @@ def test_location_anchor_node(self):
4347
f'<span><a href="{context["show_location"].location.floorplan_url()}">show_location</a></span>',
4448
result,
4549
)
50+
51+
52+
class EditorTagsTests(TestCase):
53+
def _supported_constraint_names(self):
54+
"""Get all ConstraintNames that must be supported by the tags"""
55+
constraint_names = set(ConstraintName.objects.filter(used=True))
56+
# Instantiate a couple that are added at run-time in meeting.utils
57+
constraint_names.add(ConstraintName(slug='joint_with_groups', name='joint with groups'))
58+
constraint_names.add(ConstraintName(slug='responsible_ad', name='AD'))
59+
# Reversed names are also added at run-time
60+
reversed = [
61+
ConstraintName(slug=f'{n.slug}-reversed', name=f'{n.name} - reversed')
62+
for n in constraint_names
63+
]
64+
constraint_names.update(reversed)
65+
return constraint_names
66+
67+
def test_constraint_icon_for_supports_all(self):
68+
"""constraint_icon_for tag should render all the necessary ConstraintNames
69+
70+
Relies on ConstraintNames in the names.json fixture being up-to-date
71+
"""
72+
for cn in self._supported_constraint_names():
73+
self.assertGreater(len(constraint_icon_for(cn)), 0)
74+
self.assertGreater(len(constraint_icon_for(cn, count=1)), 0)
75+
76+
def test_constraint_icon_for(self):
77+
"""Constraint icons should render as expected
78+
79+
This is the authoritative definition of what should be rendered for each constraint.
80+
Update this before changing the constraint_icon_for tag.
81+
"""
82+
test_cases = (
83+
# (ConstraintName slug, additional tag parameters, expected output HTML)
84+
('conflict', '', '<span class="encircled">1</span>'),
85+
('conflic2', '', '<span class="encircled">2</span>'),
86+
('conflic3', '', '<span class="encircled">3</span>'),
87+
('conflict-reversed', '', '<span class="encircled">-1</span>'),
88+
('conflic2-reversed', '', '<span class="encircled">-2</span>'),
89+
('conflic3-reversed', '', '<span class="encircled">-3</span>'),
90+
('bethere', '27', '<i class="bi bi-person"></i>27'),
91+
('timerange', '', '<i class="bi bi-calendar"></i>'),
92+
('time_relation', '', '\u0394'), # \u0394 is a capital Greek Delta
93+
('wg_adjacent', '', '<i class="bi bi-skip-end"></i>'),
94+
('wg_adjacent-reversed', '', '-<i class="bi bi-skip-end"></i>'),
95+
('chair_conflict', '', '<i class="bi bi-person-circle"></i>'),
96+
('chair_conflict-reversed', '', '-<i class="bi bi-person-circle"></i>'),
97+
('tech_overlap', '', '<i class="bi bi-link"></i>'),
98+
('tech_overlap-reversed', '', '-<i class="bi bi-link"></i>'),
99+
('key_participant', '', '<i class="bi bi-key"></i>'),
100+
('key_participant-reversed', '', '-<i class="bi bi-key"></i>'),
101+
('joint_with_groups', '', '<i class="bi bi-merge"></i>'),
102+
('responsible_ad', '', '<span class="encircled">AD</span>'),
103+
)
104+
# Create a template with a cn_i context variable for the ConstraintName in each test case.
105+
template = Template(
106+
'{% load editor_tags %}<html>' +
107+
''.join(
108+
f'<div id="test-case-{index}">{{% constraint_icon_for cn_{index} {params} %}}</div>'
109+
for index, (_, params, _) in enumerate(test_cases)
110+
) +
111+
'</html>'
112+
)
113+
# Construct the cn_i in the Context and render.
114+
result = template.render(
115+
Context({
116+
f'cn_{index}': ConstraintName(slug=slug)
117+
for index, (slug, _, _) in enumerate(test_cases)
118+
})
119+
)
120+
q = PyQuery(result)
121+
for index, (slug, params, expected) in enumerate(test_cases):
122+
self.assertHTMLEqual(
123+
q(f'#test-case-{index}').html(),
124+
expected,
125+
f'Unexpected output for {slug} {params}',
126+
)

ietf/meeting/tests_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3632,7 +3632,7 @@ def test_edit_meeting_schedule_conflict_types(self):
36323632

36333633
# Now enable the 'chair_conflict' constraint only
36343634
chair_conflict = ConstraintName.objects.get(slug='chair_conflict')
3635-
chair_conf_label = b'<i class="bi bi-person-plus"/>' # result of etree.tostring(etree.fromstring(editor_label))
3635+
chair_conf_label = b'<i class="bi bi-person-circle"/>' # result of etree.tostring(etree.fromstring(editor_label))
36363636
meeting.group_conflict_types.add(chair_conflict)
36373637
r = self.client.get(url)
36383638
q = PyQuery(r.content)

ietf/meeting/utils.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# -*- coding: utf-8 -*-
33
import datetime
44
import itertools
5-
import re
65
import requests
76
import subprocess
87

@@ -14,8 +13,6 @@
1413
from django.contrib import messages
1514
from django.template.loader import render_to_string
1615
from django.utils.encoding import smart_text
17-
from django.utils.html import format_html
18-
from django.utils.safestring import mark_safe
1916

2017
import debug # pyflakes:ignore
2118

@@ -291,14 +288,6 @@ def data_for_meetings_overview(meetings, interim_status=None):
291288

292289
return meetings
293290

294-
def reverse_editor_label(label):
295-
reverse_sign = "-"
296-
297-
m = re.match(r"(<[^>]+>)([^<].*)", label)
298-
if m:
299-
return m.groups()[0] + reverse_sign + m.groups()[1]
300-
else:
301-
return reverse_sign + label
302291

303292
def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
304293
# process constraint names - we synthesize extra names to be able
@@ -308,15 +297,13 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
308297
joint_with_groups_constraint_name = ConstraintName(
309298
slug='joint_with_groups',
310299
name="Joint session with",
311-
editor_label="<i class=\"bi bi-link\"></i>",
312300
order=8,
313301
)
314302
constraint_names[joint_with_groups_constraint_name.slug] = joint_with_groups_constraint_name
315303

316304
ad_constraint_name = ConstraintName(
317305
slug='responsible_ad',
318306
name="Responsible AD",
319-
editor_label="<span class=\"encircled\">AD</span>",
320307
order=9,
321308
)
322309
constraint_names[ad_constraint_name.slug] = ad_constraint_name
@@ -327,12 +314,8 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
327314
slug=n.slug + "-reversed",
328315
name="{} - reversed".format(n.name),
329316
)
330-
reverse_n.formatted_editor_label = mark_safe(reverse_editor_label(n.editor_label))
331317
constraint_names[reverse_n.slug] = reverse_n
332318

333-
n.formatted_editor_label = mark_safe(n.editor_label)
334-
n.countless_formatted_editor_label = format_html(n.formatted_editor_label, count="") if "{count}" in n.formatted_editor_label else n.formatted_editor_label
335-
336319
# convert human-readable rules in the database to constraints on actual sessions
337320
constraints = list(meeting.enabled_constraints().prefetch_related('target', 'person', 'timeranges'))
338321

ietf/meeting/views.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import tempfile
1717

1818
from calendar import timegm
19-
from collections import OrderedDict, Counter, deque, defaultdict
19+
from collections import OrderedDict, Counter, deque, defaultdict, namedtuple
2020
from urllib.parse import unquote
2121
from tempfile import mkstemp
2222
from wsgiref.handlers import format_date_time
@@ -39,7 +39,6 @@
3939
from django.utils.encoding import force_str
4040
from django.utils.functional import curry
4141
from django.utils.text import slugify
42-
from django.utils.html import format_html
4342
from django.utils.timezone import now
4443
from django.views.decorators.cache import cache_page
4544
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
@@ -558,20 +557,20 @@ def prepare_sessions_for_display(sessions):
558557
# shared between the conflicting sessions they cover - the JS
559558
# then simply has to detect violations and show the
560559
# preprocessed labels
561-
constrained_sessions_grouped_by_label = defaultdict(set)
562-
for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]):
560+
ConstraintHint = namedtuple('ConstraintHint', 'constraint_name count')
561+
constraint_hints = defaultdict(set)
562+
for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]): # name_id same for each set of ts
563563
ts = list(ts)
564564
session_pks = (t[1] for t in ts)
565-
constraint_name = constraint_names[name_id]
566-
if "{count}" in constraint_name.formatted_editor_label:
567-
for session_pk, grouped_session_pks in itertools.groupby(session_pks):
568-
count = sum(1 for i in grouped_session_pks)
569-
constrained_sessions_grouped_by_label[format_html(constraint_name.formatted_editor_label, count=count)].add(session_pk)
570-
571-
else:
572-
constrained_sessions_grouped_by_label[constraint_name.formatted_editor_label].update(session_pks)
573-
574-
s.constrained_sessions = list(constrained_sessions_grouped_by_label.items())
565+
for session_pk, grouped_session_pks in itertools.groupby(session_pks):
566+
# count is the number of instances of session_pk - should only have multiple in the
567+
# case of bethere constraints, where there will be one per person.pk
568+
count = len(list(grouped_session_pks)) # list() needed because iterator has no len()
569+
constraint_hints[ConstraintHint(constraint_names[name_id], count)].add(session_pk)
570+
571+
# The constraint hint key is a tuple (ConstraintName, count). Value is the set of sessions pks that
572+
# should trigger that hint.
573+
s.constraint_hints = list(constraint_hints.items())
575574
s.formatted_constraints = formatted_constraints_for_sessions.get(s.pk, {})
576575

577576
s.other_sessions = [s_other for s_other in sessions_for_group.get(s.group_id) if s != s_other]

ietf/static/js/edit-meeting-schedule.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ $(function () {
105105
let sessionInfoContainer = schedEditor.find(".scheduling-panel .session-info-container");
106106
sessionInfoContainer.html(jQuery(element).find(".session-info").html());
107107

108-
sessionInfoContainer.find("[data-original-title]").tooltip();
108+
sessionInfoContainer.find("[data-bs-original-title]").tooltip();
109109

110110
sessionInfoContainer.find(".time").text(jQuery(element).closest(".timeslot").data('scheduledatlabel'));
111111

ietf/templates/meeting/edit_meeting_schedule_session.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% load person_filters %}
1+
{% load person_filters editor_tags %}
22
<div id="session{{ session.pk }}"
33
class="session {% if not session.group.parent.scheduling_color %}untoggleable-by-parent{% endif %} {% if session.parent_acronym %}parent-{{ session.parent_acronym }}{% endif %} purpose-{{ session.purpose.slug }} {% if session.readonly %}readonly{% endif %} {% if not session.on_agenda %}off-agenda{% endif %}"
44
style="width:{{ session.layout_width }}em;"
@@ -13,10 +13,10 @@
1313
<span class="requested-duration">{{ session.requested_duration_in_hours|floatformat }}h</span>
1414
{% if session.attendees != None %}<span class="attendees">&middot; {{ session.attendees }}</span>{% endif %}
1515
{% if session.comments %}<span class="comments"><i class="bi bi-chat"></i></span>{% endif %}
16-
{% if session.constrained_sessions %}
16+
{% if session.constraint_hints %}
1717
<span class="constraints">
18-
{% for label, sessions in session.constrained_sessions %}
19-
<span data-sessions="{{ sessions|join:"," }}">{{ label }}</span>
18+
{% for hint, sessions in session.constraint_hints %}
19+
<span data-sessions="{{ sessions|join:"," }}">{% constraint_icon_for hint.constraint_name hint.count %}</span>
2020
{% endfor %}
2121
</span>
2222
{% endif %}
@@ -65,7 +65,7 @@
6565
<div class="formatted-constraints">
6666
{% for constraint_name, values in session.formatted_constraints.items %}
6767
<div>
68-
<span title="{{ constraint_name.name }}">{{ constraint_name.countless_formatted_editor_label }}</span>: {{ values|join:", " }}
68+
<span title="{{ constraint_name.name }}">{% constraint_icon_for constraint_name %}</span>: {{ values|join:", " }}
6969
</div>
7070
{% endfor %}
7171
</div>

0 commit comments

Comments
 (0)