Multiselect user filters (#2090)

* Extract user filter generation code into method

* Extend make_user_cond to allow lists of user IDs

* Update documentation for stats APIs to indicate handling of ID lists

* Use multiselect dropdown for user filter on graphs page

Use standard concatenation

Fix select style

Move settings to JS constructor

Change text for no users checked

Don't call selectAll on page init

Add it back

Remove attributes

Fix emptiness check

Allow deselect all

Only refresh if user id changed

* Show "N users" starting at 2 users

Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

* Use helper function split_strip

Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

* Move make_user_cond at bottom and make private

* Add new user picker to history page

* Fix copy-paste error

* Again

* Add CSS for bootstrap-select

---------

Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
This commit is contained in:
Tom Niget 2023-07-08 02:15:16 +02:00 committed by GitHub
parent b144e6527f
commit 343a3e9281
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 125 additions and 82 deletions

File diff suppressed because one or more lines are too long

View file

@ -2914,7 +2914,7 @@ a .home-platforms-list-cover-face:hover
margin-bottom: -20px; margin-bottom: -20px;
width: 100%; width: 100%;
max-width: 1750px; max-width: 1750px;
overflow: hidden; display: flow-root;
} }
.table-card-back td { .table-card-back td {
font-size: 12px; font-size: 12px;

View file

@ -1,6 +1,7 @@
<%inherit file="base.html"/> <%inherit file="base.html"/>
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/bootstrap-select.min.css">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css"> <link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css"> <link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def> </%def>
@ -14,9 +15,7 @@
<div class="button-bar"> <div class="button-bar">
<div class="btn-group" id="user-selection"> <div class="btn-group" id="user-selection">
<label> <label>
<select name="graph-user" id="graph-user" class="btn" style="color: inherit;"> <select name="graph-user" id="graph-user" multiple>
<option value="">All Users</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
</select> </select>
</label> </label>
</div> </div>
@ -225,6 +224,7 @@
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
<script src="${http_root}js/bootstrap-select.min.js"></script>
<script src="${http_root}js/highcharts.min.js"></script> <script src="${http_root}js/highcharts.min.js"></script>
<script src="${http_root}js/jquery.dataTables.min.js"></script> <script src="${http_root}js/jquery.dataTables.min.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script> <script src="${http_root}js/dataTables.bootstrap.min.js"></script>
@ -373,14 +373,35 @@
type: 'get', type: 'get',
dataType: "json", dataType: "json",
success: function (data) { success: function (data) {
var select = $('#graph-user'); let select = $('#graph-user');
let by_id = {};
data.sort(function(a, b) { data.sort(function(a, b) {
return a.friendly_name.localeCompare(b.friendly_name); return a.friendly_name.localeCompare(b.friendly_name);
}); });
data.forEach(function(item) { data.forEach(function(item) {
select.append('<option value="' + item.user_id + '">' + select.append('<option value="' + item.user_id + '">' +
item.friendly_name + '</option>'); item.friendly_name + '</option>');
by_id[item.user_id] = item.friendly_name;
}); });
select.selectpicker({
countSelectedText: function(sel, total) {
if (sel === 0 || sel === total) {
return 'All users';
} else if (sel > 1) {
return sel + ' users';
} else {
return select.val().map(function(id) {
return by_id[id];
}).join(', ');
}
},
style: 'btn-dark',
actionsBox: true,
selectedTextFormat: 'count',
noneSelectedText: 'All users'
});
select.selectpicker('render');
select.selectpicker('selectAll');
} }
}); });
@ -602,11 +623,6 @@
$('#nav-tabs-total').tab('show'); $('#nav-tabs-total').tab('show');
} }
// Set initial state
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }
// Tab1 opened // Tab1 opened
$('#nav-tabs-plays').on('shown.bs.tab', function (e) { $('#nav-tabs-plays').on('shown.bs.tab', function (e) {
e.preventDefault(); e.preventDefault();
@ -652,9 +668,20 @@
$('.months').text(current_month_range); $('.months').text(current_month_range);
}); });
let graph_user_last_id = undefined;
// User changed // User changed
$('#graph-user').on('change', function() { $('#graph-user').on('change', function() {
selected_user_id = $(this).val() || null; let val = $(this).val();
if (val.length === 0 || val.length === $(this).children().length) {
selected_user_id = null; // if all users are selected, just send an empty list
} else {
selected_user_id = val.join(",");
}
if (selected_user_id === graph_user_last_id) {
return;
}
graph_user_last_id = selected_user_id;
if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); } if (current_tab === '#tabs-plays') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); } if (current_tab === '#tabs-stream') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); } if (current_tab === '#tabs-total') { loadGraphsTab3(current_month_range, yaxis); }

View file

@ -1,6 +1,7 @@
<%inherit file="base.html"/> <%inherit file="base.html"/>
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/bootstrap-select.min.css">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css"> <link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.min.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css"> <link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css"> <link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
@ -31,9 +32,7 @@
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
<div class="btn-group" id="user-selection"> <div class="btn-group" id="user-selection">
<label> <label>
<select name="history-user" id="history-user" class="btn" style="color: inherit;"> <select name="history-user" id="history-user" multiple>
<option value="">All Users</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
</select> </select>
</label> </label>
</div> </div>
@ -121,6 +120,7 @@
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
<script src="${http_root}js/bootstrap-select.min.js"></script>
<script src="${http_root}js/jquery.dataTables.min.js"></script> <script src="${http_root}js/jquery.dataTables.min.js"></script>
<script src="${http_root}js/dataTables.colVis.js"></script> <script src="${http_root}js/dataTables.colVis.js"></script>
<script src="${http_root}js/dataTables.bootstrap.min.js"></script> <script src="${http_root}js/dataTables.bootstrap.min.js"></script>
@ -134,17 +134,40 @@
type: 'GET', type: 'GET',
dataType: 'json', dataType: 'json',
success: function (data) { success: function (data) {
var select = $('#history-user'); let select = $('#history-user');
let by_id = {};
data.sort(function (a, b) { data.sort(function (a, b) {
return a.friendly_name.localeCompare(b.friendly_name); return a.friendly_name.localeCompare(b.friendly_name);
}); });
data.forEach(function (item) { data.forEach(function (item) {
select.append('<option value="' + item.user_id + '">' + select.append('<option value="' + item.user_id + '">' +
item.friendly_name + '</option>'); item.friendly_name + '</option>');
by_id[item.user_id] = item.friendly_name;
}); });
select.selectpicker({
countSelectedText: function(sel, total) {
if (sel === 0 || sel === total) {
return 'All users';
} else if (sel > 1) {
return sel + ' users';
} else {
return select.val().map(function(id) {
return by_id[id];
}).join(', ');
}
},
style: 'btn-dark',
actionsBox: true,
selectedTextFormat: 'count',
noneSelectedText: 'All users'
});
select.selectpicker('render');
select.selectpicker('selectAll');
} }
}); });
let history_user_last_id = undefined;
function loadHistoryTable(media_type, transcode_decision, selected_user_id) { function loadHistoryTable(media_type, transcode_decision, selected_user_id) {
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
@ -187,7 +210,16 @@
}); });
$('#history-user').on('change', function () { $('#history-user').on('change', function () {
selected_user_id = $(this).val() || null; let val = $(this).val();
if (val.length === 0 || val.length === $(this).children().length) {
selected_user_id = null; // if all users are selected, just send an empty list
} else {
selected_user_id = val.join(",");
}
if (selected_user_id === history_user_last_id) {
return;
}
history_user_last_id = selected_user_id;
history_table.draw(); history_table.draw();
}); });
} }

File diff suppressed because one or more lines are too long

View file

@ -51,11 +51,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -171,11 +167,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = "AND session_history.user_id = %s " % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = "AND session_history.user_id = %s " % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -308,11 +300,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -427,11 +415,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 12 time_range = helpers.cast_to_int(time_range) or 12
timestamp = arrow.get(helpers.timestamp()).shift(months=-time_range).floor('month').timestamp() timestamp = arrow.get(helpers.timestamp()).shift(months=-time_range).floor('month').timestamp()
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -554,11 +538,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -653,11 +633,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -763,11 +739,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -860,11 +832,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -941,11 +909,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -1048,11 +1012,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -1128,11 +1088,7 @@ class Graphs(object):
time_range = helpers.cast_to_int(time_range) or 30 time_range = helpers.cast_to_int(time_range) or 30
timestamp = helpers.timestamp() - time_range * 24 * 60 * 60 timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
user_cond = '' user_cond = self._make_user_cond(user_id)
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None: if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
@ -1212,3 +1168,16 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]} 'series': [series_1_output, series_2_output, series_3_output]}
return output return output
def _make_user_cond(self, user_id):
"""
Expects user_id to be a comma-separated list of ints.
"""
user_cond = ''
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id:
user_ids = helpers.split_strip(user_id)
if all(id.isdigit() for id in user_ids):
user_cond = 'AND session_history.user_id IN (%s) ' % ','.join(user_ids)
return user_cond

View file

@ -2258,7 +2258,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2302,7 +2302,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2346,7 +2346,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2390,7 +2390,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of months of data to return time_range (str): The number of months of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2434,7 +2434,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2478,7 +2478,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2522,7 +2522,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2565,7 +2565,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2608,7 +2608,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2651,7 +2651,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns:
@ -2694,7 +2694,7 @@ class WebInterface(object):
Optional parameters: Optional parameters:
time_range (str): The number of days of data to return time_range (str): The number of days of data to return
y_axis (str): "plays" or "duration" y_axis (str): "plays" or "duration"
user_id (str): The user id to filter the data user_id (str): Comma separated list of user id to filter the data
grouping (int): 0 or 1 grouping (int): 0 or 1
Returns: Returns: