Add support for sending new messages.
This commit is contained in:
parent
9d802d8f25
commit
687b83e2e1
69
tailbone/static/css/jquery.tagit.css
Normal file
69
tailbone/static/css/jquery.tagit.css
Normal file
|
@ -0,0 +1,69 @@
|
|||
ul.tagit {
|
||||
padding: 1px 5px;
|
||||
overflow: auto;
|
||||
margin-left: inherit; /* usually we don't want the regular ul margins. */
|
||||
margin-right: inherit;
|
||||
}
|
||||
ul.tagit li {
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 2px 5px 2px 0;
|
||||
}
|
||||
ul.tagit li.tagit-choice {
|
||||
position: relative;
|
||||
line-height: inherit;
|
||||
}
|
||||
input.tagit-hidden-field {
|
||||
display: none;
|
||||
}
|
||||
ul.tagit li.tagit-choice-read-only {
|
||||
padding: .2em .5em .2em .5em;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-choice-editable {
|
||||
padding: .2em 18px .2em .5em;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-new {
|
||||
padding: .25em 4px .25em 0;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-choice a.tagit-label {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.tagit li.tagit-choice .tagit-close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: .1em;
|
||||
top: 50%;
|
||||
margin-top: -8px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
/* used for some custom themes that don't need image icons */
|
||||
ul.tagit li.tagit-choice .tagit-close .text-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-choice input {
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 2px 5px 2px 0;
|
||||
}
|
||||
ul.tagit input[type="text"] {
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
|
||||
-moz-box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: inherit;
|
||||
background-color: inherit;
|
||||
outline: none;
|
||||
}
|
17
tailbone/static/js/lib/tag-it.min.js
vendored
Normal file
17
tailbone/static/js/lib/tag-it.min.js
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
|
||||
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
|
||||
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
|
||||
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
|
||||
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
|
||||
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
|
||||
b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
|
||||
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
|
||||
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
|
||||
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
|
||||
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
|
||||
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
|
||||
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
|
||||
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
|
||||
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
|
||||
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
|
||||
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);
|
59
tailbone/templates/messages/create.mako
Normal file
59
tailbone/templates/messages/create.mako
Normal file
|
@ -0,0 +1,59 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/master/create.mako" />
|
||||
|
||||
<%def name="head_tags()">
|
||||
${parent.head_tags()}
|
||||
${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))}
|
||||
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))}
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
|
||||
var recipients = $('.recipients ul');
|
||||
|
||||
recipients.tagit({
|
||||
fieldName: 'Message--recipients',
|
||||
autocomplete: {
|
||||
delay: 0,
|
||||
minLength: 2,
|
||||
autoFocus: true,
|
||||
source: function(request, response) {
|
||||
$.get('${url('messages.recipients')}', {term: request.term}, response);
|
||||
},
|
||||
select: function(event, ui) {
|
||||
recipients.tagit('createTag', ui.item.value + ',' + ui.item.label);
|
||||
// Preventing the tag input to be updated with the chosen value.
|
||||
return false;
|
||||
}
|
||||
},
|
||||
beforeTagAdded: function(event, ui) {
|
||||
var label = ui.tagLabel.split(',');
|
||||
var value = label.shift();
|
||||
$(ui.tag).find('.tagit-hidden-field').val(value);
|
||||
$(ui.tag).find('.tagit-label').text(label.join());
|
||||
},
|
||||
removeConfirmation: true
|
||||
});
|
||||
|
||||
// set focus to recipients field
|
||||
recipients.data('ui-tagit').tagInput.focus();
|
||||
|
||||
});
|
||||
</script>
|
||||
<style type="text/css">
|
||||
|
||||
.field-wrapper.subject .field input[type="text"] {
|
||||
width: 99%;
|
||||
}
|
||||
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
<%def name="context_menu_items()">
|
||||
% if request.has_perm('messages.list'):
|
||||
<li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li>
|
||||
<li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li>
|
||||
<li>${h.link_to("Go to my Sent Messages", url('messages.sent'))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
|
@ -58,4 +58,10 @@
|
|||
## </style>
|
||||
</%def>
|
||||
|
||||
<%def name="context_menu_items()">
|
||||
% if request.has_perm('messages.create'):
|
||||
<li>${h.link_to("Send a new Message", url('messages.create'))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/master/index.mako" />
|
||||
<%inherit file="/messages/index.mako" />
|
||||
|
||||
<%def name="title()">Sent Messages</%def>
|
||||
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
<%inherit file="/master/view.mako" />
|
||||
|
||||
<%def name="context_menu_items()">
|
||||
% if request.has_perm('messages.create'):
|
||||
<li>${h.link_to("Send a new Message", url('messages.create'))}</li>
|
||||
% endif
|
||||
% if recipient:
|
||||
% if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX:
|
||||
<li>${h.link_to("Back to Message Inbox", url('messages.inbox'))}</li>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2014 Lance Edgar
|
||||
# Copyright © 2010-2016 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -20,11 +20,12 @@
|
|||
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
Autocomplete View
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from tailbone.views.core import View
|
||||
from tailbone.db import Session
|
||||
|
||||
|
@ -73,4 +74,4 @@ class AutocompleteView(View):
|
|||
if not term:
|
||||
return []
|
||||
results = self.query(term).all()
|
||||
return [{u'label': self.display(x), u'value': self.value(x)} for x in results]
|
||||
return [{'label': self.display(x), 'value': self.value(x)} for x in results]
|
||||
|
|
|
@ -103,6 +103,7 @@ class MasterView(View):
|
|||
form = self.make_form(self.model_class)
|
||||
if self.request.method == 'POST':
|
||||
if form.validate():
|
||||
self.before_create(form)
|
||||
form.save()
|
||||
instance = form.fieldset.model
|
||||
self.after_create(instance)
|
||||
|
@ -549,6 +550,12 @@ class MasterView(View):
|
|||
"""
|
||||
fieldset.configure()
|
||||
|
||||
def before_create(self, form):
|
||||
"""
|
||||
Event hook, called just after the form to create a new instance has
|
||||
been validated, but prior to the form itself being saved.
|
||||
"""
|
||||
|
||||
def after_create(self, instance):
|
||||
"""
|
||||
Event hook, called just after a new instance is saved.
|
||||
|
|
|
@ -30,12 +30,13 @@ from rattail import enum
|
|||
from rattail.db import model
|
||||
|
||||
import formalchemy
|
||||
from formalchemy.helpers import hidden_field
|
||||
from pyramid import httpexceptions
|
||||
from webhelpers.html import tags
|
||||
from webhelpers.html import tags, HTML
|
||||
|
||||
from tailbone import forms
|
||||
from tailbone.db import Session
|
||||
from tailbone.views import MasterView
|
||||
from tailbone.views import MasterView, AutocompleteView
|
||||
|
||||
|
||||
class SubjectFieldRenderer(formalchemy.FieldRenderer):
|
||||
|
@ -56,13 +57,31 @@ class SenderFieldRenderer(forms.renderers.UserFieldRenderer):
|
|||
return super(SenderFieldRenderer, self).render_readonly(**kwargs)
|
||||
|
||||
|
||||
class RecipientsField(formalchemy.Field):
|
||||
"""
|
||||
Custom field for recipients, used when sending new messages.
|
||||
"""
|
||||
is_collection = True
|
||||
|
||||
def sync(self):
|
||||
if not self.is_readonly():
|
||||
message = self.parent.model
|
||||
for uuid in set(self._deserialize()):
|
||||
user = Session.query(model.User).get(uuid)
|
||||
if user:
|
||||
message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX)
|
||||
|
||||
|
||||
class RecipientsFieldRenderer(formalchemy.FieldRenderer):
|
||||
|
||||
def render(self, **kwargs):
|
||||
return HTML.tag('ul')
|
||||
|
||||
def render_readonly(self, **kwargs):
|
||||
recipients = self.raw_value
|
||||
if not recipients:
|
||||
return ''
|
||||
recips = filter(lambda r: r.recipient is not self.request.user, recipients)
|
||||
recips = [r for r in recipients if r.recipient is not self.request.user]
|
||||
recips = sorted([r.recipient.display_name for r in recips])
|
||||
if len(recips) < len(recipients):
|
||||
recips.insert(0, 'you')
|
||||
|
@ -87,7 +106,6 @@ class MessagesView(MasterView):
|
|||
Base class for message views.
|
||||
"""
|
||||
model_class = model.Message
|
||||
creatable = False
|
||||
editable = False
|
||||
deletable = False
|
||||
checkboxes = True
|
||||
|
@ -151,16 +169,27 @@ class MessagesView(MasterView):
|
|||
return {}
|
||||
|
||||
def configure_fieldset(self, fs):
|
||||
fs.configure(
|
||||
include=[
|
||||
if self.creating:
|
||||
fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer))
|
||||
fs.configure(include=[
|
||||
fs.recipients,
|
||||
fs.subject,
|
||||
fs.body.textarea(size='50x10'),
|
||||
])
|
||||
else:
|
||||
fs.configure(include=[
|
||||
fs.sender.with_renderer(SenderFieldRenderer).label("From"),
|
||||
fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"),
|
||||
fs.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)),
|
||||
fs.body,
|
||||
fs.subject,
|
||||
fs.body.textarea(size='50x10'),
|
||||
])
|
||||
if self.viewing:
|
||||
del fs.body
|
||||
del fs.body # ..really?
|
||||
|
||||
def before_create(self, form):
|
||||
message = form.fieldset.model
|
||||
message.sender = self.request.user
|
||||
|
||||
def get_recipient(self, message):
|
||||
for recip in message.recipients:
|
||||
|
@ -265,6 +294,22 @@ class SentView(MessagesView):
|
|||
g.filters['sender'].default_active = False
|
||||
|
||||
|
||||
class RecipientsAutocomplete(AutocompleteView):
|
||||
"""
|
||||
Autocomplete AJAX view for the recipients field when sending a new message.
|
||||
"""
|
||||
|
||||
def query(self, term):
|
||||
return Session.query(model.User)\
|
||||
.join(model.Person)\
|
||||
.filter(model.User.active == True)\
|
||||
.filter(model.Person.display_name.ilike('%{}%'.format(term)))\
|
||||
.order_by(model.Person.display_name)
|
||||
|
||||
def display(self, user):
|
||||
return user.person.display_name
|
||||
|
||||
|
||||
def includeme(config):
|
||||
|
||||
config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages")
|
||||
|
@ -279,6 +324,10 @@ def includeme(config):
|
|||
config.add_view(ArchiveView, attr='index', route_name='messages.archive',
|
||||
permission='messages.list')
|
||||
|
||||
# move (single)
|
||||
config.add_route('messages.move', '/messages/{uuid}/move')
|
||||
config.add_view(MessagesView, attr='move', route_name='messages.move')
|
||||
|
||||
# move bulk
|
||||
config.add_route('messages.move_bulk', '/messages/move-bulk')
|
||||
config.add_view(MessagesView, attr='move_bulk', route_name='messages.move_bulk')
|
||||
|
@ -288,10 +337,9 @@ def includeme(config):
|
|||
config.add_view(SentView, attr='index', route_name='messages.sent',
|
||||
permission='messages.list')
|
||||
|
||||
# view
|
||||
config.add_route('messages.view', '/messages/{uuid}')
|
||||
config.add_view(MessagesView, attr='view', route_name='messages.view')
|
||||
# recipients autocomplete
|
||||
config.add_route('messages.recipients', '/messages/recipients')
|
||||
config.add_view(RecipientsAutocomplete, route_name='messages.recipients',
|
||||
renderer='json', permission='messages.create')
|
||||
|
||||
# move (single)
|
||||
config.add_route('messages.move', '/messages/{uuid}/move')
|
||||
config.add_view(MessagesView, attr='move', route_name='messages.move')
|
||||
MessagesView.defaults(config)
|
||||
|
|
Loading…
Reference in a new issue