Make constraint hints more obvious. Show constraints in the session
information panel. - Legacy-Id: 17971
This commit is contained in:
parent
d357723a54
commit
27384a1935
|
@ -189,7 +189,7 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase):
|
|||
s1_element = self.driver.find_element_by_css_selector('#session{}'.format(s1.pk))
|
||||
s1_element.click()
|
||||
|
||||
constraint_element = s2_element.find_element_by_css_selector(".constraints span[data-sessions=\"{}\"].selected-hint".format(s1.pk))
|
||||
constraint_element = s2_element.find_element_by_css_selector(".constraints span[data-sessions=\"{}\"].would-violate-hint".format(s1.pk))
|
||||
self.assertTrue(constraint_element.is_displayed())
|
||||
|
||||
# current constraint violations
|
||||
|
|
|
@ -3,20 +3,25 @@
|
|||
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
import re
|
||||
import requests
|
||||
|
||||
from collections import defaultdict
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django.db.models.expressions import Subquery, OuterRef
|
||||
from django.utils.html import format_html
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.dbtemplate.models import DBTemplate
|
||||
from ietf.meeting.models import Session, Meeting, SchedulingEvent, TimeSlot
|
||||
from ietf.meeting.models import Session, Meeting, SchedulingEvent, TimeSlot, Constraint
|
||||
from ietf.group.models import Group, Role
|
||||
from ietf.group.utils import can_manage_materials
|
||||
from ietf.name.models import SessionStatusName
|
||||
from ietf.name.models import SessionStatusName, ConstraintName
|
||||
from ietf.nomcom.utils import DISQUALIFYING_ROLE_QUERY_EXPRESSION
|
||||
from ietf.person.models import Email
|
||||
from ietf.secr.proceedings.proc_utils import import_audio_files
|
||||
|
@ -303,3 +308,91 @@ def data_for_meetings_overview(meetings, interim_status=None):
|
|||
m.interim_meeting_cancelled = m.type_id == 'interim' and all(s.current_status == 'canceled' for s in m.sessions)
|
||||
|
||||
return meetings
|
||||
|
||||
def format_constraint_editor_label(label, inner_fmt="{}"):
|
||||
m = re.match(r'(.*)\(person\)(.*)', label)
|
||||
if m:
|
||||
return format_html("{}<i class=\"fa fa-user-o\"></i>{}", format_html(inner_fmt, m.groups()[0]), m.groups()[1])
|
||||
|
||||
m = re.match(r"\(([^()]+)\)", label)
|
||||
if m:
|
||||
return format_html("<span class=\"encircled\">{}</span>", format_html(inner_fmt, m.groups()[0]))
|
||||
|
||||
return format_html(inner_fmt, label)
|
||||
|
||||
def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
|
||||
constraints = Constraint.objects.filter(meeting=meeting).prefetch_related('target', 'person', 'timeranges')
|
||||
|
||||
# process constraint names
|
||||
constraint_names = {n.pk: n for n in ConstraintName.objects.all()}
|
||||
|
||||
for n in list(constraint_names.values()):
|
||||
# add reversed version of the name
|
||||
reverse_n = ConstraintName(
|
||||
slug=n.slug + "-reversed",
|
||||
name="{} - reversed".format(n.name),
|
||||
)
|
||||
reverse_n.formatted_editor_label = format_constraint_editor_label(n.editor_label, inner_fmt="-{}")
|
||||
constraint_names[reverse_n.slug] = reverse_n
|
||||
|
||||
n.formatted_editor_label = format_constraint_editor_label(n.editor_label)
|
||||
n.countless_formatted_editor_label = format_html(n.formatted_editor_label, count="") if "{count}" in n.formatted_editor_label else n.formatted_editor_label
|
||||
|
||||
# convert human-readable rules in the database to constraints on actual sessions
|
||||
constraints_for_sessions = defaultdict(list)
|
||||
|
||||
person_needed_for_groups = defaultdict(set)
|
||||
for c in constraints:
|
||||
if c.name_id == 'bethere' and c.person_id is not None:
|
||||
person_needed_for_groups[c.person_id].add(c.source_id)
|
||||
|
||||
sessions_for_group = defaultdict(list)
|
||||
for s in sessions:
|
||||
if s.group_id is not None:
|
||||
sessions_for_group[s.group_id].append(s.pk)
|
||||
|
||||
def add_group_constraints(g1_pk, g2_pk, name_id, person_id):
|
||||
if g1_pk != g2_pk:
|
||||
for s1_pk in sessions_for_group.get(g1_pk, []):
|
||||
for s2_pk in sessions_for_group.get(g2_pk, []):
|
||||
if s1_pk != s2_pk:
|
||||
constraints_for_sessions[s1_pk].append((name_id, s2_pk, person_id))
|
||||
|
||||
reverse_constraints = []
|
||||
seen_forward_constraints_for_groups = set()
|
||||
|
||||
for c in constraints:
|
||||
if c.target_id:
|
||||
add_group_constraints(c.source_id, c.target_id, c.name_id, c.person_id)
|
||||
seen_forward_constraints_for_groups.add((c.source_id, c.target_id, c.name_id))
|
||||
reverse_constraints.append(c)
|
||||
|
||||
elif c.person_id:
|
||||
for g in person_needed_for_groups.get(c.person_id):
|
||||
add_group_constraints(c.source_id, g, c.name_id, c.person_id)
|
||||
|
||||
for c in reverse_constraints:
|
||||
# suppress reverse constraints in case we have a forward one already
|
||||
if (c.target_id, c.source_id, c.name_id) not in seen_forward_constraints_for_groups:
|
||||
add_group_constraints(c.target_id, c.source_id, c.name_id + "-reversed", c.person_id)
|
||||
|
||||
# formatted constraints
|
||||
def format_constraint(c):
|
||||
if c.name_id == "time_relation":
|
||||
return c.get_time_relation_display()
|
||||
elif c.name_id == "timerange":
|
||||
return ", ".join(t.desc for t in c.timeranges.all())
|
||||
elif c.person:
|
||||
return c.person.plain_name()
|
||||
elif c.target:
|
||||
return c.target.acronym
|
||||
else:
|
||||
return "UNKNOWN"
|
||||
|
||||
formatted_constraints_for_sessions = defaultdict(dict)
|
||||
for (group_pk, cn_pk), cs in itertools.groupby(sorted(constraints, key=lambda c: (c.source_id, constraint_names[c.name_id].order, c.name_id, c.pk)), key=lambda c: (c.source_id, c.name_id)):
|
||||
cs = list(cs)
|
||||
for s_pk in sessions_for_group.get(group_pk, []):
|
||||
formatted_constraints_for_sessions[s_pk][constraint_names[cn_pk]] = [format_constraint(c) for c in cs]
|
||||
|
||||
return constraints_for_sessions, formatted_constraints_for_sessions, constraint_names
|
||||
|
|
|
@ -55,7 +55,7 @@ from ietf.person.name import plain_name
|
|||
from ietf.ietfauth.utils import role_required, has_role
|
||||
from ietf.mailtrigger.utils import gather_address_lists
|
||||
from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
|
||||
from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Constraint, ConstraintName
|
||||
from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment
|
||||
from ietf.meeting.helpers import get_areas, get_person_by_email, get_schedule_by_name
|
||||
from ietf.meeting.helpers import build_all_agenda_slices, get_wg_name_list
|
||||
from ietf.meeting.helpers import get_all_assignments_from_schedule
|
||||
|
@ -77,6 +77,7 @@ from ietf.meeting.utils import session_time_for_sorting
|
|||
from ietf.meeting.utils import session_requested_by
|
||||
from ietf.meeting.utils import current_session_status
|
||||
from ietf.meeting.utils import data_for_meetings_overview
|
||||
from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor
|
||||
from ietf.message.utils import infer_message
|
||||
from ietf.secr.proceedings.utils import handle_upload_file
|
||||
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files,
|
||||
|
@ -623,70 +624,8 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
|
|||
# requesters
|
||||
requested_by_lookup = {p.pk: p for p in Person.objects.filter(pk__in=set(s.requested_by for s in sessions if s.requested_by))}
|
||||
|
||||
# constraints - convert the human-readable rules in the database
|
||||
# to constraints on the actual sessions, compress them and output
|
||||
# them, so that the JS simply has to detect violations and show
|
||||
# the relevant preprocessed label
|
||||
constraints = Constraint.objects.filter(meeting=meeting)
|
||||
person_needed_for_groups = defaultdict(set)
|
||||
for c in constraints:
|
||||
if c.name_id == 'bethere' and c.person_id is not None:
|
||||
person_needed_for_groups[c.person_id].add(c.source_id)
|
||||
|
||||
sessions_for_group = defaultdict(list)
|
||||
for s in sessions:
|
||||
if s.group_id is not None:
|
||||
sessions_for_group[s.group_id].append(s.pk)
|
||||
|
||||
constraint_names = {n.pk: n for n in ConstraintName.objects.all()}
|
||||
constraint_names_with_count = set()
|
||||
constraint_label_replacements = [
|
||||
(re.compile(r"\(person\)"), lambda match_groups: format_html("<i class=\"fa fa-user-o\"></i>")),
|
||||
(re.compile(r"\(([^()])\)"), lambda match_groups: format_html("<span class=\"encircled\">{}</span>", match_groups[0])),
|
||||
]
|
||||
for n in list(constraint_names.values()):
|
||||
# spiff up the labels a bit
|
||||
for pattern, replacer in constraint_label_replacements:
|
||||
m = pattern.match(n.editor_label)
|
||||
if m:
|
||||
n.editor_label = replacer(m.groups())
|
||||
|
||||
# add reversed version of the name
|
||||
reverse_n = ConstraintName(
|
||||
slug=n.slug + "-reversed",
|
||||
name="{} - reversed".format(n.name),
|
||||
)
|
||||
reverse_n.editor_label = format_html("<i>{}</i>", n.editor_label)
|
||||
constraint_names[reverse_n.slug] = reverse_n
|
||||
|
||||
constraints_for_sessions = defaultdict(list)
|
||||
|
||||
def add_group_constraints(g1_pk, g2_pk, name_id, person_id):
|
||||
if g1_pk != g2_pk:
|
||||
for s1_pk in sessions_for_group.get(g1_pk, []):
|
||||
for s2_pk in sessions_for_group.get(g2_pk, []):
|
||||
if s1_pk != s2_pk:
|
||||
constraints_for_sessions[s1_pk].append((name_id, s2_pk, person_id))
|
||||
|
||||
reverse_constraints = []
|
||||
seen_forward_constraints_for_groups = set()
|
||||
|
||||
for c in constraints:
|
||||
if c.target_id:
|
||||
add_group_constraints(c.source_id, c.target_id, c.name_id, c.person_id)
|
||||
seen_forward_constraints_for_groups.add((c.source_id, c.target_id, c.name_id))
|
||||
reverse_constraints.append(c)
|
||||
|
||||
elif c.person_id:
|
||||
constraint_names_with_count.add(c.name_id)
|
||||
|
||||
for g in person_needed_for_groups.get(c.person_id):
|
||||
add_group_constraints(c.source_id, g, c.name_id, c.person_id)
|
||||
|
||||
for c in reverse_constraints:
|
||||
# suppress reverse constraints in case we have a forward one already
|
||||
if (c.target_id, c.source_id, c.name_id) not in seen_forward_constraints_for_groups:
|
||||
add_group_constraints(c.target_id, c.source_id, c.name_id + "-reversed", c.person_id)
|
||||
# constraints
|
||||
constraints_for_sessions, formatted_constraints_for_sessions, constraint_names = preprocess_constraints_for_meeting_schedule_editor(meeting, sessions)
|
||||
|
||||
unassigned_sessions = []
|
||||
for s in sessions:
|
||||
|
@ -705,22 +644,25 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
|
|||
s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else ""
|
||||
s.historic_group_ad_name = ad_names.get(s.group_id)
|
||||
|
||||
# compress the constraints, so similar constraint explanations
|
||||
# are shared between the conflicting sessions they cover
|
||||
constrained_sessions_grouped_by_explanation = defaultdict(set)
|
||||
# compress the constraints, so similar constraint labels are
|
||||
# shared between the conflicting sessions they cover - the JS
|
||||
# then simply has to detect violations and show the
|
||||
# preprocessed labels
|
||||
constrained_sessions_grouped_by_label = defaultdict(set)
|
||||
for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]):
|
||||
ts = list(ts)
|
||||
session_pks = (t[1] for t in ts)
|
||||
constraint_name = constraint_names[name_id]
|
||||
if name_id in constraint_names_with_count:
|
||||
if "{count}" in constraint_name.formatted_editor_label:
|
||||
for session_pk, grouped_session_pks in itertools.groupby(session_pks):
|
||||
count = sum(1 for i in grouped_session_pks)
|
||||
constrained_sessions_grouped_by_explanation[format_html("{}{}", constraint_name.editor_label, count)].add(session_pk)
|
||||
constrained_sessions_grouped_by_label[format_html(constraint_name.formatted_editor_label, count=count)].add(session_pk)
|
||||
|
||||
else:
|
||||
constrained_sessions_grouped_by_explanation[constraint_name.editor_label].update(session_pks)
|
||||
constrained_sessions_grouped_by_label[constraint_name.formatted_editor_label].update(session_pks)
|
||||
|
||||
s.constrained_sessions = list(constrained_sessions_grouped_by_explanation.items())
|
||||
s.constrained_sessions = list(constrained_sessions_grouped_by_label.items())
|
||||
s.formatted_constraints = formatted_constraints_for_sessions.get(s.pk, {})
|
||||
|
||||
assigned = False
|
||||
for a in assignments_by_session.get(s.pk, []):
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
# Copyright The IETF Trust 2020, All Rights Reserved
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('name', '0011_constraintname_editor_label'),
|
||||
]
|
||||
|
||||
def update_editor_labels(apps, schema_editor):
|
||||
ConstraintName = apps.get_model('name', 'ConstraintName')
|
||||
for cn in ConstraintName.objects.all():
|
||||
cn.editor_label = {
|
||||
'bethere': "(person){count}",
|
||||
}.get(cn.slug, cn.editor_label)
|
||||
|
||||
cn.order = {
|
||||
'conflict': 1,
|
||||
'conflic2': 2,
|
||||
'conflic3': 3,
|
||||
'bethere': 4,
|
||||
'timerange': 5,
|
||||
'time_relation': 6,
|
||||
'wg_adjacent': 7,
|
||||
}.get(cn.slug, cn.order)
|
||||
|
||||
cn.save()
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_editor_labels, noop, elidable=True),
|
||||
]
|
|
@ -1096,6 +1096,24 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
|
|||
border-right: 2px dashed #fff; /* cut-off illusion */
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .timeslot.would-violate-hint {
|
||||
background-color: #ffe0e0;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .constraints .encircled,
|
||||
.edit-meeting-schedule .formatted-constraints .encircled {
|
||||
border: 1px solid #000;
|
||||
border-radius: 1em;
|
||||
min-width: 1.3em;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .formatted-constraints .encircled {
|
||||
font-size: smaller;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* sessions */
|
||||
.edit-meeting-schedule .session {
|
||||
background-color: #fff;
|
||||
|
@ -1108,6 +1126,14 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session.selected {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .read-only .session.selected {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session.selected .session-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -1132,7 +1158,8 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
|
|||
}
|
||||
|
||||
.edit-meeting-schedule .session.too-many-attendees .attendees {
|
||||
color: #f33;
|
||||
font-weight: bold;
|
||||
color: #8432d4;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints {
|
||||
|
@ -1147,25 +1174,22 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
|
|||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span .encircled {
|
||||
border: 1px solid #f99;
|
||||
border-radius: 1em;
|
||||
min-width: 1.3em;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
border: 1px solid #b35eff;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span.violated-hint {
|
||||
display: inline-block;
|
||||
color: #f55;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span.selected-hint {
|
||||
display: inline-block;
|
||||
color: #8432d4;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span.selected-hint .encircled {
|
||||
border: 1px solid #b35eff;
|
||||
.edit-meeting-schedule .session .constraints > span.would-violate-hint {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: #f55;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span.would-violate-hint .encircled {
|
||||
border: 1px solid #f99;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .unassigned-sessions .session .constraints > span {
|
||||
|
|
|
@ -17,13 +17,33 @@ jQuery(document).ready(function () {
|
|||
content.css("padding-bottom", "14em");
|
||||
}
|
||||
|
||||
function findTimeslotsOverlapping(intervals) {
|
||||
let res = [];
|
||||
|
||||
timeslots.each(function () {
|
||||
var timeslot = jQuery(this);
|
||||
let start = timeslot.data("start");
|
||||
let end = timeslot.data("end");
|
||||
|
||||
for (let i = 0; i < intervals.length; ++i) {
|
||||
if (end >= intervals[i][0] && intervals[i][1] >= start) {
|
||||
res.push(timeslot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
// selecting
|
||||
function selectSessionElement(element) {
|
||||
if (element) {
|
||||
sessions.not(element).removeClass("selected");
|
||||
jQuery(element).addClass("selected");
|
||||
showConstraintHints(element.id.slice("session".length));
|
||||
content.find(".scheduling-panel .session-info-container").html(jQuery(element).find(".session-info").html());
|
||||
content.find(".scheduling-panel .session-info-container").html(jQuery(element).find(".session-info").html()).find('[data-original-title]').tooltip();
|
||||
}
|
||||
else {
|
||||
sessions.removeClass("selected");
|
||||
|
@ -33,16 +53,32 @@ jQuery(document).ready(function () {
|
|||
}
|
||||
|
||||
function showConstraintHints(sessionIdStr) {
|
||||
let intervals = [];
|
||||
|
||||
sessions.find(".constraints > span").each(function () {
|
||||
if (!sessionIdStr) {
|
||||
jQuery(this).removeClass("selected-hint");
|
||||
jQuery(this).removeClass("would-violate-hint");
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionIds = this.dataset.sessions;
|
||||
if (sessionIds)
|
||||
jQuery(this).toggleClass("selected-hint", sessionIds.split(",").indexOf(sessionIdStr) != -1);
|
||||
if (!sessionIds)
|
||||
return;
|
||||
|
||||
let wouldViolate = sessionIds.split(",").indexOf(sessionIdStr) != -1;
|
||||
jQuery(this).toggleClass("would-violate-hint", wouldViolate);
|
||||
|
||||
if (wouldViolate) {
|
||||
let timeslot = jQuery(this).closest(".timeslot");
|
||||
if (timeslot.length > 0)
|
||||
intervals.push([timeslot.data("start"), timeslot.data("end")]);
|
||||
}
|
||||
});
|
||||
|
||||
timeslots.removeClass("would-violate-hint");
|
||||
let overlappingTimeslots = findTimeslotsOverlapping(intervals);
|
||||
for (let i = 0; i < overlappingTimeslots.length; ++i)
|
||||
overlappingTimeslots[i].addClass("would-violate-hint");
|
||||
}
|
||||
|
||||
content.on("click", function (event) {
|
||||
|
@ -153,7 +189,7 @@ jQuery(document).ready(function () {
|
|||
|
||||
// hints for the current schedule
|
||||
|
||||
function updateCurrentSessionConstraintViolations() {
|
||||
function updateSessionConstraintViolations() {
|
||||
// do a sweep on sessions sorted by start time
|
||||
let scheduledSessions = [];
|
||||
|
||||
|
@ -233,7 +269,7 @@ jQuery(document).ready(function () {
|
|||
}
|
||||
|
||||
function updateCurrentSchedulingHints() {
|
||||
updateCurrentSessionConstraintViolations();
|
||||
updateSessionConstraintViolations();
|
||||
updateAttendeesViolations();
|
||||
updateTimeSlotDurationViolations();
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="edit-grid">
|
||||
<div class="edit-grid {% if not can_edit %}read-only{% endif %}">
|
||||
|
||||
{# using the same markup in both room labels and the actual days ensures they are aligned #}
|
||||
<div class="room-label-column">
|
||||
|
|
|
@ -14,14 +14,14 @@
|
|||
|
||||
{% if session.constrained_sessions %}
|
||||
<span class="constraints">
|
||||
{% for explanation, sessions in session.constrained_sessions %}
|
||||
<span data-sessions="{{ sessions|join:"," }}">{{ explanation }}</span>
|
||||
{% for label, sessions in session.constrained_sessions %}
|
||||
<span data-sessions="{{ sessions|join:"," }}">{{ label }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# this is shown elsewhere on the page with JS - we just include it here for convenience #}
|
||||
{# the JS uses this to display session information in the bottom panel #}
|
||||
<div class="session-info">
|
||||
<label>
|
||||
{{ session.scheduling_label }}
|
||||
|
@ -60,5 +60,15 @@
|
|||
{{ session.comments|linebreaksbr }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.formatted_constraints %}
|
||||
<div class="formatted-constraints">
|
||||
{% for constraint_name, values in session.formatted_constraints.items %}
|
||||
<div>
|
||||
<span title="{{ constraint_name.name }}">{{ constraint_name.countless_formatted_editor_label }}</span>: {{ values|join:", " }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue