diff --git a/assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6 b/assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6
new file mode 100644
index 0000000..5345a3a
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6
@@ -0,0 +1,7 @@
+export default {
+ setupComponent(attrs) {
+ if (!attrs.category.custom_fields) {
+ attrs.category.custom_fields = {};
+ }
+ }
+};
diff --git a/assets/javascripts/discourse/initializers/qa-edits.js.es6 b/assets/javascripts/discourse/initializers/qa-edits.js.es6
index 49f0f55..4979866 100644
--- a/assets/javascripts/discourse/initializers/qa-edits.js.es6
+++ b/assets/javascripts/discourse/initializers/qa-edits.js.es6
@@ -4,6 +4,8 @@ import { h } from 'virtual-dom';
import { avatarImg, avatarFor } from 'discourse/widgets/post';
import { dateNode, numberNode } from 'discourse/helpers/node';
import { REPLY } from "discourse/models/composer";
+import { undoVote, whoVoted, voteActionId } from '../lib/qa-utilities';
+import { avatarAtts } from 'discourse/widgets/actions-summary';
export default {
name: 'qa-edits',
@@ -144,14 +146,45 @@ export default {
});
api.attachWidgetAction('post', 'undoPostAction', function(typeId) {
- const post = this.model;
- if (typeId === 5) {
+
+ if (typeId === voteActionId) {
+ const post = this.model;
+ const user = this.currentUser;
+
post.set('topic.voted', false);
+
+ let vote = {
+ user_id: user.id,
+ post_id: post.id,
+ direction: 'up'
+ }
+
+ undoVote({ vote });
+ } else {
+ this._super(typeId);
+ }
+ });
+
+ api.reopenWidget('actions-summary-item', {
+ whoActed() {
+ const attrs = this.attrs;
+
+ if (attrs.id === voteActionId) {
+ whoVoted({
+ post_id: attrs.postId
+ }).then(result => {
+ if (result.voters) {
+ this.state.users = result.voters.map(avatarAtts);
+ this.scheduleRerender();
+ }
+ });
+ } else {
+ this._super();
+ }
}
- return post.get('actions_summary').findBy('id', typeId).undo(post);
});
- api.modifyClass('model:topic', {
+ api.modifyClass('model:topic', {
@computed('qa_enabled')
showQaTip(qaEnabled) {
return qaEnabled && this.siteSettings.qa_show_topic_tip;
diff --git a/assets/javascripts/discourse/lib/qa-utilities.js.es6 b/assets/javascripts/discourse/lib/qa-utilities.js.es6
new file mode 100644
index 0000000..b00891b
--- /dev/null
+++ b/assets/javascripts/discourse/lib/qa-utilities.js.es6
@@ -0,0 +1,28 @@
+import { popupAjaxError } from 'discourse/lib/ajax-error';
+import { ajax } from 'discourse/lib/ajax';
+
+const voteActionId = 100;
+
+const vote = function(type, data) {
+ return ajax('/qa/vote', {
+ type,
+ data
+ }).catch(popupAjaxError)
+}
+
+const undoVote = function(data) {
+ return vote('DELETE', data);
+}
+
+const castVote = function(data) {
+ return vote('POST', data);
+}
+
+const whoVoted = function(data) {
+ return ajax('/qa/voters', {
+ type: 'GET',
+ data
+ }).catch(popupAjaxError)
+}
+
+export { undoVote, castVote, voteActionId, whoVoted };
diff --git a/assets/javascripts/discourse/widgets/qa-button.js.es6 b/assets/javascripts/discourse/widgets/qa-button.js.es6
index c7d1a31..7f777e2 100644
--- a/assets/javascripts/discourse/widgets/qa-button.js.es6
+++ b/assets/javascripts/discourse/widgets/qa-button.js.es6
@@ -8,6 +8,6 @@ export default createWidget('qa-button', {
},
click() {
- this.sendWidgetAction('vote');
+ this.sendWidgetAction('vote', this.attrs.direction);
}
});
diff --git a/assets/javascripts/discourse/widgets/qa-post.js.es6 b/assets/javascripts/discourse/widgets/qa-post.js.es6
index eedf724..51cc5f6 100644
--- a/assets/javascripts/discourse/widgets/qa-post.js.es6
+++ b/assets/javascripts/discourse/widgets/qa-post.js.es6
@@ -1,4 +1,5 @@
import { createWidget } from 'discourse/widgets/widget';
+import { castVote } from '../lib/qa-utilities';
import { h } from 'virtual-dom';
export default createWidget('qa-post', {
@@ -17,17 +18,27 @@ export default createWidget('qa-post', {
return contents;
},
- vote() {
+ vote(direction) {
const post = this.attrs.post;
+ const user = this.currentUser;
+
if (post.get('topic.voted')) {
return bootbox.alert(I18n.t('vote.already_voted'));
}
- if (!this.currentUser) {
+
+ if (!user) {
return this.sendShowLogin();
}
+
post.set('topic.voted', true);
- const voteAction = post.get('actions_summary').findBy('id', 5);
- voteAction.act(post);
+
+ let vote = {
+ user_id: user.id,
+ post_id: post.id,
+ direction
+ };
+
+ castVote({ vote });
}
});
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 82db028..5e6b6b3 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1,5 +1,8 @@
en:
js:
+ category:
+ enable_qa: "Make all topics in this category QnA."
+
composer:
composer_actions:
reply_to_question:
@@ -8,6 +11,24 @@ en:
comment_on_answer:
label: Comment on answer of %{postUsername}
desc: Comment on an answer
+
+ post:
+ actions:
+ undo:
+ vote: "Undo vote"
+ people:
+ vote: "voted for this"
+ by_you:
+ vote: "You voted for this post"
+ by_you_and_others:
+ vote:
+ one: "You and 1 other voted for this post"
+ other: "You and {{count}} other people voted for this post"
+ by_others:
+ vote:
+ one: "1 person voted for this post"
+ other: "{{count}} people voted for this post"
+
topic:
answer:
title: 'Answer'
@@ -22,8 +43,7 @@ en:
details: "Users can vote for the response that best answers the initial post.
- Each user is allowed one vote.
- Responses are ordered by vote count.
"
- category:
- enable_qa: "Make all topics in this category QnA."
+
vote:
already_voted: "You can only vote once per question."
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index d9bc6ca..1ec3f55 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -5,5 +5,16 @@ en:
qa_enabled: "Enable QA Plugin"
qa_disable_like_on_answers: "Disables like button on answers in QnA topics"
qa_undo_vote_action_window: "Number of minutes users are allowed to undo votes in QnA topics (enter 0 for no limit)"
+
+ post_action_types:
+ vote:
+ title: 'Vote'
+ description: 'Vote for this post'
+ short_description: 'Vote for this post'
+ long_form: 'voted for this post'
+
vote:
- already_voted: "You can only vote once per question."
+ error:
+ user_has_not_voted: "User has not voted."
+ already_voted: "You can only vote once per question."
+ undo_vote_action_window: "You can only undo votes %{minutes} after voting."
diff --git a/db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb b/db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb
new file mode 100644
index 0000000..d4951c3
--- /dev/null
+++ b/db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb
@@ -0,0 +1,47 @@
+class SaveExistingPostVoteCountsToCustomFields < ActiveRecord::Migration[5.2]
+ def up
+ vote_totals = {}
+
+ PostAction.where(post_action_type_id: 5).each do |action|
+ if post = Post.find_by(id: action[:post_id])
+ votes = post.vote_history
+
+ votes.push(
+ "direction": QuestionAnswer::Vote::UP,
+ "action": QuestionAnswer::Vote::CREATE,
+ "user_id": action[:user_id].to_s,
+ "created_at": action[:created_at]
+ )
+
+ post.custom_fields['vote_history'] = votes.to_json
+ post.save_custom_fields(true)
+ end
+
+ total = vote_totals[action[:post_id]]
+ total = { count: 0, voted: [] } if total == nil
+
+ total[:count] += 1
+
+ voted = total[:voted]
+ voted.push(action[:user_id])
+ total[:voted] = voted
+
+ vote_totals[action[:post_id]] = total
+ end
+
+ if vote_totals.any?
+ vote_totals.each do |k, v|
+ if post = Post.find_by(id: k)
+ post.custom_fields['vote_history'] = post.vote_history.to_json
+ post.custom_fields['vote_count'] = v[:count].to_i
+ post.custom_fields['voted'] = v[:voted]
+ post.save_custom_fields(true)
+ end
+ end
+ end
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+end
diff --git a/lib/qa.rb b/lib/qa.rb
new file mode 100644
index 0000000..9c8c860
--- /dev/null
+++ b/lib/qa.rb
@@ -0,0 +1,177 @@
+module ::QuestionAnswer
+ class Engine < ::Rails::Engine
+ engine_name 'question_answer'
+ isolate_namespace QuestionAnswer
+ end
+end
+
+QuestionAnswer::Engine.routes.draw do
+ resource :vote
+ get 'voters' => 'votes#voters'
+end
+
+Discourse::Application.routes.append do
+ mount ::QuestionAnswer::Engine, at: 'qa'
+end
+
+require_dependency 'post_action_user_serializer'
+class QuestionAnswer::VoterSerializer < ::PostActionUserSerializer
+ def post_url
+ nil
+ end
+end
+
+Voter = Struct.new(:user)
+
+require_dependency 'application_controller'
+class QuestionAnswer::VotesController < ::ApplicationController
+ before_action :ensure_logged_in
+ before_action :find_vote_post
+ before_action :find_vote_user, only: [:create, :destroy]
+ before_action :ensure_qa_enabled, only: [:create, :destroy]
+ before_action :ensure_can_act, only: [:create, :destroy]
+
+ def create
+ if QuestionAnswer::Vote.vote(@post, @user, vote_args)
+ render json: success_json.merge(
+ vote_count: @post.vote_count
+ )
+ else
+ render json: failed_json, status: 422
+ end
+ end
+
+ def destroy
+ if QuestionAnswer::Vote.vote(@post, @user, vote_args)
+ render json: success_json.merge(
+ vote_count: @post.vote_count
+ )
+ else
+ render json: failed_json, status: 422
+ end
+ end
+
+ def voters
+ voters = []
+
+ if @post.voted.any?
+ @post.voted.each do |user_id|
+ if user = User.find_by(id: user_id)
+ voters.push(Voter.new(user))
+ end
+ end
+ end
+
+ render_json_dump(voters: serialize_data(voters, QuestionAnswer::VoterSerializer))
+ end
+
+ private
+
+ def vote_params
+ params.require(:vote).permit(:post_id, :user_id, :direction)
+ end
+
+ def vote_args
+ {
+ direction: vote_params[:direction],
+ action: self.action_name
+ }
+ end
+
+ def find_vote_post
+ if params[:vote].present?
+ post_id = vote_params[:post_id]
+ else
+ params.require(:post_id)
+ post_id = params[:post_id]
+ end
+
+ if post = Post.find_by(id: post_id)
+ @post = post
+ else
+ raise Discourse::NotFound
+ end
+ end
+
+ def find_vote_user
+ if vote_params[:user_id] && user = User.find_by(id: vote_params[:user_id])
+ @user = user
+ else
+ raise Discourse::NotFound
+ end
+ end
+
+ def ensure_qa_enabled
+ Topic.qa_enabled(@post.topic)
+ end
+
+ def ensure_can_act
+ if Topic.voted(@post.topic, @user)
+ if self.action_name === QuestionAnswer::Vote::CREATE
+ raise Discourse::InvalidAccess.new, I18n.t('vote.error.alread_voted')
+ end
+
+ if self.action_name === QuestionAnswer::Vote::DESTROY && !QuestionAnswer::Vote.can_undo(@post, @user)
+ raise Discourse::InvalidAccess.new, I18n.t('vote.error.undo_vote_action_window',
+ minutes: SiteSetting.qa_undo_vote_action_window
+ )
+ end
+ elsif self.action_name === QuestionAnswer::Vote::DESTROY
+ raise Discourse::InvalidAccess.new, I18n.t('vote.error.user_has_not_voted')
+ end
+ end
+end
+
+class QuestionAnswer::Vote
+ CREATE = 'create'
+ DESTROY = 'destroy'
+ UP = 'up'
+ DOWN = 'down'
+
+ def self.vote(post, user, args)
+ modifier = 0
+
+ if args[:direction] === UP
+ modifier = args[:action] === CREATE ? 1 : -1
+ end
+
+ post.custom_fields['vote_count'] = post.vote_count + modifier
+
+ voted = post.voted
+
+ if args[:direction] === UP
+ if args[:action] === CREATE
+ voted.push(user.id)
+ elsif args[:action] === DESTROY
+ voted.delete(user.id)
+ end
+ end
+
+ post.custom_fields['voted'] = voted
+
+ votes = post.vote_history
+
+ votes.push(
+ direction: args[:direction],
+ action: args[:action],
+ user_id: user.id,
+ created_at: Time.now
+ )
+
+ post.custom_fields['vote_history'] = votes.to_json
+
+ if post.save_custom_fields(true)
+ Topic.update_vote_order(post.topic)
+ post.publish_change_to_clients! :acted
+
+ true
+ else
+ false
+ end
+ end
+
+ def self.can_undo(post, user)
+ window = SiteSetting.qa_undo_vote_action_window.to_i
+ window === 0 || post.last_voted(user.id).to_i > window.minutes.ago.to_i
+ end
+end
diff --git a/lib/qa_helper.rb b/lib/qa_helper.rb
deleted file mode 100644
index 6e56699..0000000
--- a/lib/qa_helper.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-module QAHelper
- class << self
- def qa_enabled(topic)
- return false if !SiteSetting.qa_enabled
- return false if !topic || !topic.respond_to?(:is_category_topic?) || topic.is_category_topic?
-
- tags = topic.tags.map(&:name)
- has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty?
- is_qa_category = topic.category && topic.category.custom_fields["qa_enabled"]
- is_qa_subtype = topic.subtype == 'question'
- has_qa_tag || is_qa_category || is_qa_subtype
- end
-
- ## This should be replaced with a :voted? property in TopicUser - but how to do this properly in a plugin?
- def user_has_voted(topic, user)
- return nil if !user || !SiteSetting.qa_enabled
-
- PostAction.exists?(post_id: topic.posts.map(&:id),
- user_id: user.id,
- post_action_type_id: PostActionType.types[:vote])
- end
-
- def update_order(topic_id)
- return if !SiteSetting.qa_enabled
-
- posts = Post.where(topic_id: topic_id)
-
- answers = posts.where(reply_to_post_number: [nil, ''])
- .where.not(post_number: 1)
- .order("vote_count DESC, post_number ASC")
-
- count = 1
- answers.each do |a|
- a.update(sort_order: count)
- comments = posts.where(reply_to_post_number: a.post_number)
- .order("post_number ASC")
- if comments.any?
- comments.each do |c|
- count += 1
- c.update(sort_order: count)
- end
- else
- count += 1
- end
- end
- end
- end
-end
diff --git a/lib/qa_post_edits.rb b/lib/qa_post_edits.rb
index 1462d89..df94138 100644
--- a/lib/qa_post_edits.rb
+++ b/lib/qa_post_edits.rb
@@ -1,72 +1,103 @@
-::PostSerializer.class_eval do
- attributes :vote_count
-end
+module PostSerializerQAExtension
+ def actions_summary
+ summaries = super.reject { |s| s[:id] === PostActionType.types[:vote]}
+
+ if object.qa_enabled
+ user = scope.current_user
+ summary = {
+ id: PostActionType.types[:vote],
+ count: object.vote_count
+ }
+
+ voted = object.voted.include?(user.id)
-require 'post_actions_controller'
-class ::PostActionsController
- before_action :check_if_voted, only: :create
+ if voted
+ summary[:acted] = true
+ summary[:can_undo] = ::QuestionAnswer::Vote.can_undo(object, user)
+ else
+ summary[:can_act] = true
+ end
- def check_if_voted
- if current_user && params[:post_action_type_id].to_i === PostActionType.types[:vote] &&
- QAHelper.qa_enabled(@post.topic) && QAHelper.user_has_voted(@post.topic, current_user)
- raise Discourse::InvalidAccess.new, I18n.t('vote.alread_voted')
+ summary.delete(:count) if summary[:count] == 0
+
+ if summary[:can_act] || summary[:count]
+ summaries + [summary]
+ else
+ summaries
+ end
+ else
+ summaries
end
end
end
-class ::Post
- after_create :update_qa_order, if: :qa_enabled
+require_dependency 'post_serializer'
+class ::PostSerializer
+ prepend PostSerializerQAExtension
- def qa_enabled
- QAHelper.qa_enabled(topic)
+ attributes :vote_count, :voted
+
+ def vote_count
+ object.vote_count
end
- def update_qa_order
- QAHelper.update_order(topic_id)
+ def voted
+ object.voted
end
end
-class ::PostAction
- after_commit :update_qa_order, if: :is_vote?
+## 'vote_count' and 'voted' are used for quick access, whereas 'vote_history' is used for record keeping
+## See QuestionAnswer::Vote for how these fields are saved / updated
+
+Post.register_custom_field_type('vote_count', :integer)
+Post.register_custom_field_type('vote_history', :json)
+
+class ::Post
+ after_create :update_vote_order, if: :qa_enabled
+
+ self.ignored_columns = %w(vote_count)
- def is_vote?
- post_action_type_id == PostActionType.types[:vote]
+ def vote_count
+ if custom_fields['vote_count'].present?
+ custom_fields['vote_count'].to_i
+ else
+ 0
+ end
end
- def notify_subscribers
- if (is_like? || is_flag? || is_vote?) && post
- post.publish_change_to_clients! :acted
+ def voted
+ if custom_fields['voted'].present?
+ [*custom_fields['voted']].map(&:to_i)
+ else
+ []
end
end
- def update_qa_order
- topic_id = Post.where(id: post_id).pluck(:topic_id).first
- QAHelper.update_order(topic_id)
+ def vote_history
+ if custom_fields['vote_history'].present?
+ [*custom_fields['vote_history']]
+ else
+ []
+ end
end
-end
-module PostGuardianVoteExtension
- def can_delete_post_action?(post_action)
- # only use extension if post_action is a vote
- return super(post_action) unless post_action.post_action_type_id == PostActionType.types[:vote]
+ def qa_enabled
+ ::Topic.qa_enabled(topic)
+ end
- # You can only undo your own actions
- return false unless is_my_own?(post_action) && not(post_action.is_private_message?)
+ def update_vote_order
+ ::Topic.update_vote_order(topic_id)
+ end
- # Apply vote action window if it exists
- vote_window = SiteSetting.qa_undo_vote_action_window.to_i
- if vote_window == 0
- return true
- elsif vote_window.present?
- return post_action.created_at > vote_window.minutes.ago
+ def last_voted(user_id)
+ user_votes = vote_history.select do |v|
+ v['user_id'].to_i === user_id && v['action'] === 'create'
end
- # Use post action setting as default
- post_action.created_at > SiteSetting.post_undo_action_window_mins.minutes.ago
+ if user_votes.any?
+ user_votes.sort_by { |v| v['created_at'].to_i }.first['created_at'].to_datetime
+ else
+ nil
+ end
end
end
-
-require_dependency 'guardian'
-class ::Guardian
- prepend PostGuardianVoteExtension
-end
diff --git a/lib/qa_topic_edits.rb b/lib/qa_topic_edits.rb
index 3b66209..7c852e5 100644
--- a/lib/qa_topic_edits.rb
+++ b/lib/qa_topic_edits.rb
@@ -1,4 +1,4 @@
-module QATopicExtension
+module TopicQAExtension
def reload(options = nil)
@answers = nil
@comments = nil
@@ -40,7 +40,6 @@ def last_commented_on
def last_answer_post_number
if answers.any?
- puts "HERE ARE THE ANSWERS: #{answers.inspect}"
answers.last[:post_number]
else
nil
@@ -49,7 +48,7 @@ def last_answer_post_number
def last_answerer
if answers.any?
- @last_answerer ||= User.find(answers.last[:user_id])
+ @last_answerer ||= ::User.find(answers.last[:user_id])
else
nil
end
@@ -58,17 +57,67 @@ def last_answerer
require_dependency 'topic'
class ::Topic
- prepend QATopicExtension
+ prepend TopicQAExtension
+
+ def self.voted(topic, user)
+ return nil if !user || !SiteSetting.qa_enabled
+
+ PostCustomField.exists?(post_id: topic.posts.map(&:id),
+ name: 'voted',
+ value: user.id)
+ end
+
+ def self.qa_enabled(topic)
+ return false if !SiteSetting.qa_enabled
+ return false if !topic || !topic.respond_to?(:is_category_topic?) || topic.is_category_topic?
+
+ tags = topic.tags.map(&:name)
+ has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty?
+ is_qa_category = topic.category && topic.category.custom_fields["qa_enabled"]
+ is_qa_subtype = topic.subtype == 'question'
+
+ has_qa_tag || is_qa_category || is_qa_subtype
+ end
+
+ def self.update_vote_order(topic_id)
+ return if !SiteSetting.qa_enabled
+
+ posts = Post.where(topic_id: topic_id)
+
+ answers = posts.where(reply_to_post_number: [nil, ''])
+ .where.not(post_number: 1)
+ .order("(
+ SELECT COALESCE ((
+ SELECT value::integer FROM post_custom_fields
+ WHERE post_id = posts.id AND name = 'vote_count'
+ ), 0)
+ ) DESC, post_number ASC")
+
+ count = 1
+ answers.each do |a|
+ a.update(sort_order: count)
+ comments = posts.where(reply_to_post_number: a.post_number)
+ .order("post_number ASC")
+ if comments.any?
+ comments.each do |c|
+ count += 1
+ c.update(sort_order: count)
+ end
+ else
+ count += 1
+ end
+ end
+ end
end
module TopicViewQAExtension
def qa_enabled
- QAHelper.qa_enabled(@topic)
+ ::Topic.qa_enabled(@topic)
end
def filter_posts_by_ids(post_ids)
if qa_enabled
- posts = Post.where(id: post_ids, topic_id: @topic.id)
+ posts = ::Post.where(id: post_ids, topic_id: @topic.id)
.includes(:user, :reply_to_user, :incoming_email)
@posts = posts.order("case when post_number = 1 then 0 else 1 end, sort_order ASC")
@posts = filter_post_types(@posts)
@@ -80,11 +129,12 @@ def filter_posts_by_ids(post_ids)
end
end
-class ::TopicView
+class TopicView
prepend TopicViewQAExtension
end
-require 'topic_view_serializer'
+require_dependency 'topic_view_serializer'
+require_dependency 'basic_user_serializer'
class ::TopicViewSerializer
attributes :qa_enabled,
:voted,
@@ -96,11 +146,11 @@ class ::TopicViewSerializer
:last_answerer
def qa_enabled
- @qa_enabled ||= QAHelper.qa_enabled(object.topic)
+ object.qa_enabled
end
def voted
- scope.current_user && QAHelper.user_has_voted(object.topic, scope.current_user)
+ scope.current_user && ::Topic.voted(object.topic, scope.current_user)
end
def last_answered_at
@@ -144,7 +194,7 @@ def include_last_answer_post_number?
end
def last_answerer
- BasicUserSerializer.new(object.topic.last_answerer, scope: scope, root: false)
+ ::BasicUserSerializer.new(object.topic.last_answerer, scope: scope, root: false)
end
def include_last_answerer
diff --git a/plugin.rb b/plugin.rb
index 7d523a4..0d5ee56 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -14,7 +14,9 @@
Category.register_custom_field_type('qa_enabled', :boolean)
add_to_serializer(:basic_category, :qa_enabled) { object.custom_fields["qa_enabled"] }
- load File.expand_path('../lib/qa_helper.rb', __FILE__)
+ PostActionType.types[:vote] = 100
+
+ load File.expand_path('../lib/qa.rb', __FILE__)
load File.expand_path('../lib/qa_post_edits.rb', __FILE__)
load File.expand_path('../lib/qa_topic_edits.rb', __FILE__)
end