mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-23 23:58:31 -05:00
FEATURE: download user posts archive
This commit is contained in:
parent
cd3703e441
commit
bb152a5b3f
19 changed files with 169 additions and 61 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { outputExportResult } from 'admin/lib/export-result';
|
import { outputExportResult } from 'discourse/lib/export-result';
|
||||||
|
|
||||||
export default Ember.ArrayController.extend(Discourse.Presence, {
|
export default Ember.ArrayController.extend(Discourse.Presence, {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { outputExportResult } from 'admin/lib/export-result';
|
import { outputExportResult } from 'discourse/lib/export-result';
|
||||||
|
|
||||||
export default Ember.ArrayController.extend(Discourse.Presence, {
|
export default Ember.ArrayController.extend(Discourse.Presence, {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { outputExportResult } from 'admin/lib/export-result';
|
import { outputExportResult } from 'discourse/lib/export-result';
|
||||||
|
|
||||||
export default Ember.ArrayController.extend(Discourse.Presence, {
|
export default Ember.ArrayController.extend(Discourse.Presence, {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
@namespace Discourse
|
@namespace Discourse
|
||||||
@module Discourse
|
@module Discourse
|
||||||
**/
|
**/
|
||||||
import { outputExportResult } from 'admin/lib/export-result';
|
import { outputExportResult } from 'discourse/lib/export-result';
|
||||||
|
|
||||||
export default Ember.ArrayController.extend(Discourse.Presence, {
|
export default Ember.ArrayController.extend(Discourse.Presence, {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { outputExportResult } from 'admin/lib/export-result';
|
import { outputExportResult } from 'discourse/lib/export-result';
|
||||||
|
|
||||||
export default Discourse.Route.extend({
|
export default Discourse.Route.extend({
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import ObjectController from 'discourse/controllers/object';
|
import ObjectController from 'discourse/controllers/object';
|
||||||
import CanCheckEmails from 'discourse/mixins/can-check-emails';
|
import CanCheckEmails from 'discourse/mixins/can-check-emails';
|
||||||
|
import { outputExportResult } from 'discourse/lib/export-result';
|
||||||
|
|
||||||
export default ObjectController.extend(CanCheckEmails, {
|
export default ObjectController.extend(CanCheckEmails, {
|
||||||
indexStream: false,
|
indexStream: false,
|
||||||
|
@ -51,6 +52,10 @@ export default ObjectController.extend(CanCheckEmails, {
|
||||||
Discourse.AdminUser.find(this.get('username').toLowerCase()).then(function(user){
|
Discourse.AdminUser.find(this.get('username').toLowerCase()).then(function(user){
|
||||||
user.destroy({deletePosts: true});
|
user.destroy({deletePosts: true});
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
exportUserArchive: function() {
|
||||||
|
Discourse.ExportCsv.exportUserArchive().then(outputExportResult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,13 +9,22 @@
|
||||||
Discourse.ExportCsv = Discourse.Model.extend({});
|
Discourse.ExportCsv = Discourse.Model.extend({});
|
||||||
|
|
||||||
Discourse.ExportCsv.reopenClass({
|
Discourse.ExportCsv.reopenClass({
|
||||||
|
/**
|
||||||
|
Exports user archive
|
||||||
|
|
||||||
|
@method export_user_archive
|
||||||
|
**/
|
||||||
|
exportUserArchive: function() {
|
||||||
|
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'user', entity: 'user_archive'}});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Exports user list
|
Exports user list
|
||||||
|
|
||||||
@method export_user_list
|
@method export_user_list
|
||||||
**/
|
**/
|
||||||
exportUserList: function() {
|
exportUserList: function() {
|
||||||
return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'user'}});
|
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'user'}});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,7 +33,7 @@ Discourse.ExportCsv.reopenClass({
|
||||||
@method export_staff_action_logs
|
@method export_staff_action_logs
|
||||||
**/
|
**/
|
||||||
exportStaffActionLogs: function() {
|
exportStaffActionLogs: function() {
|
||||||
return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'staff_action'}});
|
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'staff_action'}});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -33,7 +42,7 @@ Discourse.ExportCsv.reopenClass({
|
||||||
@method export_screened_email_list
|
@method export_screened_email_list
|
||||||
**/
|
**/
|
||||||
exportScreenedEmailList: function() {
|
exportScreenedEmailList: function() {
|
||||||
return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'screened_email'}});
|
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'screened_email'}});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -42,7 +51,7 @@ Discourse.ExportCsv.reopenClass({
|
||||||
@method export_screened_ip_list
|
@method export_screened_ip_list
|
||||||
**/
|
**/
|
||||||
exportScreenedIpList: function() {
|
exportScreenedIpList: function() {
|
||||||
return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'screened_ip'}});
|
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'screened_ip'}});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,6 +60,6 @@ Discourse.ExportCsv.reopenClass({
|
||||||
@method export_screened_url_list
|
@method export_screened_url_list
|
||||||
**/
|
**/
|
||||||
exportScreenedUrlList: function() {
|
exportScreenedUrlList: function() {
|
||||||
return Discourse.ajax("/admin/export_csv/export_entity.json", {data: {entity: 'screened_url'}});
|
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'screened_url'}});
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -186,6 +186,12 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if viewingSelf}}
|
||||||
|
<div class='user-archive'>
|
||||||
|
<button {{action "exportUserArchive"}} class="btn" title="{{i18n 'user.download_archive'}}">{{fa-icon "download"}} {{i18n 'user.download_archive'}}</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class='user-right'>
|
<section class='user-right'>
|
||||||
|
|
|
@ -56,6 +56,7 @@
|
||||||
//= require ./discourse/helpers/cold-age-class
|
//= require ./discourse/helpers/cold-age-class
|
||||||
//= require ./discourse/helpers/loading-spinner
|
//= require ./discourse/helpers/loading-spinner
|
||||||
//= require ./discourse/helpers/category-link
|
//= require ./discourse/helpers/category-link
|
||||||
|
//= require ./discourse/lib/export-result
|
||||||
//= require ./discourse/dialects/dialect
|
//= require ./discourse/dialects/dialect
|
||||||
//= require ./discourse/lib/emoji/emoji
|
//= require ./discourse/lib/emoji/emoji
|
||||||
|
|
||||||
|
@ -69,4 +70,3 @@
|
||||||
//= require_tree ./discourse/templates
|
//= require_tree ./discourse/templates
|
||||||
//= require_tree ./discourse/routes
|
//= require_tree ./discourse/routes
|
||||||
//= require_tree ./discourse/initializers
|
//= require_tree ./discourse/initializers
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
//= require list-view
|
//= require list-view
|
||||||
//= require admin/lib/export-result
|
|
||||||
//= require admin/models/user-field
|
//= require admin/models/user-field
|
||||||
//= require admin/controllers/admin-email-skipped
|
//= require admin/controllers/admin-email-skipped
|
||||||
//= require admin/controllers/change-site-customization-details
|
//= require admin/controllers/change-site-customization-details
|
||||||
|
//= require discourse/lib/export-result
|
||||||
//= require_tree ./admin
|
//= require_tree ./admin
|
||||||
|
|
||||||
//= require resumable.js
|
//= require resumable.js
|
||||||
|
|
|
@ -139,6 +139,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-archive {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-right.groups {
|
.user-right.groups {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,27 @@
|
||||||
class Admin::ExportCsvController < Admin::AdminController
|
class ExportCsvController < ApplicationController
|
||||||
|
|
||||||
skip_before_filter :check_xhr, only: [:show]
|
skip_before_filter :check_xhr, only: [:show]
|
||||||
|
|
||||||
def export_entity
|
def export_entity
|
||||||
params.require(:entity)
|
params.require(:entity)
|
||||||
# export csv file in a background thread
|
params.require(:entity_type)
|
||||||
|
if params[:entity_type] == "admin"
|
||||||
|
guardian.ensure_can_export_admin_entity!(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
Jobs.enqueue(:export_csv_file, entity: params[:entity], user_id: current_user.id)
|
Jobs.enqueue(:export_csv_file, entity: params[:entity], user_id: current_user.id)
|
||||||
render json: success_json
|
render json: success_json
|
||||||
end
|
end
|
||||||
|
|
||||||
# download
|
# download
|
||||||
def show
|
def show
|
||||||
filename = params.fetch(:id)
|
params.require(:entity)
|
||||||
|
params.require(:file_id)
|
||||||
|
if params[:entity] == "system"
|
||||||
|
guardian.ensure_can_export_admin_entity!(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
filename = params.fetch(:file_id)
|
||||||
if export_csv_path = ExportCsv.get_download_path(filename)
|
if export_csv_path = ExportCsv.get_download_path(filename)
|
||||||
send_file export_csv_path
|
send_file export_csv_path
|
||||||
else
|
else
|
|
@ -5,6 +5,7 @@ module Jobs
|
||||||
|
|
||||||
class ExportCsvFile < Jobs::Base
|
class ExportCsvFile < Jobs::Base
|
||||||
HEADER_ATTRS_FOR = {}
|
HEADER_ATTRS_FOR = {}
|
||||||
|
HEADER_ATTRS_FOR['user_archive'] = ['raw','like_count','reply_count','created_at']
|
||||||
HEADER_ATTRS_FOR['user'] = ['id','name','username','email','title','created_at','trust_level','active','admin','moderator','ip_address']
|
HEADER_ATTRS_FOR['user'] = ['id','name','username','email','title','created_at','trust_level','active','admin','moderator','ip_address']
|
||||||
HEADER_ATTRS_FOR['user_stats'] = ['topics_entered','posts_read_count','time_read','topic_count','post_count','likes_given','likes_received']
|
HEADER_ATTRS_FOR['user_stats'] = ['topics_entered','posts_read_count','time_read','topic_count','post_count','likes_given','likes_received']
|
||||||
HEADER_ATTRS_FOR['user_sso'] = ['external_id','external_email', 'external_username', 'external_name', 'external_avatar_url']
|
HEADER_ATTRS_FOR['user_sso'] = ['external_id','external_email', 'external_username', 'external_name', 'external_avatar_url']
|
||||||
|
@ -18,10 +19,16 @@ module Jobs
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@file_name = ""
|
@file_name = ""
|
||||||
|
@entity_type = "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute(args)
|
def execute(args)
|
||||||
entity = args[:entity]
|
entity = args[:entity]
|
||||||
|
|
||||||
|
if entity == "user_archive"
|
||||||
|
@entity_type = "user"
|
||||||
|
end
|
||||||
|
|
||||||
@current_user = User.find_by(id: args[:user_id])
|
@current_user = User.find_by(id: args[:user_id])
|
||||||
|
|
||||||
export_method = "#{entity}_export".to_sym
|
export_method = "#{entity}_export".to_sym
|
||||||
|
@ -41,6 +48,13 @@ module Jobs
|
||||||
notify_user
|
notify_user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def user_archive_export
|
||||||
|
user_archive_data = Post.where(user_id: @current_user.id).select(HEADER_ATTRS_FOR['user_archive']).with_deleted.to_a
|
||||||
|
user_archive_data.map do |user_archive|
|
||||||
|
get_user_archive_fields(user_archive)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def user_export
|
def user_export
|
||||||
query = ::AdminUserIndexQuery.new
|
query = ::AdminUserIndexQuery.new
|
||||||
user_data = query.find_users_query.to_a
|
user_data = query.find_users_query.to_a
|
||||||
|
@ -113,6 +127,16 @@ module Jobs
|
||||||
return group_names
|
return group_names
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_user_archive_fields(user_archive)
|
||||||
|
user_archive_array = []
|
||||||
|
|
||||||
|
HEADER_ATTRS_FOR['user_archive'].each do |attr|
|
||||||
|
user_archive_array.push(user_archive.attributes[attr])
|
||||||
|
end
|
||||||
|
|
||||||
|
user_archive_array
|
||||||
|
end
|
||||||
|
|
||||||
def get_user_fields(user)
|
def get_user_fields(user)
|
||||||
user_array = []
|
user_array = []
|
||||||
|
|
||||||
|
@ -234,7 +258,11 @@ module Jobs
|
||||||
def notify_user
|
def notify_user
|
||||||
if @current_user
|
if @current_user
|
||||||
if @file_name != "" && File.exists?("#{ExportCsv.base_directory}/#{@file_name}")
|
if @file_name != "" && File.exists?("#{ExportCsv.base_directory}/#{@file_name}")
|
||||||
SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/admin/export_csv/#{@file_name}", file_name: @file_name)
|
if @entity_type == "admin"
|
||||||
|
SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/export_csv/system/#{@file_name}", file_name: @file_name)
|
||||||
|
else
|
||||||
|
SystemMessage.create_from_system_user(@current_user, :csv_export_succeeded, download_link: "#{Discourse.base_url}/export_csv/#{@current_user.username}/#{@file_name}", file_name: @file_name)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
SystemMessage.create_from_system_user(@current_user, :csv_export_failed)
|
SystemMessage.create_from_system_user(@current_user, :csv_export_failed)
|
||||||
end
|
end
|
||||||
|
|
|
@ -292,7 +292,7 @@ en:
|
||||||
profile: "Profile"
|
profile: "Profile"
|
||||||
mute: "Mute"
|
mute: "Mute"
|
||||||
edit: "Edit Preferences"
|
edit: "Edit Preferences"
|
||||||
download_archive: "download archive of my posts"
|
download_archive: "Download archive of my posts"
|
||||||
new_private_message: "New Private Message"
|
new_private_message: "New Private Message"
|
||||||
private_message: "Private Message"
|
private_message: "Private Message"
|
||||||
private_messages: "Messages"
|
private_messages: "Messages"
|
||||||
|
|
|
@ -164,15 +164,6 @@ Discourse::Application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :export_csv, constraints: AdminConstraint.new do
|
|
||||||
collection do
|
|
||||||
get "export_entity" => "export_csv#export_entity"
|
|
||||||
end
|
|
||||||
member do
|
|
||||||
get "" => "export_csv#show", constraints: { id: /[^\/]+/ }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :badges, constraints: AdminConstraint.new do
|
resources :badges, constraints: AdminConstraint.new do
|
||||||
collection do
|
collection do
|
||||||
get "types" => "badges#badge_types"
|
get "types" => "badges#badge_types"
|
||||||
|
@ -441,6 +432,13 @@ Discourse::Application.routes.draw do
|
||||||
get "invites/redeem/:token" => "invites#redeem_disposable_invite"
|
get "invites/redeem/:token" => "invites#redeem_disposable_invite"
|
||||||
delete "invites" => "invites#destroy"
|
delete "invites" => "invites#destroy"
|
||||||
|
|
||||||
|
resources :export_csv do
|
||||||
|
collection do
|
||||||
|
get "export_entity" => "export_csv#export_entity"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
get "export_csv/:entity/:file_id" => "export_csv#show", constraints: {entity: USERNAME_ROUTE_FORMAT, file_id: /[^\/]+/}
|
||||||
|
|
||||||
get "onebox" => "onebox#show"
|
get "onebox" => "onebox#show"
|
||||||
|
|
||||||
get "error" => "forums#error"
|
get "error" => "forums#error"
|
||||||
|
|
|
@ -249,6 +249,10 @@ class Guardian
|
||||||
@can_see_emails
|
@can_see_emails
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_export_admin_entity?(user)
|
||||||
|
user.staff?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def is_my_own?(obj)
|
def is_my_own?(obj)
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
require "spec_helper"
|
|
||||||
|
|
||||||
describe Admin::ExportCsvController do
|
|
||||||
|
|
||||||
it "is a subclass of AdminController" do
|
|
||||||
(Admin::ExportCsvController < Admin::AdminController).should == true
|
|
||||||
end
|
|
||||||
|
|
||||||
let(:export_filename) { "export_b6a2bc87.csv" }
|
|
||||||
|
|
||||||
context "while logged in as an admin" do
|
|
||||||
|
|
||||||
before { @admin = log_in(:admin) }
|
|
||||||
|
|
||||||
describe ".download" do
|
|
||||||
|
|
||||||
it "uses send_file to transmit the export file" do
|
|
||||||
controller.stubs(:render)
|
|
||||||
export = ExportCsv.new()
|
|
||||||
ExportCsv.expects(:get_download_path).with(export_filename).returns(export)
|
|
||||||
subject.expects(:send_file).with(export)
|
|
||||||
get :show, id: export_filename
|
|
||||||
end
|
|
||||||
|
|
||||||
it "returns 404 when the export file does not exist" do
|
|
||||||
ExportCsv.expects(:get_download_path).returns(nil)
|
|
||||||
get :show, id: export_filename
|
|
||||||
response.should be_not_found
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
78
spec/controllers/export_csv_controller_spec.rb
Normal file
78
spec/controllers/export_csv_controller_spec.rb
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
require "spec_helper"
|
||||||
|
|
||||||
|
describe ExportCsvController do
|
||||||
|
|
||||||
|
let(:export_filename) { "export_b6a2bc87.csv" }
|
||||||
|
|
||||||
|
|
||||||
|
context "while logged in as normal user" do
|
||||||
|
before { @user = log_in(:user) }
|
||||||
|
|
||||||
|
describe ".export_entity" do
|
||||||
|
it "enqueues export job" do
|
||||||
|
Jobs.expects(:enqueue).with(:export_csv_file, has_entries(entity: "user_archive", user_id: @user.id))
|
||||||
|
xhr :post, :export_entity, entity: "user_archive", entity_type: "user"
|
||||||
|
response.should be_success
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 404 when normal user tries to export admin entity" do
|
||||||
|
xhr :post, :export_entity, entity: "staff_action", entity_type: "admin"
|
||||||
|
response.should_not be_success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".download" do
|
||||||
|
it "uses send_file to transmit the export file" do
|
||||||
|
controller.stubs(:render)
|
||||||
|
export = ExportCsv.new()
|
||||||
|
ExportCsv.expects(:get_download_path).with(export_filename).returns(export)
|
||||||
|
subject.expects(:send_file).with(export)
|
||||||
|
get :show, entity: "username", file_id: export_filename
|
||||||
|
response.should be_success
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 404 when the normal user tries to access admin export file" do
|
||||||
|
controller.stubs(:render)
|
||||||
|
get :show, entity: "system", file_id: export_filename
|
||||||
|
response.should_not be_success
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 404 when the export file does not exist" do
|
||||||
|
ExportCsv.expects(:get_download_path).returns(nil)
|
||||||
|
get :show, entity: "username", file_id: export_filename
|
||||||
|
response.should be_not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
context "while logged in as an admin" do
|
||||||
|
before { @admin = log_in(:admin) }
|
||||||
|
|
||||||
|
describe ".export_entity" do
|
||||||
|
it "enqueues export job" do
|
||||||
|
Jobs.expects(:enqueue).with(:export_csv_file, has_entries(entity: "staff_action", user_id: @admin.id))
|
||||||
|
xhr :post, :export_entity, entity: "staff_action", entity_type: "admin"
|
||||||
|
response.should be_success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".download" do
|
||||||
|
it "uses send_file to transmit the export file" do
|
||||||
|
controller.stubs(:render)
|
||||||
|
export = ExportCsv.new()
|
||||||
|
ExportCsv.expects(:get_download_path).with(export_filename).returns(export)
|
||||||
|
subject.expects(:send_file).with(export)
|
||||||
|
get :show, entity: "system", file_id: export_filename
|
||||||
|
response.should be_success
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns 404 when the export file does not exist" do
|
||||||
|
ExportCsv.expects(:get_download_path).returns(nil)
|
||||||
|
get :show, entity: "system", file_id: export_filename
|
||||||
|
response.should be_not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
Loading…
Reference in a new issue