mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
Merge branch 'inventree:master' into matmair/issue2694
This commit is contained in:
25
InvenTree/templates/InvenTree/notifications/history.html
Normal file
25
InvenTree/templates/InvenTree/notifications/history.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends "panel.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block label %}history{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Notification History" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
<div class='btn btn-secondary' type='button' id='history-refresh' title='{% trans "Refresh Notification History" %}'>
|
||||
<span class='fa fa-sync'></span> {% trans "Refresh Notification History" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<table class='table table-striped table-condensed' id='history-table'>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
28
InvenTree/templates/InvenTree/notifications/inbox.html
Normal file
28
InvenTree/templates/InvenTree/notifications/inbox.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends "panel.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block label %}inbox{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Pending Notifications" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
<div class='btn btn-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
|
||||
<span class='fa fa-bookmark'></span> {% trans "Mark all as read" %}
|
||||
</div>
|
||||
<div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'>
|
||||
<span class='fa fa-sync'></span> {% trans "Refresh Pending Notifications" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<table class='table table-striped table-condensed' id='inbox-table'>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
154
InvenTree/templates/InvenTree/notifications/notifications.html
Normal file
154
InvenTree/templates/InvenTree/notifications/notifications.html
Normal file
@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block breadcrumb_list %}
|
||||
{% endblock %}
|
||||
|
||||
{% block page_title %}
|
||||
{% inventree_title %} | {% trans "Notifications" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "InvenTree/notifications/sidebar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "InvenTree/notifications/inbox.html" %}
|
||||
{% include "InvenTree/notifications/history.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
function updateNotificationTables() {
|
||||
$("#inbox-table").bootstrapTable('refresh');
|
||||
$("#history-table").bootstrapTable('refresh');
|
||||
}
|
||||
// this allows the global notification panel to update the tables
|
||||
window.updateNotifications = updateNotificationTables
|
||||
|
||||
function loadNotificationTable(table, options={}, enableDelete=false) {
|
||||
|
||||
var params = options.params || {};
|
||||
var read = typeof(params.read) === 'undefined' ? true : params.read;
|
||||
|
||||
$(table).inventreeTable({
|
||||
url: options.url,
|
||||
name: options.name,
|
||||
groupBy: false,
|
||||
search: true,
|
||||
queryParams: {
|
||||
ordering: 'age',
|
||||
read: read,
|
||||
},
|
||||
paginationVAlign: 'bottom',
|
||||
formatNoMatches: options.no_matches,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: '{% trans "ID" %}',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'age',
|
||||
title: '{% trans "Age" %}',
|
||||
sortable: 'true',
|
||||
formatter: function(value, row) {
|
||||
return row.age_human
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
title: '{% trans "Category" %}',
|
||||
sortable: 'true',
|
||||
},
|
||||
{
|
||||
field: 'target',
|
||||
title: '{% trans "Item" %}',
|
||||
sortable: 'true',
|
||||
formatter: function(value, row, index, field) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var html = `${value.model}: ${value.name}`;
|
||||
if (value.link ) {html = `<a href='${value.link}'>${html}</a>`;}
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '{% trans "Name" %}',
|
||||
},
|
||||
{
|
||||
field: 'message',
|
||||
title: '{% trans "Message" %}',
|
||||
},
|
||||
{
|
||||
formatter: function(value, row, index, field) {
|
||||
var bRead = getReadEditButton(row.pk, row.read)
|
||||
if (enableDelete) {
|
||||
var bDel = "<button title='{% trans "Delete Notification" %}' class='notification-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
|
||||
} else {
|
||||
var bDel = '';
|
||||
}
|
||||
var html = "<div class='btn-group float-right' role='group'>" + bRead + bDel + "</div>";
|
||||
return html;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
$(table).on('click', '.notification-read', function() {
|
||||
updateNotificationReadState($(this));
|
||||
});
|
||||
}
|
||||
|
||||
loadNotificationTable("#inbox-table", {
|
||||
name: 'inbox',
|
||||
url: '{% url 'api-notifications-list' %}',
|
||||
params: {
|
||||
read: false,
|
||||
},
|
||||
no_matches: function() { return '{% trans "No unread notifications found" %}'; },
|
||||
});
|
||||
|
||||
$("#inbox-refresh").on('click', function() {
|
||||
$("#inbox-table").bootstrapTable('refresh');
|
||||
});
|
||||
|
||||
$("#mark-all").on('click', function() {
|
||||
inventreeGet(
|
||||
'{% url "api-notifications-readall" %}',
|
||||
{
|
||||
read: false,
|
||||
},
|
||||
);
|
||||
updateNotificationTables();
|
||||
});
|
||||
|
||||
loadNotificationTable("#history-table", {
|
||||
name: 'history',
|
||||
url: '{% url 'api-notifications-list' %}',
|
||||
no_matches: function() { return '{% trans "No notification history found" %}'; },
|
||||
}, true);
|
||||
|
||||
$("#history-refresh").on('click', function() {
|
||||
$("#history-table").bootstrapTable('refresh');
|
||||
});
|
||||
|
||||
$("#history-table").on('click', '.notification-delete', function() {
|
||||
constructForm(`/api/notifications/${$(this).attr('pk')}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Notification" %}',
|
||||
onSuccess: function(data) {
|
||||
updateNotificationTables();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
enableSidebar('notifications');
|
||||
{% endblock %}
|
11
InvenTree/templates/InvenTree/notifications/sidebar.html
Normal file
11
InvenTree/templates/InvenTree/notifications/sidebar.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% trans "Notifications" as text %}
|
||||
{% include "sidebar_header.html" with text=text icon='fa-bell' %}
|
||||
|
||||
{% trans "Inbox" as text %}
|
||||
{% include "sidebar_item.html" with label='inbox' text=text icon="fa-envelope" %}
|
||||
{% trans "History" as text %}
|
||||
{% include "sidebar_item.html" with label='history' text=text icon="fa-clock" %}
|
@ -91,7 +91,7 @@
|
||||
|
||||
<!-- general InvenTree -->
|
||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
|
||||
|
||||
<!-- fontawesome -->
|
||||
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
||||
|
@ -128,6 +128,7 @@
|
||||
</div>
|
||||
{% include 'modals.html' %}
|
||||
{% include 'about.html' %}
|
||||
{% include "notifications.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
@ -161,7 +162,6 @@
|
||||
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
|
||||
|
||||
<!-- general InvenTree -->
|
||||
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
||||
|
||||
<!-- dynamic javascript templates -->
|
||||
@ -189,8 +189,10 @@
|
||||
<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
|
||||
|
||||
<script type='text/javascript' src="{% static 'fontawesome/js/solid.min.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'fontawesome/js/regular.min.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'fontawesome/js/brands.min.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.min.js' %}"></script>
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block title %}
|
||||
{% blocktrans with part=part.name %} The available stock for {{ part }} has fallen below the configured minimum level{% endblocktrans %}
|
||||
{{ message }}
|
||||
{% if link %}
|
||||
<p>{% trans "Click on the following link to view this part" %}: <a href="{{ link }}">{{ link }}</a></p>
|
||||
{% endif %}
|
||||
|
313
InvenTree/templates/js/translated/notification.js
Normal file
313
InvenTree/templates/js/translated/notification.js
Normal file
@ -0,0 +1,313 @@
|
||||
{% load i18n %}
|
||||
|
||||
/* exported
|
||||
showAlertOrCache,
|
||||
showCachedAlerts,
|
||||
startNotificationWatcher,
|
||||
stopNotificationWatcher,
|
||||
openNotificationPanel,
|
||||
closeNotificationPanel,
|
||||
*/
|
||||
|
||||
/*
|
||||
* Add a cached alert message to sesion storage
|
||||
*/
|
||||
function addCachedAlert(message, options={}) {
|
||||
|
||||
var alerts = sessionStorage.getItem('inventree-alerts');
|
||||
|
||||
if (alerts) {
|
||||
alerts = JSON.parse(alerts);
|
||||
} else {
|
||||
alerts = [];
|
||||
}
|
||||
|
||||
alerts.push({
|
||||
message: message,
|
||||
style: options.style || 'success',
|
||||
icon: options.icon,
|
||||
});
|
||||
|
||||
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Remove all cached alert messages
|
||||
*/
|
||||
function clearCachedAlerts() {
|
||||
sessionStorage.removeItem('inventree-alerts');
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Display an alert, or cache to display on reload
|
||||
*/
|
||||
function showAlertOrCache(message, cache, options={}) {
|
||||
|
||||
if (cache) {
|
||||
addCachedAlert(message, options);
|
||||
} else {
|
||||
showMessage(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Display cached alert messages when loading a page
|
||||
*/
|
||||
function showCachedAlerts() {
|
||||
|
||||
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
|
||||
|
||||
alerts.forEach(function(alert) {
|
||||
showMessage(
|
||||
alert.message,
|
||||
{
|
||||
style: alert.style || 'success',
|
||||
icon: alert.icon,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
clearCachedAlerts();
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Display an alert message at the top of the screen.
|
||||
* The message will contain a "close" button,
|
||||
* and also dismiss automatically after a certain amount of time.
|
||||
*
|
||||
* arguments:
|
||||
* - message: Text / HTML content to display
|
||||
*
|
||||
* options:
|
||||
* - style: alert style e.g. 'success' / 'warning'
|
||||
* - timeout: Time (in milliseconds) after which the message will be dismissed
|
||||
*/
|
||||
function showMessage(message, options={}) {
|
||||
|
||||
var style = options.style || 'info';
|
||||
|
||||
var timeout = options.timeout || 5000;
|
||||
|
||||
var target = options.target || $('#alerts');
|
||||
|
||||
var details = '';
|
||||
|
||||
if (options.details) {
|
||||
details = `<p><small>${options.details}</p></small>`;
|
||||
}
|
||||
|
||||
// Hacky function to get the next available ID
|
||||
var id = 1;
|
||||
|
||||
while ($(`#alert-${id}`).exists()) {
|
||||
id++;
|
||||
}
|
||||
|
||||
var icon = '';
|
||||
|
||||
if (options.icon) {
|
||||
icon = `<span class='${options.icon}'></span>`;
|
||||
}
|
||||
|
||||
// Construct the alert
|
||||
var html = `
|
||||
<div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
|
||||
${icon}
|
||||
<b>${message}</b>
|
||||
${details}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
target.append(html);
|
||||
|
||||
// Remove the alert automatically after a specified period of time
|
||||
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {
|
||||
$(this).alert(close);
|
||||
});
|
||||
}
|
||||
|
||||
var notificationWatcher = null; // reference for the notificationWatcher
|
||||
/**
|
||||
* start the regular notification checks
|
||||
**/
|
||||
function startNotificationWatcher() {
|
||||
notificationCheck(force=true);
|
||||
notificationWatcher = setInterval(notificationCheck, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* stop the regular notification checks
|
||||
**/
|
||||
function stopNotificationWatcher() {
|
||||
clearInterval(notificationWatcher);
|
||||
}
|
||||
|
||||
|
||||
var notificationUpdateTic = 0;
|
||||
/**
|
||||
* The notification checker is initiated when the document is loaded. It checks if there are unread notifications
|
||||
* if unread messages exist the notification indicator is updated
|
||||
*
|
||||
* options:
|
||||
* - force: set true to force an update now (if you got in focus for example)
|
||||
**/
|
||||
function notificationCheck(force = false) {
|
||||
notificationUpdateTic = notificationUpdateTic + 1;
|
||||
|
||||
// refresh if forced or
|
||||
// if in focus and was not refreshed in the last 5 seconds
|
||||
if (force || (document.hasFocus() && notificationUpdateTic >= 5)) {
|
||||
notificationUpdateTic = 0;
|
||||
inventreeGet(
|
||||
'/api/notifications/',
|
||||
{
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
updateNotificationIndicator(response.length);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* handles read / unread buttons and UI rebuilding
|
||||
*
|
||||
* arguments:
|
||||
* - btn: element that got clicked / fired the event -> must contain pk and target as attributes
|
||||
*
|
||||
* options:
|
||||
* - panel_caller: this button was clicked in the notification panel
|
||||
**/
|
||||
function updateNotificationReadState(btn, panel_caller=false) {
|
||||
var url = `/api/notifications/${btn.attr('pk')}/${btn.attr('target')}/`;
|
||||
|
||||
inventreePut(url, {}, {
|
||||
method: 'POST',
|
||||
success: function() {
|
||||
// update the notification tables if they were declared
|
||||
if (window.updateNotifications) {
|
||||
window.updateNotifications();
|
||||
}
|
||||
|
||||
// update current notification count
|
||||
var count = parseInt($('#notification-counter').html());
|
||||
if (btn.attr('target') == 'read') {
|
||||
count = count - 1;
|
||||
} else {
|
||||
count = count + 1;
|
||||
}
|
||||
// update notification indicator now
|
||||
updateNotificationIndicator(count);
|
||||
|
||||
// remove notification if called from notification panel
|
||||
if (panel_caller) {
|
||||
btn.parent().parent().remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the html for a read / unread button
|
||||
*
|
||||
* arguments:
|
||||
* - pk: primary key of the notification
|
||||
* - state: current state of the notification (read / unread) -> just pass what you were handed by the api
|
||||
* - small: should the button be small
|
||||
**/
|
||||
function getReadEditButton(pk, state, small=false) {
|
||||
if (state) {
|
||||
var bReadText = '{% trans "Mark as unread" %}';
|
||||
var bReadIcon = 'fas fa-bookmark icon-red';
|
||||
var bReadTarget = 'unread';
|
||||
} else {
|
||||
var bReadText = '{% trans "Mark as read" %}';
|
||||
var bReadIcon = 'far fa-bookmark icon-green';
|
||||
var bReadTarget = 'read';
|
||||
}
|
||||
|
||||
var style = (small) ? 'btn-sm ' : '';
|
||||
return `<button title='${bReadText}' class='notification-read btn ${style}btn-outline-secondary' type='button' pk='${pk}' target='${bReadTarget}'><span class='${bReadIcon}'></span></button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* fills the notification panel when opened
|
||||
**/
|
||||
function openNotificationPanel() {
|
||||
var html = '';
|
||||
var center_ref = '#notification-center';
|
||||
|
||||
inventreeGet(
|
||||
'/api/notifications/',
|
||||
{
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
if (response.length == 0) {
|
||||
html = `<p class='text-muted'>{% trans "No unread notifications" %}</p>`;
|
||||
} else {
|
||||
// build up items
|
||||
response.forEach(function(item, index) {
|
||||
html += '<li class="list-group-item">';
|
||||
// d-flex justify-content-between align-items-start
|
||||
html += '<div>';
|
||||
html += `<span class="badge rounded-pill bg-primary">${item.category}</span><span class="ms-2">${item.name}</span>`;
|
||||
html += '</div>';
|
||||
if (item.target) {
|
||||
var link_text = `${item.target.model}: ${item.target.name}`;
|
||||
if (item.target.link) {
|
||||
link_text = `<a href='${item.target.link}'>${link_text}</a>`;
|
||||
}
|
||||
html += link_text;
|
||||
}
|
||||
html += '<div>';
|
||||
html += `<span class="text-muted">${item.age_human}</span>`;
|
||||
html += getReadEditButton(item.pk, item.read, true);
|
||||
html += '</div></li>';
|
||||
});
|
||||
|
||||
// package up
|
||||
html = `<ul class="list-group">${html}</ul>`;
|
||||
}
|
||||
|
||||
// set html
|
||||
$(center_ref).html(html);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$(center_ref).on('click', '.notification-read', function() {
|
||||
updateNotificationReadState($(this), true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* clears the notification panel when closed
|
||||
**/
|
||||
function closeNotificationPanel() {
|
||||
$('#notification-center').html(`<p class='text-muted'>{% trans "Notifications will load here" %}</p>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the notification counter
|
||||
**/
|
||||
function updateNotificationIndicator(count) {
|
||||
// reset update Ticker -> safe some API bandwidth
|
||||
notificationUpdateTic = 0;
|
||||
|
||||
if (count == 0) {
|
||||
$('#notification-alert').addClass('d-none');
|
||||
} else {
|
||||
$('#notification-alert').removeClass('d-none');
|
||||
}
|
||||
$('#notification-counter').html(count);
|
||||
}
|
@ -96,7 +96,18 @@
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class='nav-item' id='navbar-barcode-li'>
|
||||
|
||||
<li class='nav-item me-2'>
|
||||
<button data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" class='btn position-relative' title='{% trans "Show Notifications" %}'>
|
||||
<span class='fas fa-bell'></span>
|
||||
<span class="position-absolute top-100 start-100 translate-middle badge rounded-pill bg-danger d-none" id="notification-alert">
|
||||
<span class="visually-hidden">{% trans "New Notifications" %}</span>
|
||||
<span id="notification-counter">0</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li class='nav-item me-2'>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-objects" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
14
InvenTree/templates/notifications.html
Normal file
14
InvenTree/templates/notifications.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% load i18n %}
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" data-bs-scroll="true" aria-labelledby="offcanvasRightLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 id="offcanvasRightLabel">{% trans "Notifications" %}</h5>
|
||||
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<div id="notification-center">
|
||||
<p class='text-muted'>{% trans "Notifications will load here" %}</p>
|
||||
</div>
|
||||
<hr>
|
||||
<a href="{% url 'notifications' %}">{% trans "Show all notifications and history" %}</a>
|
||||
</div>
|
||||
</div>
|
@ -77,7 +77,7 @@
|
||||
|
||||
<!-- general InvenTree -->
|
||||
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
|
||||
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
|
||||
{% block body_scripts_inventree %}
|
||||
{% endblock %}
|
||||
|
||||
|
Reference in New Issue
Block a user