mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 13:03:44 +08:00
FEATURE: Add plugin API to register About stat group (#17442)
This commit introduces a new plugin API to register a group of stats that will be included in about.json and also conditionally in the site about UI at /about. The usage is like this: ```ruby register_about_stat_group("chat_messages", show_in_ui: true) do { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000, previous_30_days: 120 } end ``` In reality the stats will be generated any way the implementer chooses within the plugin. The `last_day`, `7_days`, `30_days,` and `count` keys must be present but apart from that additional stats may be added. Only those core 4 stat keys will be shown in the UI, but everything will be shown in about.json. The stat group name is used to prefix the stats in about.json like so: ```json "chat_messages_last_day": 2322, "chat_messages_7_days": 2322, "chat_messages_30_days": 2322, "chat_messages_count": 2322, ``` The `show_in_ui` option (default false) is used to determine whether the group of stats is shown on the site About page in the Site Statistics table. Some stats may be needed purely for reporting purposes and thus do not need to be shown in the UI to admins/users. An extension to the Site serializer, `displayed_about_plugin_stat_groups`, has been added so this can be inspected on the client-side.
This commit is contained in:
parent
8dad778fcc
commit
098ab29d41
|
@ -29,6 +29,7 @@ export default DiscourseRoute.extend({
|
||||||
result.about.category_moderators[index].category = category;
|
result.about.category_moderators[index].category = category;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.about;
|
return result.about;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -69,41 +69,50 @@
|
||||||
<th>{{i18n "about.stat.last_30_days"}}</th>
|
<th>{{i18n "about.stat.last_30_days"}}</th>
|
||||||
<th>{{i18n "about.stat.all_time"}}</th>
|
<th>{{i18n "about.stat.all_time"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="about-topic-count">
|
||||||
<td class="title">{{i18n "about.topic_count"}}</td>
|
<td class="title">{{i18n "about.topic_count"}}</td>
|
||||||
<td>{{number this.model.stats.topics_last_day}}</td>
|
<td>{{number this.model.stats.topics_last_day}}</td>
|
||||||
<td>{{number this.model.stats.topics_7_days}}</td>
|
<td>{{number this.model.stats.topics_7_days}}</td>
|
||||||
<td>{{number this.model.stats.topics_30_days}}</td>
|
<td>{{number this.model.stats.topics_30_days}}</td>
|
||||||
<td>{{number this.model.stats.topic_count}}</td>
|
<td>{{number this.model.stats.topic_count}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="about-post-count">
|
||||||
<td>{{i18n "about.post_count"}}</td>
|
<td>{{i18n "about.post_count"}}</td>
|
||||||
<td>{{number this.model.stats.posts_last_day}}</td>
|
<td>{{number this.model.stats.posts_last_day}}</td>
|
||||||
<td>{{number this.model.stats.posts_7_days}}</td>
|
<td>{{number this.model.stats.posts_7_days}}</td>
|
||||||
<td>{{number this.model.stats.posts_30_days}}</td>
|
<td>{{number this.model.stats.posts_30_days}}</td>
|
||||||
<td>{{number this.model.stats.post_count}}</td>
|
<td>{{number this.model.stats.post_count}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="about-user-count">
|
||||||
<td>{{i18n "about.user_count"}}</td>
|
<td>{{i18n "about.user_count"}}</td>
|
||||||
<td>{{number this.model.stats.users_last_day}}</td>
|
<td>{{number this.model.stats.users_last_day}}</td>
|
||||||
<td>{{number this.model.stats.users_7_days}}</td>
|
<td>{{number this.model.stats.users_7_days}}</td>
|
||||||
<td>{{number this.model.stats.users_30_days}}</td>
|
<td>{{number this.model.stats.users_30_days}}</td>
|
||||||
<td>{{number this.model.stats.user_count}}</td>
|
<td>{{number this.model.stats.user_count}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="about-active-user-count">
|
||||||
<td>{{i18n "about.active_user_count"}}</td>
|
<td>{{i18n "about.active_user_count"}}</td>
|
||||||
<td>{{number this.model.stats.active_users_last_day}}</td>
|
<td>{{number this.model.stats.active_users_last_day}}</td>
|
||||||
<td>{{number this.model.stats.active_users_7_days}}</td>
|
<td>{{number this.model.stats.active_users_7_days}}</td>
|
||||||
<td>{{number this.model.stats.active_users_30_days}}</td>
|
<td>{{number this.model.stats.active_users_30_days}}</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="about-like-count">
|
||||||
<td>{{i18n "about.like_count"}}</td>
|
<td>{{i18n "about.like_count"}}</td>
|
||||||
<td>{{number this.model.stats.likes_last_day}}</td>
|
<td>{{number this.model.stats.likes_last_day}}</td>
|
||||||
<td>{{number this.model.stats.likes_7_days}}</td>
|
<td>{{number this.model.stats.likes_7_days}}</td>
|
||||||
<td>{{number this.model.stats.likes_30_days}}</td>
|
<td>{{number this.model.stats.likes_30_days}}</td>
|
||||||
<td>{{number this.model.stats.like_count}}</td>
|
<td>{{number this.model.stats.like_count}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{#each this.site.displayed_about_plugin_stat_groups as |statGroupName|}}
|
||||||
|
<tr class={{concat "about-" statGroupName "-count"}}>
|
||||||
|
<td>{{i18n (concat "about." statGroupName "_count")}}</td>
|
||||||
|
<td>{{number (get this.model.stats (concat statGroupName "_last_day"))}}</td>
|
||||||
|
<td>{{number (get this.model.stats (concat statGroupName "_7_days"))}}</td>
|
||||||
|
<td>{{number (get this.model.stats (concat statGroupName "_30_days"))}}</td>
|
||||||
|
<td>{{number (get this.model.stats (concat statGroupName "_count"))}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -9,6 +9,24 @@ acceptance("About", function () {
|
||||||
assert.ok(document.body.classList.contains("about-page"), "has body class");
|
assert.ok(document.body.classList.contains("about-page"), "has body class");
|
||||||
assert.ok(exists(".about.admins .user-info"), "has admins");
|
assert.ok(exists(".about.admins .user-info"), "has admins");
|
||||||
assert.ok(exists(".about.moderators .user-info"), "has moderators");
|
assert.ok(exists(".about.moderators .user-info"), "has moderators");
|
||||||
assert.ok(exists(".about.stats tr td"), "has stats");
|
assert.ok(
|
||||||
|
exists(".about.stats tr.about-topic-count td"),
|
||||||
|
"has topic stats"
|
||||||
|
);
|
||||||
|
assert.ok(exists(".about.stats tr.about-post-count td"), "has post stats");
|
||||||
|
assert.ok(exists(".about.stats tr.about-user-count td"), "has user stats");
|
||||||
|
assert.ok(
|
||||||
|
exists(".about.stats tr.about-active-user-count td"),
|
||||||
|
"has active user stats"
|
||||||
|
);
|
||||||
|
assert.ok(exists(".about.stats tr.about-like-count td"), "has like stats");
|
||||||
|
assert.ok(
|
||||||
|
exists(".about.stats tr.about-chat_messages-count td"),
|
||||||
|
"has plugin stats"
|
||||||
|
);
|
||||||
|
assert.notOk(
|
||||||
|
exists(".about.stats tr.about-chat_users-count td"),
|
||||||
|
"does not show hidden plugin stats"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,17 +6,30 @@ export default {
|
||||||
topic_count: 27480,
|
topic_count: 27480,
|
||||||
post_count: 490358,
|
post_count: 490358,
|
||||||
user_count: 41719,
|
user_count: 41719,
|
||||||
|
topics_last_day: 34,
|
||||||
topics_7_days: 169,
|
topics_7_days: 169,
|
||||||
topics_30_days: 517,
|
topics_30_days: 517,
|
||||||
|
posts_last_day: 794,
|
||||||
posts_7_days: 3128,
|
posts_7_days: 3128,
|
||||||
posts_30_days: 10660,
|
posts_30_days: 10660,
|
||||||
|
users_last_day: 123,
|
||||||
users_7_days: 237,
|
users_7_days: 237,
|
||||||
users_30_days: 866,
|
users_30_days: 866,
|
||||||
|
active_users_last_day: 432,
|
||||||
active_users_7_days: 1004,
|
active_users_7_days: 1004,
|
||||||
active_users_30_days: 2026,
|
active_users_30_days: 2026,
|
||||||
like_count: 499135,
|
like_count: 499135,
|
||||||
|
likes_last_day: 120,
|
||||||
likes_7_days: 3449,
|
likes_7_days: 3449,
|
||||||
likes_30_days: 12313,
|
likes_30_days: 12313,
|
||||||
|
chat_messages_last_day: 10,
|
||||||
|
chat_messages_7_days: 100,
|
||||||
|
chat_messages_30_days: 1000,
|
||||||
|
chat_messages_count: 10000,
|
||||||
|
chat_users_last_day: 10,
|
||||||
|
chat_users_7_days: 100,
|
||||||
|
chat_users_30_days: 1000,
|
||||||
|
chat_users_count: 10000
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"Discussion about the next-generation open source Discourse forum software",
|
"Discussion about the next-generation open source Discourse forum software",
|
||||||
|
|
|
@ -697,6 +697,7 @@ export default {
|
||||||
can_revoke: true,
|
can_revoke: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
displayed_about_plugin_stat_groups: ["chat_messages"]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,24 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class About
|
class About
|
||||||
|
cattr_reader :plugin_stat_groups
|
||||||
|
|
||||||
|
def self.add_plugin_stat_group(prefix, show_in_ui: false, &block)
|
||||||
|
@@displayed_plugin_stat_groups << prefix if show_in_ui
|
||||||
|
@@plugin_stat_groups[prefix] = block
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.clear_plugin_stat_groups
|
||||||
|
@@displayed_plugin_stat_groups = Set.new
|
||||||
|
@@plugin_stat_groups = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.displayed_plugin_stat_groups
|
||||||
|
@@displayed_plugin_stat_groups.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
clear_plugin_stat_groups
|
||||||
|
|
||||||
class CategoryMods
|
class CategoryMods
|
||||||
include ActiveModel::Serialization
|
include ActiveModel::Serialization
|
||||||
attr_reader :category_id, :moderators
|
attr_reader :category_id, :moderators
|
||||||
|
@ -82,7 +100,30 @@ class About
|
||||||
likes_last_day: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 1.days.ago).count,
|
likes_last_day: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 1.days.ago).count,
|
||||||
likes_7_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 7.days.ago).count,
|
likes_7_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 7.days.ago).count,
|
||||||
likes_30_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 30.days.ago).count
|
likes_30_days: UserAction.where(action_type: UserAction::LIKE).where("created_at > ?", 30.days.ago).count
|
||||||
}
|
}.merge(plugin_stats)
|
||||||
|
end
|
||||||
|
|
||||||
|
def plugin_stats
|
||||||
|
final_plugin_stats = {}
|
||||||
|
@@plugin_stat_groups.each do |plugin_stat_group_name, stat_group|
|
||||||
|
begin
|
||||||
|
stats = stat_group.call
|
||||||
|
rescue StandardError => err
|
||||||
|
Discourse.warn_exception(err, message: "Unexpected error when collecting #{plugin_stat_group_name} About stats.")
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
if !stats.key?(:last_day) || !stats.key?("7_days") || !stats.key?("30_days") || !stats.key?(:count)
|
||||||
|
Rails.logger.warn("Plugin stat group #{plugin_stat_group_name} for About stats does not have all required keys, skipping.")
|
||||||
|
else
|
||||||
|
final_plugin_stats.merge!(
|
||||||
|
stats.transform_keys do |key|
|
||||||
|
"#{plugin_stat_group_name}_#{key}".to_sym
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
final_plugin_stats
|
||||||
end
|
end
|
||||||
|
|
||||||
def category_moderators
|
def category_moderators
|
||||||
|
|
|
@ -33,7 +33,8 @@ class SiteSerializer < ApplicationSerializer
|
||||||
:watched_words_replace,
|
:watched_words_replace,
|
||||||
:watched_words_link,
|
:watched_words_link,
|
||||||
:categories,
|
:categories,
|
||||||
:markdown_additional_options
|
:markdown_additional_options,
|
||||||
|
:displayed_about_plugin_stat_groups
|
||||||
)
|
)
|
||||||
|
|
||||||
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
|
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
|
||||||
|
@ -208,6 +209,10 @@ class SiteSerializer < ApplicationSerializer
|
||||||
Site.markdown_additional_options
|
Site.markdown_additional_options
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def displayed_about_plugin_stat_groups
|
||||||
|
About.displayed_plugin_stat_groups
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ordered_flags(flags)
|
def ordered_flags(flags)
|
||||||
|
|
|
@ -100,6 +100,15 @@
|
||||||
<td><%= stats["likes_7_days"] %></td>
|
<td><%= stats["likes_7_days"] %></td>
|
||||||
<td><%= stats["likes_30_days"] %></td>
|
<td><%= stats["likes_30_days"] %></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<% About.displayed_plugin_stat_groups.each do |stat_group_name| %>
|
||||||
|
<tr>
|
||||||
|
<td><%=t "js.about.#{stat_group_name}_count" %></td>
|
||||||
|
<td><%= stats["#{stat_group_name}_count"] %></td>
|
||||||
|
<td><%= stats["#{stat_group_name}_last_day"] %></td>
|
||||||
|
<td><%= stats["#{stat_group_name}_7_days"] %></td>
|
||||||
|
<td><%= stats["#{stat_group_name}_30_days"] %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -1032,6 +1032,47 @@ class Plugin::Instance
|
||||||
DiscoursePluginRegistry.register_email_unsubscriber({ type => unsubscriber }, self)
|
DiscoursePluginRegistry.register_email_unsubscriber({ type => unsubscriber }, self)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Allows the plugin to export additional site stats via the About class
|
||||||
|
# which will be shown on the /about route. The stats returned by the block
|
||||||
|
# should be in the following format (these four keys are _required_):
|
||||||
|
#
|
||||||
|
# {
|
||||||
|
# last_day: 1,
|
||||||
|
# 7_days: 10,
|
||||||
|
# 30_days: 100,
|
||||||
|
# count: 1000
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# Only keys above will be shown on the /about page in the UI,
|
||||||
|
# but all stats will be shown on the /about.json route. For example take
|
||||||
|
# this usage:
|
||||||
|
#
|
||||||
|
# register_about_stat_group("chat_messages") do
|
||||||
|
# { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000, previous_30_days: 150 }
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# In the UI we will show a table like this:
|
||||||
|
#
|
||||||
|
# | 24h | 7 days | 30 days | all time|
|
||||||
|
# Chat Messages | 1 | 10 | 100 | 1000 |
|
||||||
|
#
|
||||||
|
# But the JSON will be like this:
|
||||||
|
#
|
||||||
|
# {
|
||||||
|
# "chat_messages_last_day": 1,
|
||||||
|
# "chat_messages_7_days": 10,
|
||||||
|
# "chat_messages_30_days": 100,
|
||||||
|
# "chat_messages_count": 1000,
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# The show_in_ui option (default false) is used to determine whether the
|
||||||
|
# group of stats is shown on the site About page in the Site Statistics
|
||||||
|
# table. Some stats may be needed purely for reporting purposes and thus
|
||||||
|
# do not need to be shown in the UI to admins/users.
|
||||||
|
def register_about_stat_group(plugin_stat_group_name, show_in_ui: false, &block)
|
||||||
|
About.add_plugin_stat_group(plugin_stat_group_name, show_in_ui: show_in_ui, &block)
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def self.js_path
|
def self.js_path
|
||||||
|
|
|
@ -742,4 +742,35 @@ describe Plugin::Instance do
|
||||||
expect(UnsubscribeKey.get_unsubscribe_strategy_for(key).class).to eq(CustomUnsubscriber)
|
expect(UnsubscribeKey.get_unsubscribe_strategy_for(key).class).to eq(CustomUnsubscriber)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#register_about_stat_group" do
|
||||||
|
let(:plugin) { Plugin::Instance.new }
|
||||||
|
|
||||||
|
after do
|
||||||
|
About.clear_plugin_stat_groups
|
||||||
|
end
|
||||||
|
|
||||||
|
it "registers an about stat group correctly" do
|
||||||
|
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
|
||||||
|
plugin.register_about_stat_group("some_group", show_in_ui: true) do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
expect(About.new.plugin_stats.with_indifferent_access).to match(
|
||||||
|
hash_including(
|
||||||
|
some_group_last_day: 1,
|
||||||
|
some_group_7_days: 10,
|
||||||
|
some_group_30_days: 100,
|
||||||
|
some_group_count: 1000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "hides the stat group from the UI by default" do
|
||||||
|
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
|
||||||
|
plugin.register_about_stat_group("some_group") do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
expect(About.displayed_plugin_stat_groups).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,72 @@ describe About do
|
||||||
include_examples 'stats cacheable'
|
include_examples 'stats cacheable'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#stats" do
|
||||||
|
after do
|
||||||
|
About.clear_plugin_stat_groups
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds plugin stats to the output" do
|
||||||
|
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
|
||||||
|
About.add_plugin_stat_group("some_group", show_in_ui: true) do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
expect(described_class.new.stats.with_indifferent_access).to match(
|
||||||
|
hash_including(
|
||||||
|
some_group_last_day: 1,
|
||||||
|
some_group_7_days: 10,
|
||||||
|
some_group_30_days: 100,
|
||||||
|
some_group_count: 1000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not add plugin stats to the output if they are missing one of the required keys" do
|
||||||
|
stats = { "7_days" => 10, "30_days" => 100, count: 1000 }
|
||||||
|
About.add_plugin_stat_group("some_group", show_in_ui: true) do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
expect(described_class.new.stats).not_to match(
|
||||||
|
hash_including(
|
||||||
|
some_group_last_day: 1,
|
||||||
|
some_group_7_days: 10,
|
||||||
|
some_group_30_days: 100,
|
||||||
|
some_group_count: 1000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not error if any of the plugin stat blocks throw an error and still adds the non-errored stats to output" do
|
||||||
|
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
|
||||||
|
About.add_plugin_stat_group("some_group", show_in_ui: true) do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
About.add_plugin_stat_group("other_group", show_in_ui: true) do
|
||||||
|
raise StandardError
|
||||||
|
end
|
||||||
|
expect(described_class.new.stats.with_indifferent_access).to match(
|
||||||
|
hash_including(
|
||||||
|
some_group_last_day: 1,
|
||||||
|
some_group_7_days: 10,
|
||||||
|
some_group_30_days: 100,
|
||||||
|
some_group_count: 1000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect { described_class.new.stats.with_indifferent_access }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not allow duplicate displayed stat groups" do
|
||||||
|
stats = { last_day: 1, "7_days" => 10, "30_days" => 100, count: 1000 }
|
||||||
|
About.add_plugin_stat_group("some_group", show_in_ui: true) do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
About.add_plugin_stat_group("some_group", show_in_ui: true) do
|
||||||
|
stats
|
||||||
|
end
|
||||||
|
expect(described_class.displayed_plugin_stat_groups).to eq(["some_group"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "#category_moderators" do
|
describe "#category_moderators" do
|
||||||
let(:user) { Fabricate(:user) }
|
let(:user) { Fabricate(:user) }
|
||||||
let(:public_cat_moderator) { Fabricate(:user, last_seen_at: 1.month.ago) }
|
let(:public_cat_moderator) { Fabricate(:user, last_seen_at: 1.month.ago) }
|
||||||
|
|
|
@ -470,6 +470,9 @@
|
||||||
"markdown_additional_options" : {
|
"markdown_additional_options" : {
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"displayed_about_plugin_stat_groups" : {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items":
|
"items":
|
||||||
|
|
Loading…
Reference in New Issue
Block a user