Implemented server push if ticket, article or user has changed.
This commit is contained in:
parent
8fd45e7297
commit
ff5d44bd24
31 changed files with 111 additions and 50 deletions
|
@ -1,8 +1,15 @@
|
||||||
class App.Controller extends Spine.Controller
|
class App.Controller extends Spine.Controller
|
||||||
@include App.Log
|
@include App.Log
|
||||||
|
|
||||||
constructor: ->
|
constructor: (params) ->
|
||||||
|
|
||||||
|
# unbind old bindlings
|
||||||
|
if params && params.el && params.el.unbind
|
||||||
|
params.el.unbind()
|
||||||
|
|
||||||
super
|
super
|
||||||
|
|
||||||
|
# create shortcuts
|
||||||
@Config = App.Config
|
@Config = App.Config
|
||||||
@Session = App.Session
|
@Session = App.Session
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,21 @@ class App.TicketZoom extends App.Controller
|
||||||
@load(cache)
|
@load(cache)
|
||||||
update = =>
|
update = =>
|
||||||
@fetch( @ticket_id, false )
|
@fetch( @ticket_id, false )
|
||||||
@interval( update, 30000, @key, 'ticket_zoom' )
|
@interval( update, 120000, @key, 'ticket_zoom' )
|
||||||
|
|
||||||
|
# fetch new data if triggered
|
||||||
|
App.Event.bind(
|
||||||
|
'ticket:updated'
|
||||||
|
(data) =>
|
||||||
|
update = =>
|
||||||
|
if data.id.toString() is @ticket_id.toString()
|
||||||
|
ticket = App.Collection.find( 'Ticket', @ticket_id )
|
||||||
|
console.log('TRY', data.updated_at, ticket.updated_at)
|
||||||
|
if data.updated_at isnt ticket.updated_at
|
||||||
|
@fetch( @ticket_id, false )
|
||||||
|
@delay( update, 2000, 'ticket-zoom-' + @ticket_id )
|
||||||
|
'ticket-zoom-' + @ticket_id
|
||||||
|
)
|
||||||
|
|
||||||
meta: =>
|
meta: =>
|
||||||
return if !@ticket
|
return if !@ticket
|
||||||
|
@ -43,6 +57,7 @@ class App.TicketZoom extends App.Controller
|
||||||
return true
|
return true
|
||||||
|
|
||||||
release: =>
|
release: =>
|
||||||
|
App.Event.unbindLevel 'ticket-zoom-' + @ticket_id
|
||||||
@clearInterval( @key, 'ticket_zoom' )
|
@clearInterval( @key, 'ticket_zoom' )
|
||||||
@el.remove()
|
@el.remove()
|
||||||
|
|
||||||
|
@ -75,13 +90,12 @@ class App.TicketZoom extends App.Controller
|
||||||
# return if ticket hasnt changed
|
# return if ticket hasnt changed
|
||||||
return if _.isEqual( @dataLastCall.ticket, data.ticket )
|
return if _.isEqual( @dataLastCall.ticket, data.ticket )
|
||||||
|
|
||||||
# return if ticket changed by my self
|
|
||||||
return if data.ticket.updated_by_id is @Session.all().id
|
|
||||||
|
|
||||||
# trigger task notify
|
# trigger task notify
|
||||||
diff = difference( @dataLastCall.ticket, data.ticket )
|
diff = difference( @dataLastCall.ticket, data.ticket )
|
||||||
console.log('diff', diff)
|
console.log('diff', diff)
|
||||||
if !_.isEmpty(diff)
|
|
||||||
|
# notify if ticket changed not by my self
|
||||||
|
if !_.isEmpty(diff) && data.ticket.updated_by_id isnt @Session.all().id
|
||||||
App.TaskManager.notify( @task_key )
|
App.TaskManager.notify( @task_key )
|
||||||
|
|
||||||
# remember current data
|
# remember current data
|
||||||
|
@ -601,7 +615,7 @@ class ArticleView extends App.Controller
|
||||||
#@ui.el.find('[name="cc"]').val('')
|
#@ui.el.find('[name="cc"]').val('')
|
||||||
#@ui.el.find('[name="subject"]').val('')
|
#@ui.el.find('[name="subject"]').val('')
|
||||||
@ui.el.find('[name="in_reply_to"]').val('')
|
@ui.el.find('[name="in_reply_to"]').val('')
|
||||||
console.log('repl2', article_type.name)
|
|
||||||
if article.message_id
|
if article.message_id
|
||||||
@ui.el.find('[name="in_reply_to"]').val(article.message_id)
|
@ui.el.find('[name="in_reply_to"]').val(article.message_id)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
class App.Model extends Spine.Model
|
class App.Model extends Spine.Model
|
||||||
|
@destroyBind: false
|
||||||
|
|
||||||
constructor: ->
|
constructor: ->
|
||||||
super
|
super
|
||||||
|
|
||||||
# delete object from local storage on destroy
|
# delete object from local storage on destroy
|
||||||
|
if !@constructor.destroyBind
|
||||||
@bind( 'destroy', (e) ->
|
@bind( 'destroy', (e) ->
|
||||||
className = Object.getPrototypeOf(e).constructor.className
|
className = Object.getPrototypeOf(e).constructor.className
|
||||||
key = "collection::#{className}::#{e.id}"
|
key = "collection::#{className}::#{e.id}"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class App.Channel extends App.Model
|
class App.Channel extends App.Model
|
||||||
@configure 'Channel', 'adapter', 'area', 'options', 'group_id', 'active'
|
@configure 'Channel', 'adapter', 'area', 'options', 'group_id', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/channels'
|
@url: 'api/channels'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.EmailAddress extends App.Model
|
class App.EmailAddress extends App.Model
|
||||||
@configure 'EmailAddress', 'realname', 'email', 'note', 'active'
|
@configure 'EmailAddress', 'realname', 'email', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/email_addresses'
|
@url: 'api/email_addresses'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Group extends App.Model
|
class App.Group extends App.Model
|
||||||
@configure 'Group', 'name', 'assignment_timeout', 'follow_up_possible', 'follow_up_assignment', 'email_address_id', 'signature_id', 'note', 'active'
|
@configure 'Group', 'name', 'assignment_timeout', 'follow_up_possible', 'follow_up_assignment', 'email_address_id', 'signature_id', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/groups'
|
@url: 'api/groups'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Network extends App.Model
|
class App.Network extends App.Model
|
||||||
@configure 'Network', 'name', 'note', 'active'
|
@configure 'Network', 'name', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
{ name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, 'null': false, 'class': 'xlarge' },
|
{ name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, 'null': false, 'class': 'xlarge' },
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
class App.NetworkCategory extends App.Model
|
class App.NetworkCategory extends App.Model
|
||||||
@configure 'NetworkCategory', 'name', 'network_id', 'network_category_type_id', 'network_privacy_id', 'note', 'allow_comments', 'active'
|
@configure 'NetworkCategory', 'name', 'network_id', 'network_category_type_id', 'network_privacy_id', 'note', 'allow_comments', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
class App.NetworkCategoryType extends App.Model
|
class App.NetworkCategoryType extends App.Model
|
||||||
@configure 'NetworkCategoryType', 'name', 'note', 'active'
|
@configure 'NetworkCategoryType', 'name', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
class App.NetworkPrivacy extends App.Model
|
class App.NetworkPrivacy extends App.Model
|
||||||
@configure 'NetworkPrivacy', 'name', 'key'
|
@configure 'NetworkPrivacy', 'name', 'key', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Organization extends App.Model
|
class App.Organization extends App.Model
|
||||||
@configure 'Organization', 'name', 'shared', 'note', 'active'
|
@configure 'Organization', 'name', 'shared', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/organizations'
|
@url: 'api/organizations'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Overview extends Spine.Model
|
class App.Overview extends Spine.Model
|
||||||
@configure 'Overview', 'name', 'link', 'prio', 'condition', 'order', 'group_by', 'view', 'user_id', 'organization_shared', 'role_id', 'order', 'group_by', 'active'
|
@configure 'Overview', 'name', 'link', 'prio', 'condition', 'order', 'group_by', 'view', 'user_id', 'organization_shared', 'role_id', 'order', 'group_by', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/overviews'
|
@url: 'api/overviews'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.PostmasterFilter extends App.Model
|
class App.PostmasterFilter extends App.Model
|
||||||
@configure 'PostmasterFilter', 'name', 'channel', 'match', 'perform', 'note', 'active'
|
@configure 'PostmasterFilter', 'name', 'channel', 'match', 'perform', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/postmaster_filters'
|
@url: 'api/postmaster_filters'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Role extends App.Model
|
class App.Role extends App.Model
|
||||||
@configure 'Role', 'name', 'note', 'active'
|
@configure 'Role', 'name', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/roles'
|
@url: 'api/roles'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Signature extends App.Model
|
class App.Signature extends App.Model
|
||||||
@configure 'Signature', 'name', 'body', 'note', 'active'
|
@configure 'Signature', 'name', 'body', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/signatures'
|
@url: 'api/signatures'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Sla extends App.Model
|
class App.Sla extends App.Model
|
||||||
@configure 'Sla', 'name', 'first_response_time', 'update_time', 'close_time', 'condition', 'timezone', 'data', 'active'
|
@configure 'Sla', 'name', 'first_response_time', 'update_time', 'close_time', 'condition', 'timezone', 'data', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/slas'
|
@url: 'api/slas'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Taskbar extends App.Model
|
class App.Taskbar extends App.Model
|
||||||
@configure 'Taskbar', 'key', 'client_id', 'callback', 'state', 'params', 'prio', 'notify', 'active'
|
@configure 'Taskbar', 'key', 'client_id', 'callback', 'state', 'params', 'prio', 'notify', 'active', 'updated_at'
|
||||||
# @extend Spine.Model.Local
|
# @extend Spine.Model.Local
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/taskbar'
|
@url: 'api/taskbar'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class App.Template extends App.Model
|
class App.Template extends App.Model
|
||||||
@configure 'Template', 'name', 'options', 'group_ids', 'user_id'
|
@configure 'Template', 'name', 'options', 'group_ids', 'user_id', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/templates'
|
@url: 'api/templates'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.TextModule extends App.Model
|
class App.TextModule extends App.Model
|
||||||
@configure 'TextModule', 'name', 'keywords', 'content', 'active', 'group_ids', 'user_id'
|
@configure 'TextModule', 'name', 'keywords', 'content', 'active', 'group_ids', 'user_id', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/text_modules'
|
@url: 'api/text_modules'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.Ticket extends App.Model
|
class App.Ticket extends App.Model
|
||||||
@configure 'Ticket', 'number', 'title', 'group_id', 'owner_id', 'customer_id', 'ticket_state_id', 'ticket_priority_id', 'article', 'tags'
|
@configure 'Ticket', 'number', 'title', 'group_id', 'owner_id', 'customer_id', 'ticket_state_id', 'ticket_priority_id', 'article', 'tags', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/tickets'
|
@url: 'api/tickets'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.TicketArticle extends App.Model
|
class App.TicketArticle extends App.Model
|
||||||
@configure 'TicketArticle', 'from', 'to', 'cc', 'subject', 'body', 'ticket_id', 'ticket_article_type_id', 'ticket_article_sender_id', 'internal', 'in_reply_to', 'form_id'
|
@configure 'TicketArticle', 'from', 'to', 'cc', 'subject', 'body', 'ticket_id', 'ticket_article_type_id', 'ticket_article_sender_id', 'internal', 'in_reply_to', 'form_id', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/ticket_articles'
|
@url: 'api/ticket_articles'
|
||||||
@configure_attributes = [
|
@configure_attributes = [
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class App.TicketArticleSender extends App.Model
|
class App.TicketArticleSender extends App.Model
|
||||||
@configure 'TicketArticleSender', 'name'
|
@configure 'TicketArticleSender', 'name', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: '/ticket_article_senders'
|
@url: '/ticket_article_senders'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class App.TicketArticleType extends App.Model
|
class App.TicketArticleType extends App.Model
|
||||||
@configure 'TicketArticleType', 'name'
|
@configure 'TicketArticleType', 'name', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: '/ticket_article_types'
|
@url: '/ticket_article_types'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class App.TicketPriority extends App.Model
|
class App.TicketPriority extends App.Model
|
||||||
@configure 'TicketPriority', 'name', 'note', 'active'
|
@configure 'TicketPriority', 'name', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/ticket_priorities'
|
@url: 'api/ticket_priorities'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class App.TicketStateType extends App.Model
|
class App.TicketStateType extends App.Model
|
||||||
@configure 'TicketStateType', 'name', 'note', 'active'
|
@configure 'TicketStateType', 'name', 'note', 'active', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: '/ticket_state_types'
|
@url: '/ticket_state_types'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class App.User extends App.Model
|
class App.User extends App.Model
|
||||||
@configure 'User', 'login', 'firstname', 'lastname', 'email', 'web', 'password', 'phone', 'fax', 'mobile', 'street', 'zip', 'city', 'country', 'organization_id', 'department', 'note', 'role_ids', 'group_ids', 'active', 'invite'
|
@configure 'User', 'login', 'firstname', 'lastname', 'email', 'web', 'password', 'phone', 'fax', 'mobile', 'street', 'zip', 'city', 'country', 'organization_id', 'department', 'note', 'role_ids', 'group_ids', 'active', 'invite', 'updated_at'
|
||||||
@extend Spine.Model.Ajax
|
@extend Spine.Model.Ajax
|
||||||
@url: 'api/users'
|
@url: 'api/users'
|
||||||
|
|
||||||
|
|
29
app/models/observer/web_socket_notify.rb
Normal file
29
app/models/observer/web_socket_notify.rb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
require 'session'
|
||||||
|
|
||||||
|
class Observer::WebSocketNotify < ActiveRecord::Observer
|
||||||
|
observe :ticket, :user, 'ticket::_article'
|
||||||
|
|
||||||
|
def after_create(record)
|
||||||
|
|
||||||
|
# return if we run import mode
|
||||||
|
return if Setting.get('import_mode')
|
||||||
|
|
||||||
|
Session.broadcast(
|
||||||
|
:event => record.class.name.downcase + ':created',
|
||||||
|
:data => { :id => record.id, :updated_at => record.updated_at }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def after_update(record)
|
||||||
|
|
||||||
|
# return if we run import mode
|
||||||
|
return if Setting.get('import_mode')
|
||||||
|
puts "#{record.class.name.downcase} UPDATED " + record.updated_at.to_s
|
||||||
|
Session.broadcast(
|
||||||
|
:event => record.class.name.downcase + ':updated',
|
||||||
|
:data => { :id => record.id, :updated_at => record.updated_at }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -41,11 +41,8 @@ module Zammad
|
||||||
'observer::_ticket::_notification',
|
'observer::_ticket::_notification',
|
||||||
'observer::_tag::_ticket_history',
|
'observer::_tag::_ticket_history',
|
||||||
'observer::_ticket::_reset_new_state',
|
'observer::_ticket::_reset_new_state',
|
||||||
'observer::_ticket::_escalation_calculation'
|
'observer::_ticket::_escalation_calculation',
|
||||||
|
'observer::_web_socket_notify'
|
||||||
# Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
|
|
||||||
# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
|
|
||||||
# config.time_zone = 'Central Time (US & Canada)'
|
|
||||||
|
|
||||||
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
|
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
|
||||||
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
|
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
|
||||||
|
|
|
@ -293,6 +293,16 @@ module Session
|
||||||
return data
|
return data
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.broadcast( data )
|
||||||
|
|
||||||
|
# list all current clients
|
||||||
|
client_list = self.list
|
||||||
|
client_list.each {|local_client_id, local_client|
|
||||||
|
Session.send( local_client_id, data )
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
def self.destory( client_id )
|
def self.destory( client_id )
|
||||||
path = @path + '/' + client_id.to_s
|
path = @path + '/' + client_id.to_s
|
||||||
FileUtils.rm_rf path
|
FileUtils.rm_rf path
|
||||||
|
|
|
@ -24,10 +24,6 @@ require 'daemons'
|
||||||
:i => Dir.pwd.to_s + '/tmp/pids/websocket.pid'
|
:i => Dir.pwd.to_s + '/tmp/pids/websocket.pid'
|
||||||
}
|
}
|
||||||
|
|
||||||
if ARGV[0] != 'start' && ARGV[0] != 'stop'
|
|
||||||
puts "Usage: websocket-server.rb start|stop [options]"
|
|
||||||
exit;
|
|
||||||
end
|
|
||||||
tls_options = {}
|
tls_options = {}
|
||||||
OptionParser.new do |opts|
|
OptionParser.new do |opts|
|
||||||
opts.banner = "Usage: websocket-server.rb start|stop [options]"
|
opts.banner = "Usage: websocket-server.rb start|stop [options]"
|
||||||
|
@ -58,6 +54,11 @@ OptionParser.new do |opts|
|
||||||
end
|
end
|
||||||
end.parse!
|
end.parse!
|
||||||
|
|
||||||
|
if ARGV[0] != 'start' && ARGV[0] != 'stop'
|
||||||
|
puts "Usage: websocket-server.rb start|stop [options]"
|
||||||
|
exit;
|
||||||
|
end
|
||||||
|
|
||||||
puts "Starting websocket server on #{ @options[:b] }:#{ @options[:p] } (secure:#{ @options[:s].to_s },pid:#{@options[:i].to_s})"
|
puts "Starting websocket server on #{ @options[:b] }:#{ @options[:p] } (secure:#{ @options[:s].to_s },pid:#{@options[:i].to_s})"
|
||||||
#puts options.inspect
|
#puts options.inspect
|
||||||
|
|
||||||
|
|
|
@ -224,7 +224,7 @@ class AgentTicketActionsLevel2Test < TestCase
|
||||||
# change task and page title in first browser
|
# change task and page title in first browser
|
||||||
{
|
{
|
||||||
:execute => 'wait',
|
:execute => 'wait',
|
||||||
:value => 30,
|
:value => 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
:where => :instance1,
|
:where => :instance1,
|
||||||
|
|
Loading…
Reference in a new issue