2019-05-12 23:04:27 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2015-06-15 00:24:49 -04:00
|
|
|
# name: discourse-solved
|
2015-05-19 01:45:19 -04:00
|
|
|
# about: Add a solved button to answers on Discourse
|
|
|
|
# version: 0.1
|
|
|
|
# authors: Sam Saffron
|
2017-04-26 00:40:03 -04:00
|
|
|
# url: https://github.com/discourse/discourse-solved
|
2015-05-19 01:45:19 -04:00
|
|
|
|
2017-02-02 12:20:01 -05:00
|
|
|
enabled_site_setting :solved_enabled
|
|
|
|
|
2018-11-07 21:08:46 -05:00
|
|
|
if respond_to?(:register_svg_icon)
|
|
|
|
register_svg_icon "far fa-check-square"
|
|
|
|
register_svg_icon "check-square"
|
|
|
|
register_svg_icon "far fa-square"
|
|
|
|
end
|
|
|
|
|
2015-06-15 00:24:49 -04:00
|
|
|
PLUGIN_NAME = "discourse_solved".freeze
|
2015-05-19 01:45:19 -04:00
|
|
|
|
|
|
|
register_asset 'stylesheets/solutions.scss'
|
2019-05-10 07:07:00 -04:00
|
|
|
register_asset 'stylesheets/mobile/solutions.scss', :mobile
|
2015-05-19 01:45:19 -04:00
|
|
|
|
|
|
|
after_initialize do
|
FEATURE: Publish WebHook event when solving/unsolving (#85)
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
* UX: Add "solved" status filter in advanced search page.
And rename `in:solved` to `status:solved`.
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 13:28:29 -05:00
|
|
|
|
2020-02-04 19:14:12 -05:00
|
|
|
SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-solved", "db", "fixtures").to_s
|
2015-05-19 01:45:19 -04:00
|
|
|
|
2019-03-13 13:10:54 -04:00
|
|
|
[
|
|
|
|
'../app/serializers/concerns/topic_answer_mixin.rb'
|
|
|
|
].each { |path| load File.expand_path(path, __FILE__) }
|
|
|
|
|
2019-06-13 02:33:03 -04:00
|
|
|
skip_db = defined?(GlobalSetting.skip_db?) && GlobalSetting.skip_db?
|
|
|
|
|
2016-12-06 01:28:15 -05:00
|
|
|
# we got to do a one time upgrade
|
2019-06-13 02:33:03 -04:00
|
|
|
if !skip_db && defined?(UserAction::SOLVED)
|
2019-12-03 04:49:58 -05:00
|
|
|
unless Discourse.redis.get('solved_already_upgraded')
|
2016-12-06 01:28:15 -05:00
|
|
|
unless UserAction.where(action_type: UserAction::SOLVED).exists?
|
|
|
|
Rails.logger.info("Upgrading storage for solved")
|
2017-08-02 02:09:07 -04:00
|
|
|
sql = <<SQL
|
2016-12-06 01:28:15 -05:00
|
|
|
INSERT INTO user_actions(action_type,
|
|
|
|
user_id,
|
|
|
|
target_topic_id,
|
|
|
|
target_post_id,
|
|
|
|
acting_user_id,
|
|
|
|
created_at,
|
|
|
|
updated_at)
|
|
|
|
SELECT :solved,
|
|
|
|
p.user_id,
|
|
|
|
p.topic_id,
|
|
|
|
p.id,
|
|
|
|
t.user_id,
|
|
|
|
pc.created_at,
|
|
|
|
pc.updated_at
|
|
|
|
FROM
|
|
|
|
post_custom_fields pc
|
|
|
|
JOIN
|
|
|
|
posts p ON p.id = pc.post_id
|
|
|
|
JOIN
|
|
|
|
topics t ON t.id = p.topic_id
|
|
|
|
WHERE
|
|
|
|
pc.name = 'is_accepted_answer' AND
|
|
|
|
pc.value = 'true' AND
|
|
|
|
p.user_id IS NOT NULL
|
|
|
|
SQL
|
|
|
|
|
2019-10-09 14:56:55 -04:00
|
|
|
DB.exec(sql, solved: UserAction::SOLVED)
|
2016-12-06 01:28:15 -05:00
|
|
|
end
|
2019-12-03 04:49:58 -05:00
|
|
|
Discourse.redis.set("solved_already_upgraded", "true")
|
2016-12-06 01:28:15 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-06-15 00:24:49 -04:00
|
|
|
module ::DiscourseSolved
|
2015-05-19 01:45:19 -04:00
|
|
|
class Engine < ::Rails::Engine
|
|
|
|
engine_name PLUGIN_NAME
|
2015-06-15 00:24:49 -04:00
|
|
|
isolate_namespace DiscourseSolved
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
2015-08-05 02:21:16 -04:00
|
|
|
|
2018-08-31 00:03:42 -04:00
|
|
|
AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD = "solved_auto_close_topic_timer_id".freeze
|
|
|
|
|
2019-03-18 11:27:29 -04:00
|
|
|
def self.accept_answer!(post, acting_user, topic: nil)
|
|
|
|
topic ||= post.topic
|
2015-05-19 01:45:19 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
|
|
|
|
accepted_id = topic.custom_fields["accepted_answer_post_id"].to_i
|
2015-05-19 01:45:19 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
if accepted_id > 0
|
|
|
|
if p2 = Post.find_by(id: accepted_id)
|
2021-05-05 06:19:00 -04:00
|
|
|
p2.custom_fields.delete("is_accepted_answer")
|
2020-08-17 10:33:24 -04:00
|
|
|
p2.save!
|
2016-12-06 01:28:15 -05:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
if defined?(UserAction::SOLVED)
|
|
|
|
UserAction.where(
|
|
|
|
action_type: UserAction::SOLVED,
|
|
|
|
target_post_id: p2.id
|
|
|
|
).destroy_all
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2015-06-15 00:24:49 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
post.custom_fields["is_accepted_answer"] = "true"
|
|
|
|
topic.custom_fields["accepted_answer_post_id"] = post.id
|
2018-08-31 00:03:42 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
if defined?(UserAction::SOLVED)
|
|
|
|
UserAction.log_action!(
|
|
|
|
action_type: UserAction::SOLVED,
|
|
|
|
user_id: post.user_id,
|
|
|
|
acting_user_id: acting_user.id,
|
|
|
|
target_post_id: post.id,
|
|
|
|
target_topic_id: post.topic_id
|
2020-04-26 23:01:01 -04:00
|
|
|
)
|
2020-08-17 10:33:24 -04:00
|
|
|
end
|
|
|
|
|
2020-10-30 12:31:27 -04:00
|
|
|
notification_data = {
|
|
|
|
message: 'solved.accepted_notification',
|
|
|
|
display_username: acting_user.username,
|
|
|
|
topic_title: topic.title
|
|
|
|
}.to_json
|
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
unless acting_user.id == post.user_id
|
|
|
|
Notification.create!(
|
|
|
|
notification_type: Notification.types[:custom],
|
|
|
|
user_id: post.user_id,
|
|
|
|
topic_id: post.topic_id,
|
|
|
|
post_number: post.post_number,
|
2020-10-30 12:31:27 -04:00
|
|
|
data: notification_data
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
if SiteSetting.notify_on_staff_accept_solved && acting_user.id != topic.user_id
|
|
|
|
Notification.create!(
|
|
|
|
notification_type: Notification.types[:custom],
|
|
|
|
user_id: topic.user_id,
|
|
|
|
topic_id: post.topic_id,
|
|
|
|
post_number: post.post_number,
|
|
|
|
data: notification_data
|
2020-04-26 23:01:01 -04:00
|
|
|
)
|
|
|
|
end
|
2017-01-20 09:13:58 -05:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
auto_close_hours = SiteSetting.solved_topics_auto_close_hours
|
|
|
|
|
|
|
|
if (auto_close_hours > 0) && !topic.closed
|
2020-12-02 23:01:42 -05:00
|
|
|
topic_timer = topic.set_or_create_timer(
|
2021-06-28 01:33:25 -04:00
|
|
|
TopicTimer.types[:silent_close],
|
2020-12-02 23:01:42 -05:00
|
|
|
nil,
|
|
|
|
based_on_last_post: true,
|
2021-02-05 00:33:09 -05:00
|
|
|
duration_minutes: auto_close_hours * 60
|
2020-12-02 23:01:42 -05:00
|
|
|
)
|
2018-08-31 00:03:42 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
topic.custom_fields[
|
|
|
|
AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD
|
|
|
|
] = topic_timer.id
|
2017-05-22 05:09:40 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
MessageBus.publish("/topic/#{topic.id}", reload_topic: true)
|
|
|
|
end
|
2018-08-31 00:03:42 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
topic.save!
|
|
|
|
post.save!
|
FEATURE: Publish WebHook event when solving/unsolving (#85)
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
* UX: Add "solved" status filter in advanced search page.
And rename `in:solved` to `status:solved`.
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 13:28:29 -05:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
if WebHook.active_web_hooks(:solved).exists?
|
|
|
|
payload = WebHook.generate_payload(:post, post)
|
|
|
|
WebHook.enqueue_solved_hooks(:accepted_solution, post, payload)
|
|
|
|
end
|
|
|
|
|
|
|
|
DiscourseEvent.trigger(:accepted_solution, post)
|
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
2019-03-18 11:27:29 -04:00
|
|
|
def self.unaccept_answer!(post, topic: nil)
|
|
|
|
topic ||= post.topic
|
2018-08-31 00:03:42 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
|
2021-05-05 06:19:00 -04:00
|
|
|
post.custom_fields.delete("is_accepted_answer")
|
|
|
|
topic.custom_fields.delete("accepted_answer_post_id")
|
2018-08-31 00:03:42 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
if timer_id = topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD]
|
|
|
|
topic_timer = TopicTimer.find_by(id: timer_id)
|
|
|
|
topic_timer.destroy! if topic_timer
|
2021-05-05 06:19:00 -04:00
|
|
|
topic.custom_fields.delete(AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD)
|
2020-08-17 10:33:24 -04:00
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
topic.save!
|
|
|
|
post.save!
|
|
|
|
|
|
|
|
# TODO remove_action! does not allow for this type of interface
|
|
|
|
if defined? UserAction::SOLVED
|
|
|
|
UserAction.where(
|
|
|
|
action_type: UserAction::SOLVED,
|
|
|
|
target_post_id: post.id
|
|
|
|
).destroy_all
|
|
|
|
end
|
|
|
|
|
|
|
|
# yank notification
|
|
|
|
notification = Notification.find_by(
|
|
|
|
notification_type: Notification.types[:custom],
|
|
|
|
user_id: post.user_id,
|
|
|
|
topic_id: post.topic_id,
|
|
|
|
post_number: post.post_number
|
|
|
|
)
|
2016-12-06 01:28:15 -05:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
notification.destroy! if notification
|
2015-06-21 20:06:18 -04:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
if WebHook.active_web_hooks(:solved).exists?
|
|
|
|
payload = WebHook.generate_payload(:post, post)
|
|
|
|
WebHook.enqueue_solved_hooks(:unaccepted_solution, post, payload)
|
|
|
|
end
|
FEATURE: Publish WebHook event when solving/unsolving (#85)
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
* UX: Add "solved" status filter in advanced search page.
And rename `in:solved` to `status:solved`.
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 13:28:29 -05:00
|
|
|
|
2020-08-17 10:33:24 -04:00
|
|
|
DiscourseEvent.trigger(:unaccepted_solution, post)
|
FEATURE: Publish WebHook event when solving/unsolving (#85)
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
* UX: Add "solved" status filter in advanced search page.
And rename `in:solved` to `status:solved`.
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 13:28:29 -05:00
|
|
|
end
|
2018-07-17 20:56:56 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
require_dependency "application_controller"
|
2020-08-17 10:33:24 -04:00
|
|
|
|
2018-07-17 20:56:56 -04:00
|
|
|
class DiscourseSolved::AnswerController < ::ApplicationController
|
|
|
|
|
|
|
|
def accept
|
|
|
|
limit_accepts
|
|
|
|
|
|
|
|
post = Post.find(params[:id].to_i)
|
|
|
|
|
2019-03-18 11:27:29 -04:00
|
|
|
topic = post.topic
|
|
|
|
topic ||= Topic.with_deleted.find(post.topic_id) if guardian.is_staff?
|
|
|
|
|
2020-02-23 03:16:31 -05:00
|
|
|
guardian.ensure_can_accept_answer!(topic, post)
|
2019-03-18 11:27:29 -04:00
|
|
|
|
|
|
|
DiscourseSolved.accept_answer!(post, current_user, topic: topic)
|
2018-07-17 20:56:56 -04:00
|
|
|
|
|
|
|
render json: success_json
|
|
|
|
end
|
|
|
|
|
|
|
|
def unaccept
|
|
|
|
limit_accepts
|
2017-01-20 09:13:58 -05:00
|
|
|
|
2018-07-17 20:56:56 -04:00
|
|
|
post = Post.find(params[:id].to_i)
|
|
|
|
|
2019-03-18 11:27:29 -04:00
|
|
|
topic = post.topic
|
|
|
|
topic ||= Topic.with_deleted.find(post.topic_id) if guardian.is_staff?
|
|
|
|
|
2020-02-23 03:16:31 -05:00
|
|
|
guardian.ensure_can_accept_answer!(topic, post)
|
2019-03-18 11:27:29 -04:00
|
|
|
|
|
|
|
DiscourseSolved.unaccept_answer!(post, topic: topic)
|
2015-05-19 01:45:19 -04:00
|
|
|
render json: success_json
|
|
|
|
end
|
2015-08-05 02:21:16 -04:00
|
|
|
|
|
|
|
def limit_accepts
|
2020-08-17 10:33:24 -04:00
|
|
|
return if current_user.staff?
|
|
|
|
RateLimiter.new(nil, "accept-hr-#{current_user.id}", 20, 1.hour).performed!
|
|
|
|
RateLimiter.new(nil, "accept-min-#{current_user.id}", 4, 30.seconds).performed!
|
2015-08-05 02:21:16 -04:00
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
2015-06-15 00:24:49 -04:00
|
|
|
DiscourseSolved::Engine.routes.draw do
|
2015-05-19 01:45:19 -04:00
|
|
|
post "/accept" => "answer#accept"
|
|
|
|
post "/unaccept" => "answer#unaccept"
|
|
|
|
end
|
|
|
|
|
|
|
|
Discourse::Application.routes.append do
|
2015-06-15 00:24:49 -04:00
|
|
|
mount ::DiscourseSolved::Engine, at: "solution"
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
2020-07-14 19:34:02 -04:00
|
|
|
# TODO Drop after Discourse 2.6.0 release
|
2020-07-26 22:04:28 -04:00
|
|
|
if TopicView.respond_to?(:add_post_custom_fields_allowlister)
|
|
|
|
TopicView.add_post_custom_fields_allowlister do |user|
|
2020-07-14 19:34:02 -04:00
|
|
|
["is_accepted_answer"]
|
|
|
|
end
|
|
|
|
else
|
2020-07-26 22:04:28 -04:00
|
|
|
TopicView.add_post_custom_fields_whitelister do |user|
|
2020-07-14 19:34:02 -04:00
|
|
|
["is_accepted_answer"]
|
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
2020-01-04 08:55:42 -05:00
|
|
|
def get_schema_text(post)
|
|
|
|
post.excerpt(nil, keep_onebox_body: true).presence || post.excerpt(nil, keep_onebox_body: true, keep_quotes: true)
|
|
|
|
end
|
|
|
|
|
2018-11-01 18:38:16 -04:00
|
|
|
def before_head_close_meta(controller)
|
2018-11-01 19:15:05 -04:00
|
|
|
return "" if !controller.instance_of? TopicsController
|
|
|
|
|
|
|
|
topic_view = controller.instance_variable_get(:@topic_view)
|
|
|
|
topic = topic_view&.topic
|
|
|
|
return "" if !topic
|
|
|
|
# note, we have canonicals so we only do this for page 1 at the moment
|
|
|
|
# it can get confusing to have this on every page and it should make page 1
|
|
|
|
# a bit more prominent + cut down on pointless work
|
2018-11-04 18:21:10 -05:00
|
|
|
|
2021-06-17 16:12:33 -04:00
|
|
|
return "" if SiteSetting.solved_add_schema_markup == "never"
|
2018-11-01 19:15:05 -04:00
|
|
|
return "" if !controller.guardian.allow_accepted_answers_on_category?(topic.category_id)
|
|
|
|
|
2018-11-04 18:21:10 -05:00
|
|
|
first_post = topic_view.posts&.first
|
|
|
|
return "" if first_post&.post_number != 1
|
|
|
|
|
|
|
|
question_json = {
|
|
|
|
'@type' => 'Question',
|
|
|
|
'name' => topic.title,
|
2020-01-04 08:55:42 -05:00
|
|
|
'text' => get_schema_text(first_post),
|
2018-11-04 18:21:10 -05:00
|
|
|
'upvoteCount' => first_post.like_count,
|
2019-02-19 14:05:07 -05:00
|
|
|
'answerCount' => 0,
|
2018-11-04 18:21:10 -05:00
|
|
|
'dateCreated' => topic.created_at,
|
|
|
|
'author' => {
|
|
|
|
'@type' => 'Person',
|
|
|
|
'name' => topic.user&.name
|
2018-11-01 18:38:16 -04:00
|
|
|
}
|
2018-11-04 18:21:10 -05:00
|
|
|
}
|
2018-11-01 19:15:05 -04:00
|
|
|
|
2018-11-01 18:38:16 -04:00
|
|
|
if accepted_answer = Post.find_by(id: topic.custom_fields["accepted_answer_post_id"])
|
2019-02-19 14:05:07 -05:00
|
|
|
question_json['answerCount'] = 1
|
2018-11-01 18:38:16 -04:00
|
|
|
question_json[:acceptedAnswer] = {
|
|
|
|
'@type' => 'Answer',
|
2020-01-04 08:55:42 -05:00
|
|
|
'text' => get_schema_text(accepted_answer),
|
2018-11-01 19:15:05 -04:00
|
|
|
'upvoteCount' => accepted_answer.like_count,
|
|
|
|
'dateCreated' => accepted_answer.created_at,
|
|
|
|
'url' => accepted_answer.full_url,
|
2018-11-01 18:38:16 -04:00
|
|
|
'author' => {
|
|
|
|
'@type' => 'Person',
|
2018-11-01 19:15:05 -04:00
|
|
|
'name' => accepted_answer.user&.username
|
2018-11-01 18:38:16 -04:00
|
|
|
}
|
|
|
|
}
|
2021-06-17 16:12:33 -04:00
|
|
|
else
|
|
|
|
return "" if SiteSetting.solved_add_schema_markup == "answered only"
|
2018-11-01 18:38:16 -04:00
|
|
|
end
|
2018-11-01 19:15:05 -04:00
|
|
|
|
2018-11-01 18:38:16 -04:00
|
|
|
['<script type="application/ld+json">', MultiJson.dump(
|
|
|
|
'@context' => 'http://schema.org',
|
2018-12-04 15:04:54 -05:00
|
|
|
'@type' => 'QAPage',
|
|
|
|
'name' => topic&.title,
|
|
|
|
'mainEntity' => question_json
|
2018-11-01 19:15:05 -04:00
|
|
|
).gsub("</", "<\\/").html_safe, '</script>'].join("")
|
2018-11-01 18:38:16 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
register_html_builder('server:before-head-close-crawler') do |controller|
|
|
|
|
before_head_close_meta(controller)
|
|
|
|
end
|
|
|
|
|
|
|
|
register_html_builder('server:before-head-close') do |controller|
|
|
|
|
before_head_close_meta(controller)
|
|
|
|
end
|
|
|
|
|
2015-06-24 20:41:24 -04:00
|
|
|
if Report.respond_to?(:add_report)
|
|
|
|
Report.add_report("accepted_solutions") do |report|
|
|
|
|
report.data = []
|
2019-05-01 16:24:09 -04:00
|
|
|
|
2015-06-24 20:41:24 -04:00
|
|
|
accepted_solutions = TopicCustomField.where(name: "accepted_answer_post_id")
|
2019-05-01 16:24:09 -04:00
|
|
|
|
2020-04-24 06:16:15 -04:00
|
|
|
category_id, include_subcategories = report.add_category_filter
|
|
|
|
if category_id
|
|
|
|
if include_subcategories
|
|
|
|
accepted_solutions = accepted_solutions.joins(:topic).where('topics.category_id IN (?)', Category.subcategory_ids(category_id))
|
|
|
|
else
|
|
|
|
accepted_solutions = accepted_solutions.joins(:topic).where('topics.category_id = ?', category_id)
|
2019-05-01 16:24:09 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-06-24 20:41:24 -04:00
|
|
|
accepted_solutions.where("topic_custom_fields.created_at >= ?", report.start_date)
|
2017-08-02 02:09:07 -04:00
|
|
|
.where("topic_custom_fields.created_at <= ?", report.end_date)
|
|
|
|
.group("DATE(topic_custom_fields.created_at)")
|
|
|
|
.order("DATE(topic_custom_fields.created_at)")
|
|
|
|
.count
|
|
|
|
.each do |date, count|
|
2015-06-24 20:41:24 -04:00
|
|
|
report.data << { x: date, y: count }
|
|
|
|
end
|
|
|
|
report.total = accepted_solutions.count
|
|
|
|
report.prev30Days = accepted_solutions.where("topic_custom_fields.created_at >= ?", report.start_date - 30.days)
|
2017-08-02 02:09:07 -04:00
|
|
|
.where("topic_custom_fields.created_at <= ?", report.start_date)
|
|
|
|
.count
|
2015-06-24 20:41:24 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2016-12-06 01:28:15 -05:00
|
|
|
if defined?(UserAction::SOLVED)
|
|
|
|
require_dependency 'user_summary'
|
|
|
|
class ::UserSummary
|
|
|
|
def solved_count
|
|
|
|
UserAction
|
|
|
|
.where(user: @user)
|
|
|
|
.where(action_type: UserAction::SOLVED)
|
|
|
|
.count
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
require_dependency 'user_summary_serializer'
|
|
|
|
class ::UserSummarySerializer
|
|
|
|
attributes :solved_count
|
|
|
|
|
|
|
|
def solved_count
|
|
|
|
object.solved_count
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
FEATURE: Publish WebHook event when solving/unsolving (#85)
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
* UX: Add "solved" status filter in advanced search page.
And rename `in:solved` to `status:solved`.
* FEATURE: Publish WebHook event when solving/unsolving
This feature will publish a post edit webhook event whenever a solution
is accepted or unaccepted.
I went ahead and used the existing post-edit webhook because all the
post custom fields for the solved plugin are already included in the
post-edit serializer.
* Create Solved Event Webhook
This commit adds a solved event webhook that will only trigger when an
answer has been marked as accepted or unaccepted.
It uses 100 as the webhook ID. This way any new webhooks in core can
keep using lower numbers like 11, 12, 13, but plugins can use 101, 102,
etc.
* Removed functionality that was added to core
This [PR][1] to discourse core adds what what removed in this commit.
It is better to have this logic in core so that it is discoverable and
future webhooks won't end up accidentally using the same ID.
[1]: https://github.com/discourse/discourse/pull/9110
Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 13:28:29 -05:00
|
|
|
class ::WebHook
|
|
|
|
def self.enqueue_solved_hooks(event, post, payload = nil)
|
|
|
|
if active_web_hooks('solved').exists? && post.present?
|
|
|
|
payload ||= WebHook.generate_payload(:post, post)
|
|
|
|
|
|
|
|
WebHook.enqueue_hooks(:solved, event,
|
|
|
|
id: post.id,
|
|
|
|
category_id: post.topic&.category_id,
|
|
|
|
tag_ids: post.topic&.tags&.pluck(:id),
|
|
|
|
payload: payload
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-05-19 01:45:19 -04:00
|
|
|
require_dependency 'topic_view_serializer'
|
|
|
|
class ::TopicViewSerializer
|
|
|
|
attributes :accepted_answer
|
|
|
|
|
|
|
|
def include_accepted_answer?
|
|
|
|
accepted_answer_post_id
|
|
|
|
end
|
|
|
|
|
|
|
|
def accepted_answer
|
|
|
|
if info = accepted_answer_post_info
|
|
|
|
{
|
|
|
|
post_number: info[0],
|
|
|
|
username: info[1],
|
2017-03-08 10:21:41 -05:00
|
|
|
excerpt: info[2]
|
2015-05-19 01:45:19 -04:00
|
|
|
}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def accepted_answer_post_info
|
|
|
|
# TODO: we may already have it in the stream ... so bypass query here
|
2017-03-08 10:42:41 -05:00
|
|
|
postInfo = Post.where(id: accepted_answer_post_id, topic_id: object.topic.id)
|
2017-08-02 02:09:07 -04:00
|
|
|
.joins(:user)
|
|
|
|
.pluck('post_number', 'username', 'cooked')
|
|
|
|
.first
|
2017-05-22 05:09:40 -04:00
|
|
|
|
2017-03-08 10:47:18 -05:00
|
|
|
if postInfo
|
2017-08-06 17:22:30 -04:00
|
|
|
postInfo[2] = if SiteSetting.solved_quote_length > 0
|
2017-09-04 08:07:21 -04:00
|
|
|
PrettyText.excerpt(postInfo[2], SiteSetting.solved_quote_length, keep_emoji_images: true)
|
2017-08-11 15:36:38 -04:00
|
|
|
else
|
|
|
|
nil
|
|
|
|
end
|
2019-12-09 20:01:54 -05:00
|
|
|
postInfo
|
2017-03-08 10:47:18 -05:00
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def accepted_answer_post_id
|
|
|
|
id = object.topic.custom_fields["accepted_answer_post_id"]
|
2015-08-05 02:21:16 -04:00
|
|
|
# a bit messy but race conditions can give us an array here, avoid
|
|
|
|
id && id.to_i rescue nil
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
end
|
|
|
|
|
2015-06-09 16:09:20 -04:00
|
|
|
class ::Category
|
|
|
|
after_save :reset_accepted_cache
|
|
|
|
|
|
|
|
protected
|
|
|
|
def reset_accepted_cache
|
|
|
|
::Guardian.reset_accepted_answer_cache
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
class ::Guardian
|
|
|
|
|
|
|
|
@@allowed_accepted_cache = DistributedCache.new("allowed_accepted")
|
|
|
|
|
|
|
|
def self.reset_accepted_answer_cache
|
|
|
|
@@allowed_accepted_cache["allowed"] =
|
|
|
|
begin
|
|
|
|
Set.new(
|
|
|
|
CategoryCustomField
|
|
|
|
.where(name: "enable_accepted_answers", value: "true")
|
|
|
|
.pluck(:category_id)
|
|
|
|
)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def allow_accepted_answers_on_category?(category_id)
|
2015-06-19 02:08:43 -04:00
|
|
|
return true if SiteSetting.allow_solved_on_all_topics
|
|
|
|
|
2015-06-09 16:09:20 -04:00
|
|
|
self.class.reset_accepted_answer_cache unless @@allowed_accepted_cache["allowed"]
|
|
|
|
@@allowed_accepted_cache["allowed"].include?(category_id)
|
|
|
|
end
|
|
|
|
|
2020-02-23 03:16:31 -05:00
|
|
|
def can_accept_answer?(topic, post)
|
|
|
|
return false if !authenticated?
|
|
|
|
return false if !topic || !post || post.whisper?
|
|
|
|
return false if !allow_accepted_answers_on_category?(topic.category_id)
|
|
|
|
|
|
|
|
return true if is_staff?
|
|
|
|
return true if current_user.trust_level >= SiteSetting.accept_all_solutions_trust_level
|
|
|
|
|
2020-07-27 17:42:42 -04:00
|
|
|
if respond_to? :can_perform_action_available_to_group_moderators?
|
|
|
|
return true if can_perform_action_available_to_group_moderators?(topic)
|
|
|
|
end
|
|
|
|
|
2021-06-17 16:12:33 -04:00
|
|
|
topic.user_id == current_user.id && !topic.closed && SiteSetting.accept_solutions_topic_author
|
2015-06-09 16:09:20 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-05-19 01:45:19 -04:00
|
|
|
require_dependency 'post_serializer'
|
|
|
|
class ::PostSerializer
|
|
|
|
attributes :can_accept_answer, :can_unaccept_answer, :accepted_answer
|
|
|
|
|
|
|
|
def can_accept_answer
|
2020-08-17 10:33:24 -04:00
|
|
|
if topic = (topic_view && topic_view.topic) || object.topic
|
2020-02-23 03:16:31 -05:00
|
|
|
return scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
2016-02-17 15:58:32 -05:00
|
|
|
|
|
|
|
false
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def can_unaccept_answer
|
2020-08-17 10:33:24 -04:00
|
|
|
if topic = (topic_view && topic_view.topic) || object.topic
|
2020-02-23 03:16:31 -05:00
|
|
|
scope.can_accept_answer?(topic, object) && (post_custom_fields["is_accepted_answer"] == 'true')
|
2015-06-09 16:09:20 -04:00
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def accepted_answer
|
2016-02-17 15:58:32 -05:00
|
|
|
post_custom_fields["is_accepted_answer"] == 'true'
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|
2015-06-15 02:26:40 -04:00
|
|
|
end
|
2015-06-22 22:31:10 -04:00
|
|
|
|
|
|
|
require_dependency 'search'
|
|
|
|
|
|
|
|
#TODO Remove when plugin is 1.0
|
|
|
|
if Search.respond_to? :advanced_filter
|
2020-03-05 22:27:38 -05:00
|
|
|
Search.advanced_filter(/status:solved/) do |posts|
|
2015-06-22 22:31:10 -04:00
|
|
|
posts.where("topics.id IN (
|
|
|
|
SELECT tc.topic_id
|
|
|
|
FROM topic_custom_fields tc
|
|
|
|
WHERE tc.name = 'accepted_answer_post_id' AND
|
|
|
|
tc.value IS NOT NULL
|
|
|
|
)")
|
|
|
|
|
|
|
|
end
|
2015-06-22 22:56:22 -04:00
|
|
|
|
2020-03-05 22:27:38 -05:00
|
|
|
Search.advanced_filter(/status:unsolved/) do |posts|
|
2015-06-22 22:56:22 -04:00
|
|
|
posts.where("topics.id NOT IN (
|
|
|
|
SELECT tc.topic_id
|
|
|
|
FROM topic_custom_fields tc
|
|
|
|
WHERE tc.name = 'accepted_answer_post_id' AND
|
|
|
|
tc.value IS NOT NULL
|
|
|
|
)")
|
|
|
|
|
|
|
|
end
|
2015-06-22 22:31:10 -04:00
|
|
|
end
|
2015-06-15 02:26:40 -04:00
|
|
|
|
2017-02-27 14:51:50 -05:00
|
|
|
if Discourse.has_needed_version?(Discourse::VERSION::STRING, '1.8.0.beta6')
|
|
|
|
require_dependency 'topic_query'
|
|
|
|
|
|
|
|
TopicQuery.add_custom_filter(:solved) do |results, topic_query|
|
2017-02-28 10:22:07 -05:00
|
|
|
if topic_query.options[:solved] == 'yes'
|
2017-02-27 14:51:50 -05:00
|
|
|
results = results.where("topics.id IN (
|
|
|
|
SELECT tc.topic_id
|
|
|
|
FROM topic_custom_fields tc
|
|
|
|
WHERE tc.name = 'accepted_answer_post_id' AND
|
|
|
|
tc.value IS NOT NULL
|
|
|
|
)")
|
2017-02-28 10:22:07 -05:00
|
|
|
elsif topic_query.options[:solved] == 'no'
|
2017-02-27 14:51:50 -05:00
|
|
|
results = results.where("topics.id NOT IN (
|
|
|
|
SELECT tc.topic_id
|
|
|
|
FROM topic_custom_fields tc
|
|
|
|
WHERE tc.name = 'accepted_answer_post_id' AND
|
|
|
|
tc.value IS NOT NULL
|
|
|
|
)")
|
|
|
|
end
|
|
|
|
results
|
2017-02-27 13:19:21 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-06-15 02:26:40 -04:00
|
|
|
require_dependency 'topic_list_item_serializer'
|
2019-03-13 13:10:54 -04:00
|
|
|
require_dependency 'search_topic_list_item_serializer'
|
|
|
|
require_dependency 'suggested_topic_serializer'
|
|
|
|
require_dependency 'user_summary_serializer'
|
2015-06-15 02:26:40 -04:00
|
|
|
|
|
|
|
class ::TopicListItemSerializer
|
2019-03-13 13:10:54 -04:00
|
|
|
include TopicAnswerMixin
|
2019-03-13 06:29:07 -04:00
|
|
|
end
|
2017-04-18 23:55:15 -04:00
|
|
|
|
2019-03-13 13:10:54 -04:00
|
|
|
class ::SearchTopicListItemSerializer
|
|
|
|
include TopicAnswerMixin
|
|
|
|
end
|
2019-03-13 10:08:31 -04:00
|
|
|
|
2019-03-13 13:10:54 -04:00
|
|
|
class ::SuggestedTopicSerializer
|
|
|
|
include TopicAnswerMixin
|
|
|
|
end
|
2019-03-13 10:08:31 -04:00
|
|
|
|
2019-03-13 13:10:54 -04:00
|
|
|
class ::UserSummarySerializer::TopicSerializer
|
|
|
|
include TopicAnswerMixin
|
2017-04-18 23:55:15 -04:00
|
|
|
end
|
|
|
|
|
2019-05-10 04:43:11 -04:00
|
|
|
class ::ListableTopicSerializer
|
|
|
|
include TopicAnswerMixin
|
|
|
|
end
|
|
|
|
|
2015-08-05 02:21:16 -04:00
|
|
|
TopicList.preloaded_custom_fields << "accepted_answer_post_id" if TopicList.respond_to? :preloaded_custom_fields
|
2019-03-16 13:57:31 -04:00
|
|
|
Site.preloaded_category_custom_fields << "enable_accepted_answers" if Site.respond_to? :preloaded_category_custom_fields
|
2019-03-17 13:50:05 -04:00
|
|
|
Search.preloaded_topic_custom_fields << "accepted_answer_post_id" if Search.respond_to? :preloaded_topic_custom_fields
|
2015-06-15 02:26:40 -04:00
|
|
|
|
2017-08-01 04:25:54 -04:00
|
|
|
if CategoryList.respond_to?(:preloaded_topic_custom_fields)
|
|
|
|
CategoryList.preloaded_topic_custom_fields << "accepted_answer_post_id"
|
|
|
|
end
|
|
|
|
|
2018-07-17 20:56:56 -04:00
|
|
|
on(:filter_auto_bump_topics) do |_category, filters|
|
|
|
|
filters.push(->(r) { r.where(<<~SQL)
|
|
|
|
NOT EXISTS(
|
|
|
|
SELECT 1 FROM topic_custom_fields
|
|
|
|
WHERE topic_id = topics.id
|
|
|
|
AND name = 'accepted_answer_post_id'
|
2019-08-06 06:19:33 -04:00
|
|
|
AND value IS NOT NULL
|
2018-07-17 20:56:56 -04:00
|
|
|
)
|
|
|
|
SQL
|
|
|
|
})
|
|
|
|
end
|
2021-01-20 05:26:37 -05:00
|
|
|
|
|
|
|
on(:before_post_publish_changes) do |post_changes, topic_changes, options|
|
|
|
|
category_id_changes = topic_changes.diff["category_id"]
|
|
|
|
next if category_id_changes.blank?
|
|
|
|
|
|
|
|
old_category_allows = Guardian.new.allow_accepted_answers_on_category?(category_id_changes[0])
|
|
|
|
new_category_allows = Guardian.new.allow_accepted_answers_on_category?(category_id_changes[1])
|
|
|
|
|
|
|
|
options[:refresh_stream] = true if old_category_allows != new_category_allows
|
|
|
|
end
|
2021-06-14 06:36:55 -04:00
|
|
|
|
2021-06-22 14:18:23 -04:00
|
|
|
query = "
|
|
|
|
WITH x AS (SELECT
|
|
|
|
u.id user_id,
|
|
|
|
COUNT(DISTINCT ua.id) AS solutions
|
|
|
|
FROM users AS u
|
|
|
|
LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id AND ua.action_type = #{UserAction::SOLVED} AND COALESCE(ua.created_at, :since) > :since
|
|
|
|
WHERE u.active
|
|
|
|
AND u.silenced_till IS NULL
|
|
|
|
AND u.id > 0
|
|
|
|
GROUP BY u.id
|
|
|
|
)
|
|
|
|
UPDATE directory_items di SET
|
|
|
|
solutions = x.solutions
|
|
|
|
FROM x
|
|
|
|
WHERE x.user_id = di.user_id
|
|
|
|
AND di.period_type = :period_type
|
|
|
|
AND di.solutions <> x.solutions
|
|
|
|
"
|
2021-06-24 02:41:51 -04:00
|
|
|
if respond_to?(:add_directory_column)
|
|
|
|
add_directory_column("solutions", query: query)
|
|
|
|
end
|
2021-06-22 14:18:23 -04:00
|
|
|
|
2021-06-14 06:36:55 -04:00
|
|
|
add_to_class(:composer_messages_finder, :check_topic_is_solved) do
|
|
|
|
return if !SiteSetting.solved_enabled || SiteSetting.disable_solved_education_message
|
|
|
|
return if !replying? || @topic.blank? || @topic.private_message?
|
|
|
|
return if @topic.custom_fields["accepted_answer_post_id"].blank?
|
|
|
|
|
|
|
|
{
|
|
|
|
id: 'solved_topic',
|
|
|
|
templateName: 'education',
|
|
|
|
wait_for_typing: false,
|
|
|
|
extraClass: 'education-message',
|
|
|
|
body: PrettyText.cook(I18n.t('education.topic_is_solved', base_url: Discourse.base_url))
|
|
|
|
}
|
|
|
|
end
|
2015-05-19 01:45:19 -04:00
|
|
|
end
|